mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 18:18:17 -06:00
Project::comp_contains recursed through precomp layers with no visited
set or depth limit. A damaged project with a precomp cycle that doesn't
pass the queried comp (A and B nesting each other, C unrelated) recursed
until the stack ran out, which aborts the process ("stack overflow,
aborting"): it can't be caught as a command error, so unsaved work is
lost. layer.addItem checks it before adding a precomp, and the renderer
calls it for every nested comp and its audio. A long chain used the
stack the same way, and comps shared by many paths were walked once per
path (exponential for nested diamonds).
It is now an explicit stack with a visited set: same answers (a comp
contains itself; a target reached through a cycle is found), each comp
visited once, no recursion. The renderer already skips a precomp that
contains the comp being rendered, so a cyclic project now renders
instead of crashing; nothing is repaired on load.
Tests: project comp_contains_ends_on_cycles_long_chains_and_shared_
precomps (cycle with an unrelated target, self-nesting, a target through
a cycle, 40 levels of diamonds, a 20 000-comp chain); engine
a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_
out_of_stack (file.open, layer.addItem both ways, render). On main the
engine test aborts with a stack overflow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
7.6 KiB
Rust
140 lines
7.6 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));
|
||
}
|
||
|
||
#[test]
|
||
fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() {
|
||
// Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A
|
||
// contains C walked the A ⇄ B cycle until the stack ran out (#1237).
|
||
let mut s = Session::default();
|
||
let mut ids = vec![];
|
||
for name in ["A", "B", "C"] {
|
||
s.execute("comp.new", json!({"name": name, "width": 32, "height": 32, "frameRate": 30, "duration": 1})).unwrap();
|
||
ids.push(s.active_comp_id().unwrap());
|
||
}
|
||
let (a, b, c) = (ids[0], ids[1], ids[2]);
|
||
{
|
||
let proj = std::sync::Arc::make_mut(&mut s.project);
|
||
for (outer, inner) in [(a, b), (b, a)] {
|
||
let comp = proj.comp(outer).unwrap().clone();
|
||
let l = effectcraft_project::build::layer(proj, &comp, "pre", effectcraft_project::LayerSource::Comp { item: inner }, (32, 32), None);
|
||
let ItemKind::Comp(cc) = &mut proj.item_mut(outer).unwrap().kind else { panic!("comp") };
|
||
std::sync::Arc::make_mut(cc).layers.push(l);
|
||
}
|
||
}
|
||
let path = tmp("cycle.ecproj");
|
||
std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap();
|
||
let mut t = Session::default();
|
||
t.execute("file.open", json!({"path": path})).unwrap();
|
||
t.open_comp(c);
|
||
// Adding A into C asks whether A contains C: it doesn't, so the layer goes in.
|
||
let r = t.execute("layer.addItem", json!({"comp": c.0, "item": a.0})).unwrap();
|
||
assert!(r["layer"].as_u64().is_some(), "{r}");
|
||
// Adding C into A would nest C in itself through A: refused, as before.
|
||
assert!(t.execute("layer.addItem", json!({"comp": a.0, "item": c.0})).is_err());
|
||
// C renders: inside A the renderer skips B's copy of A instead of looping.
|
||
let img = t.render(c, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
|
||
assert_eq!((img.width, img.height), (32, 32));
|
||
}
|