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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 22:19:29 -04:00
parent 0acc106175
commit 702ca1757f
6 changed files with 69 additions and 14 deletions

View File

@ -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<Value> {
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();

View File

@ -235,12 +235,13 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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();

View File

@ -25,7 +25,8 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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(())
})?;

View File

@ -38,6 +38,14 @@ fn with_prop<T>(
})
}
/// 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<Tick> {
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<Value> {
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<Value> {
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<Value> {
fn add_key(s: &mut Session, p: &Value) -> Result<Value> {
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);

View File

@ -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] {

View File

@ -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();