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"); +}