From e6fd1461d7a8eb5922609cb6146a9ea963b72150 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 23:42:04 +0700 Subject: [PATCH] Timeline: the bar past a layer's source is striped; time remapping it ends at the source's end (#290) A footage or precomp layer dragged out past its source's end holds its last frame, but its bar looked the same all the way, so the source's end couldn't be seen. That part of the bar (and any part before the source's start) is now darkened and striped in the label colour, with an automation id (timeline.layer..bar.afterSource / beforeSource); with time remapping on, the remap decides what shows there, so it isn't. Enable Time Remapping on such a layer put its end key at the extended out point; it now ends at the source's end, where the last frame starts to hold (After Effects). Trimmed and reversed layers keep their keys. Tests: time_remap_on_an_extended_layer_ends_at_the_source_end (engine), the_bar_past_the_source_end_is_striped (ui_timeline_switches). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer_time.rs | 12 +++++++- crates/engine/src/tests_timeline.rs | 16 ++++++++++ crates/ui-egui/src/panels/timeline.rs | 31 ++++++++++++++++++++ crates/ui-egui/tests/ui_timeline_switches.rs | 24 ++++++++++++++- 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/layer_time.rs b/crates/engine/src/commands/layer_time.rs index 80c1b420..335481f7 100644 --- a/crates/engine/src/commands/layer_time.rs +++ b/crates/engine/src/commands/layer_time.rs @@ -86,6 +86,10 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result { if !comp.layers.iter().filter(|l| ids.contains(&l.id)).any(remappable) { return Err(bad("layer.enableTimeRemap", "time remapping needs a footage or composition layer")); } + // Each layer's source duration (stills have none): a layer extended past it ends its remap + // at the source's end, where its last frame starts to hold (After Effects). + let src_end: Vec<(LayerId, Tick)> = + comp.layers.iter().filter(|l| ids.contains(&l.id)).filter_map(|l| Some((l.id, l.source.item().and_then(|i| s.project.item(i))?.duration()?))).collect(); let on = s.edit("Enable Time Remapping", None, |proj, _| { let mut next = proj.next_id; let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; @@ -97,7 +101,13 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result { if target && !has { let mut pr = remap_prop(&mut Ids(&mut next)); let (lin, lout) = (l.layer_time(l.in_point), l.layer_time(l.out_point)); - let (a, b) = (lin.min(lout), lin.max(lout)); + let (a, mut b) = (lin.min(lout), lin.max(lout)); + if let Some((_, end)) = src_end.iter().find(|(id, _)| *id == l.id) + && lin <= lout + && *end > a + { + b = b.min(*end); + } pr.value = KV::Scalar(source_secs(l, l.in_point)); pr.keys = vec![Keyframe::new(a, KV::Scalar(a.seconds())), Keyframe::new(b, KV::Scalar(b.seconds()))]; if lin > lout { diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index b4c1cf50..29a440ce 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -573,6 +573,22 @@ fn deleting_time_remap_turns_time_remapping_off() { assert_eq!(prop(&s, l, "timeRemap").keys.len(), 1); } +/// Enable Time Remapping on a layer extended past its source's end (its last frame frozen) puts +/// the end key at the source's end, not at the extended out point (#290). +#[test] +fn time_remap_on_an_extended_layer_ends_at_the_source_end() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap(); + let l = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layers": [l], "out": 4.0})).unwrap(); + s.execute("layer.enableTimeRemap", json!({"layers": [l]})).unwrap(); + let p = prop(&s, l, "timeRemap"); + let keys: Vec<(f64, KV)> = p.keys.iter().map(|k| (k.time.seconds(), k.value.clone())).collect(); + assert_eq!(keys, vec![(0.0, KV::Scalar(0.0)), (2.0, KV::Scalar(2.0))]); + assert_eq!(s.active_comp().unwrap().layers[0].out_point.seconds(), 4.0, "the layer stays extended"); +} + #[test] fn freeze_on_last_frame_extends_the_layer() { let (mut s, l) = precomp_setup(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 662efdb5..c9456b4e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -73,6 +73,23 @@ pub(crate) fn bar_color(label: Color32, selected: bool) -> Color32 { Color32::from_rgb(mix(label.r()), mix(label.g()), mix(label.b())) } +/// Darken part `r` of a layer bar and stripe it diagonally in its label colour (where the layer +/// runs past its source). Only the visible part is drawn. +fn stripes(p: &egui::Painter, r: Rect, label: Color32) { + let v = r.intersect(p.clip_rect()); + if !v.is_positive() { + return; + } + let p = p.with_clip_rect(v); + p.rect_filled(v, 0.0, Color32::from_black_alpha(90)); + let h = v.height(); + let mut x = v.min.x - h; + while x < v.max.x { + p.line_segment([pos2(x, v.max.y), pos2(x + h, v.min.y)], Stroke::new(1.0, label.gamma_multiply(0.7))); + x += 6.0; + } +} + /// Row height (expression editors grow with their text). fn row_height(row: &Row, rh: f32) -> f32 { match row.kind { @@ -1818,6 +1835,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let xe = tm.x(layer.comp_time(d).seconds()); let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0)); gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18)); + // The bar past the source's end (its last frame held) or before its start + // is striped, so the source's own span shows (#290). Time remapping + // decides what shows there itself. + if layer.props.get("timeRemap").is_none() { + for (side, part) in [ + ("beforeSource", Rect::from_min_max(bar.min, pos2(ghost.min.x, bar.max.y))), + ("afterSource", Rect::from_min_max(pos2(ghost.max.x, bar.min.y), bar.max)), + ] { + if part.width() >= 1.0 { + stripes(&gp, part, lc); + app.auto.add(&format!("timeline.layer.{}.bar.{side}", layer.id.0), part, "Past the source"); + } + } + } // Dragging the source bar outside the in/out span slips the source. for (side, gr) in [ ("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))), diff --git a/crates/ui-egui/tests/ui_timeline_switches.rs b/crates/ui-egui/tests/ui_timeline_switches.rs index a37125b7..9c410af9 100644 --- a/crates/ui-egui/tests/ui_timeline_switches.rs +++ b/crates/ui-egui/tests/ui_timeline_switches.rs @@ -1,6 +1,7 @@ //! Timeline layer switches with real pointer input (egui_kittest): pressing a switch and //! dragging over other layers gives them all the state the first one got, in one undo step, as -//! in After Effects (#227). +//! in After Effects (#227); stopwatches the same way (#284). Also the layer bar past its source +//! (#290). use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; @@ -144,3 +145,24 @@ fn dragging_over_stopwatches_sets_them_all() { h.run_steps(2); assert_eq!(animated(&h), [true; 5], "each drag was one step"); } + +/// A layer extended past its source's end (its last frame held) has that part of its bar +/// striped, so the source's own span shows (#290); with time remapping it isn't. +#[test] +fn the_bar_past_the_source_end_is_striped() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap(); + let l = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let past = format!("timeline.layer.{l}.bar.afterSource"); + assert!(h.state().auto.find(&past).is_none(), "the source fills the whole bar"); + h.state_mut().session.execute("layer.timing", json!({"layers": [l], "out": 4.0})).unwrap(); + h.run_steps(2); + let (bar, after) = (rect(&h, &format!("timeline.layer.{l}.bar")), rect(&h, &past)); + assert!((after.min.x - bar.center().x).abs() < 2.0 && (after.max.x - bar.max.x).abs() < 1.0, "{bar:?} {after:?}"); + h.state_mut().session.execute("layer.enableTimeRemap", json!({"layers": [l]})).unwrap(); + h.run_steps(2); + assert!(h.state().auto.find(&past).is_none(), "time remapping decides what shows"); +}