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.<id>.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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 23:42:04 +07:00
parent 567a947fd8
commit e6fd1461d7
4 changed files with 81 additions and 2 deletions

View File

@ -86,6 +86,10 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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 {

View File

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

View File

@ -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))),

View File

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