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:
parent
0acc106175
commit
702ca1757f
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue