Files
effectcraft/crates/engine/src/tests_limits.rs
T
DWAA1660andClaude Opus 5.5 b26a340a7e Project: comp_contains walks precomps with a visited set, not recursion (#1237)
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>
2026-10-13 16:26:42 -06:00

140 lines
7.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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));
}