From 702ca1757ffd67d2d7420ef7c11a42bbe56d0ee4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:19:29 -0400 Subject: [PATCH] M5.7: frame-aligned layer, comp and keyframe times - Comp durations (comp.new / comp.settings) are whole frames. - layer.addItem start/out, layer.timing in/out/start/delta and time-stretch in/out snap to the nearest comp frame; prop.addKey / prop.set with an explicit time key the nearest frame. - The demo project builds every time on 29.97 fps frames (Lower Third in = frame 150 = 0:00:05:00, comp = 300 frames). - Test: demo + commands stay frame-aligned. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/comp.rs | 5 +-- crates/engine/src/commands/layer.rs | 13 ++++---- crates/engine/src/commands/layer_time.rs | 8 +++-- crates/engine/src/commands/prop.rs | 12 +++++-- crates/engine/src/demo.rs | 3 +- crates/engine/src/tests_timeline.rs | 42 ++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 07d34134..1553def1 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -24,7 +24,8 @@ fn apply_settings(c: &mut Comp, p: &Value) { } if let Some(d) = f_p(p, "duration") { let old = c.duration; - c.duration = Tick::from_seconds_f64(d.max(c.frame_rate.frame_duration().seconds())); + // Whole frames, at least one. + c.duration = c.frame_rate.snap_nearest(Tick::from_seconds_f64(d)).max(c.frame_rate.frame_duration()); if c.work_area.1 == old { c.work_area.1 = c.duration; } @@ -53,7 +54,7 @@ fn apply_settings(c: &mut Comp, p: &Value) { fn new_comp(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "name").unwrap_or("Comp 1").to_string(); let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97); - let mut c = Comp::new(1920, 1080, rate, Tick::from_seconds_f64(10.0)); + let mut c = Comp::new(1920, 1080, rate, rate.snap_nearest(Tick::from_seconds_f64(10.0))); apply_settings(&mut c, p); let id = s.edit("New Composition", None, |proj, st| { let mut name = name.clone(); diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 946728b2..f988b611 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -235,12 +235,13 @@ fn add_item(s: &mut Session, p: &Value) -> Result { ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None), ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")), }; - let start = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO); + let fr = comp.frame_rate; + let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO)); let id = s.edit("Add Footage to Comp", None, |proj, st| { let mut l = build::layer(proj, &comp, &it.name, src, size, dur); l.start_time = start; l.in_point = start; - l.out_point = (start + dur.unwrap_or(comp.duration)).min(comp.duration); + l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration()); insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) @@ -524,10 +525,10 @@ fn timing(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let t = s.time(); let op = str_p(p, "op").unwrap_or("set").to_string(); - let delta = f_p(p, "delta").map(Tick::from_seconds_f64); - let start = f_p(p, "start").map(Tick::from_seconds_f64); - let inp = f_p(p, "in").map(Tick::from_seconds_f64); - let outp = f_p(p, "out").map(Tick::from_seconds_f64); + // Layer times stay on frame boundaries of the comp (as in AE). + let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate; + let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v))); + let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out")); s.edit("Layer Timing", merge_p(p), |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fd = comp.frame_duration(); diff --git a/crates/engine/src/commands/layer_time.rs b/crates/engine/src/commands/layer_time.rs index 583d03bb..2bc031aa 100644 --- a/crates/engine/src/commands/layer_time.rs +++ b/crates/engine/src/commands/layer_time.rs @@ -25,7 +25,8 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { let dur = f_p(p, "duration"); let hold = str_p(p, "hold").unwrap_or("in").to_string(); let cti = s.time(); - let fd = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_duration(); + let comp_rate = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate; + let fd = comp_rate.frame_duration(); s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { @@ -57,8 +58,9 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { l.stretch = new; l.start_time = h - Tick((lt_h.0 as f64 * new / 100.0).round() as i64); let (a, b) = (l.comp_time(lin), l.comp_time(lout)); - l.in_point = a.min(b); - l.out_point = a.max(b); + let fr = comp_rate; + l.in_point = fr.snap_nearest(a.min(b)); + l.out_point = fr.snap_nearest(a.max(b)).max(l.in_point + fr.frame_duration()); } Ok(()) })?; diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 4895bc5f..9a15be8d 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -38,6 +38,14 @@ fn with_prop( }) } +/// An explicit key time (layer seconds) moved onto the nearest comp frame. +fn snapped_key_time(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Option { + let t = Tick::from_seconds_f64(f_p(p, "time")?); + let comp = s.project.comp(cid)?; + let l = comp.layer(lid)?; + Some(l.layer_time(comp.frame_rate.snap_nearest(l.comp_time(t)))) +} + fn set(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.set")?; // Position with Separate Dimensions: write the X/Y/Z Position properties instead. @@ -67,7 +75,7 @@ fn set(s: &mut Session, p: &Value) -> Result { return Ok(json!(out)); } let v = p.get("value").ok_or_else(|| bad("prop.set", "missing `value`"))?.clone(); - let at = f_p(p, "time").map(Tick::from_seconds_f64); + let at = snapped_key_time(s, cid, lid, p); let out = with_prop(s, "Change Property", merge_p(p), cid, lid, uid, |pr, lt| { let cur = pr.value_at(lt); let mut nv = cur.coerce_json(&v).ok_or_else(|| bad("prop.set", format!("can't use {v} for a {} property", cur.kind_name())))?; @@ -98,7 +106,7 @@ fn toggle_anim(s: &mut Session, p: &Value) -> Result { fn add_key(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.addKey")?; - let at = f_p(p, "time").map(Tick::from_seconds_f64); + let at = snapped_key_time(s, cid, lid, p); let value = p.get("value").cloned(); with_prop(s, "Add Keyframe", None, cid, lid, uid, |pr, lt| { let t = at.unwrap_or(lt); diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index eca58e6d..64ccb104 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -11,8 +11,9 @@ use effectcraft_time::{FrameRate, Tick}; pub const MAIN_COMP: &str = "EffectCraft Intro"; +/// Seconds → the nearest frame of the demo's 29.97 fps comps (AE keeps times frame-aligned). fn t(s: f64) -> Tick { - Tick::from_seconds_f64(s) + FrameRate::FPS_29_97.snap_nearest(Tick::from_seconds_f64(s)) } fn hex(h: &str) -> [f64; 4] { diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 6edf3e33..53405454 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -306,6 +306,48 @@ fn alt_click_stopwatch_toggles_a_self_reference_expression() { assert!(prop(&s, l, "transform/opacity").expr.unwrap().enabled); } +#[test] +fn layer_comp_and_key_times_stay_frame_aligned() { + let aligned = |fr: effectcraft_time::FrameRate, t: effectcraft_time::Tick| fr.snap(t) == t; + // The demo project: every comp duration, layer in/out/start and key time is on a frame. + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + for (_, c) in s.project.comps() { + let fr = c.frame_rate; + assert!(aligned(fr, c.duration), "duration {:?}", c.duration); + for l in &c.layers { + for t in [l.in_point, l.out_point, l.start_time] { + assert!(aligned(fr, t), "{} {t:?}", l.name); + } + l.props.walk("", &mut |_, pr| { + for k in &pr.keys { + assert!(aligned(fr, l.comp_time(k.time)), "{} {}", l.name, pr.name); + } + }); + } + } + let lower = s.active_comp().unwrap().layer_by_name("Lower Third").unwrap(); + let fr = s.active_comp().unwrap().frame_rate; + assert_eq!(fr.frame_at(lower.in_point), 150, "5 s at 29.97 is frame 150 (0:00:05:00)"); + // Commands snap: comp duration, layer timing, key times. + s.execute("comp.new", json!({"name": "F", "frameRate": 29.97, "duration": 10.0})).unwrap(); + let c = s.active_comp().unwrap(); + assert_eq!(c.frame_rate.frame_at(c.duration), 300); + assert!(aligned(c.frame_rate, c.duration)); + let l = s.execute("layer.newSolid", json!({"color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layers": [l], "in": 1.01, "out": 3.3333, "delta": 0.02})).unwrap(); + let ly = s.active_comp().unwrap().layers[0].clone(); + let fr = s.active_comp().unwrap().frame_rate; + for t in [ly.in_point, ly.out_point, ly.start_time] { + assert!(aligned(fr, t), "{t:?}"); + } + s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": 1.2345, "value": 10})).unwrap(); + let k = prop(&s, l, "transform/opacity").keys[0].time; + assert!(aligned(fr, ly.comp_time(k))); + // Layer shifted one frame (delta 0.02 s rounds to 1 frame): 1.2345 s + 1 frame -> frame 38. + assert_eq!(fr.frame_at(ly.comp_time(k)), 38); +} + #[test] fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() { let mut s = Session::default();