mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 02:27:00 -06:00
Problem: comp.new/comp.settings clamp sizes to 30,000 px, frame rates to 0.001-1000 fps and motion-blur samples to 2-64, but reading a project (.ecproj and .ecprojx, templates, recovery, offload) accepted any value: a 4-billion-px comp, solid or footage frame, a 0 or 10^18 fps rate (zero-length frames), a negative duration or 4 billion blur samples all reached the renderer. New Solid, Layer Settings, Import Solid/Placeholder, Crop Comp and PSD import set sizes without the upper bound, and comp sizes past 2^32 wrapped. Fix: one set of limits in effectcraft_project::limits. Project::from_json brings every item (and proxy) within them; from_json_repaired also says what it changed, which file.open returns as `repaired` and shows in a toast, so the project still opens. Project::add_item applies the same limits to every new item, and the size-setting commands clamp with the shared helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
105 lines
5.8 KiB
Rust
105 lines
5.8 KiB
Rust
//! Sizes, frame rates and motion-blur samples stay within the project limits when a project is
|
||
//! opened and when a command sets them (#788).
|
||
|
||
use effectcraft_project::{ItemKind, limits};
|
||
use effectcraft_render::RenderOpts;
|
||
use effectcraft_time::{FrameRate, Tick};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::{Event, Session};
|
||
|
||
fn tmp(name: &str) -> String {
|
||
let d = std::env::temp_dir().join(format!("effectcraft-limits-tests-{}", std::process::id()));
|
||
std::fs::create_dir_all(&d).unwrap();
|
||
d.join(name).to_string_lossy().to_string()
|
||
}
|
||
|
||
/// The saved project with `edit` applied to the JSON of every item of kind `kind`.
|
||
fn tamper(text: &str, kind: &str, edit: impl Fn(&mut Value)) -> String {
|
||
let mut v: Value = serde_json::from_str(text).unwrap();
|
||
let items = v["items"].as_object_mut().unwrap();
|
||
for it in items.values_mut().filter(|it| it["kind"]["type"] == kind) {
|
||
edit(&mut it["kind"]);
|
||
}
|
||
serde_json::to_string(&v).unwrap()
|
||
}
|
||
|
||
/// A project whose comp is 4 billion × 0 px at 10^18 fps, -5 s long with 4 billion motion-blur
|
||
/// samples, holding a solid of 4 billion px.
|
||
fn hostile_project() -> String {
|
||
let mut s = Session::default();
|
||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap();
|
||
s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 64, "height": 2})).unwrap();
|
||
let text = s.project.to_file_json().unwrap();
|
||
let text = tamper(&text, "Comp", |c| {
|
||
c["width"] = json!(4_000_000_000u64);
|
||
c["height"] = json!(0);
|
||
c["frame_rate"] = json!({"num": 1_000_000_000_000_000_000i64, "den": 1});
|
||
c["duration"] = json!(-5);
|
||
c["motion_blur_samples"] = json!(4_000_000_000u64);
|
||
});
|
||
tamper(&text, "Solid", |so| so["width"] = json!(4_000_000_000u64))
|
||
}
|
||
|
||
#[test]
|
||
fn a_hostile_project_opens_within_the_limits_and_says_what_changed() {
|
||
let json_path = tmp("Hostile.ecproj");
|
||
let text = hostile_project();
|
||
std::fs::write(&json_path, &text).unwrap();
|
||
let xml_path = tmp("Hostile.ecprojx");
|
||
std::fs::write(&xml_path, crate::xml_project::to_xml(&text).unwrap()).unwrap();
|
||
for path in [json_path, xml_path] {
|
||
let mut s = Session::default();
|
||
let r = s.execute("file.open", json!({"path": path})).unwrap();
|
||
let repaired = r["repaired"].as_array().unwrap();
|
||
assert!(repaired.iter().any(|n| n.as_str().unwrap().contains("4000000000 × 0 px → 30000 × 1 px")), "{path}: {repaired:?}");
|
||
let toast = s.drain_events().into_iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("outside EffectCraft's limits")));
|
||
assert!(toast, "{path}");
|
||
let (cid, c) = s.project.comps().next().map(|(id, c)| (*id, c.clone())).unwrap();
|
||
assert_eq!((c.width, c.height), (limits::MAX_SIZE, 1));
|
||
assert_eq!(c.frame_rate.as_f64(), limits::MAX_FPS);
|
||
assert_eq!(c.duration, c.frame_rate.frame_duration(), "at least one frame");
|
||
assert_eq!(c.work_area, (Tick::ZERO, c.duration));
|
||
assert_eq!(c.motion_blur_samples, limits::MOTION_BLUR_SAMPLES.1);
|
||
let solid = s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
|
||
assert_eq!((solid.width, solid.height), (limits::MAX_SIZE, 2));
|
||
// The comp renders at Full resolution, at its (capped) size.
|
||
let img = s.render(cid, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
|
||
assert_eq!((img.width, img.height), (limits::MAX_SIZE, 1));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_project_within_the_limits_opens_unchanged() {
|
||
let mut s = Session::default();
|
||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 24, "duration": 2})).unwrap();
|
||
let text = s.project.to_file_json().unwrap();
|
||
let (p, notes) = effectcraft_project::Project::from_json_repaired(&text).unwrap();
|
||
assert!(notes.is_empty(), "{notes:?}");
|
||
assert_eq!(p, *s.project);
|
||
}
|
||
|
||
#[test]
|
||
fn commands_cap_sizes_at_30000_px() {
|
||
let mut s = Session::default();
|
||
// 2^32 + 10 used to wrap to 10 px.
|
||
s.execute("comp.new", json!({"name": "Main", "width": 4_294_967_306u64, "height": 32, "pixelAspect": 1e9})).unwrap();
|
||
let c = s.active_comp().unwrap().clone();
|
||
assert_eq!((c.width, c.height, c.pixel_aspect), (limits::MAX_SIZE, 32, limits::MAX_PIXEL_ASPECT));
|
||
s.execute("comp.settings", json!({"width": 64, "height": 4_294_967_306u64})).unwrap();
|
||
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, limits::MAX_SIZE));
|
||
// New Solid: 0 and 4 billion px are brought to 1 and 30,000; the layer matches its solid.
|
||
s.execute("layer.newSolid", json!({"width": 0, "height": 4_000_000_000u64})).unwrap();
|
||
let solid = |s: &Session| s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
|
||
assert_eq!((solid(&s).width, solid(&s).height), (1, limits::MAX_SIZE));
|
||
// Layer Settings on the new (selected) solid layer.
|
||
s.execute("layer.settings", json!({"width": 4_000_000_000u64, "height": 8})).unwrap();
|
||
assert_eq!((solid(&s).width, solid(&s).height), (limits::MAX_SIZE, 8));
|
||
s.execute("file.importSolid", json!({"width": 99_999_999, "height": 4})).unwrap();
|
||
assert!(s.project.items.values().any(|i| matches!(&i.kind, ItemKind::Solid(so) if (so.width, so.height) == (limits::MAX_SIZE, 4))));
|
||
// A placeholder can't ask for a huge frame or a zero frame rate either.
|
||
s.execute("file.importPlaceholder", json!({"width": 4_000_000_000u64, "height": 8, "frameRate": 0})).unwrap();
|
||
let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap();
|
||
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
|
||
}
|