From f013bdd8d76e08dca310837ea8bcdfec57919c7a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 15:12:06 +0700 Subject: [PATCH] M13.33: nested comp markers on the precomp layer bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commands::markers::nested_markers: a precomp layer's comp markers at their times in the containing comp (start and stretch in closed form; with time remapping or a stretch over protected regions, the first frame whose source time reaches them), within In–Out. - markers.nested {layer}: the same list for agents. - Timeline: drawn on the precomp bar as outlined read-only markers (positions cached per revision), hover names them, double-click opens the nested comp at the marker; automation id timeline.layer..nestedMarker.. - Tests: engine (start, stretch, trim, time remap, non-precomp error), UI (draw, label, open). Docs: parity, control protocol. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/markers.rs | 58 ++++++++++++++++++++++++- crates/engine/src/commands/mod.rs | 2 +- crates/engine/src/tests_markers.rs | 34 +++++++++++++++ crates/ui-egui/src/panels/markers_ui.rs | 49 ++++++++++++++++++++- crates/ui-egui/tests/ui_tabs.rs | 22 ++++++++++ docs/control-protocol.md | 2 +- docs/parity.md | 10 ++++- 7 files changed, 171 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/commands/markers.rs b/crates/engine/src/commands/markers.rs index c9e4e285..4c979619 100644 --- a/crates/engine/src/commands/markers.rs +++ b/crates/engine/src/commands/markers.rs @@ -5,7 +5,7 @@ //! comp seconds; layer markers are stored in layer time. All commands are undoable. use effectcraft_color::Label; -use effectcraft_project::{Comp, CuePoint, ItemId, LayerId, LayerSource, Marker}; +use effectcraft_project::{Comp, CuePoint, ItemId, Layer, LayerId, LayerSource, Marker, Project}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -85,6 +85,56 @@ fn list(s: &mut Session, p: &Value) -> Result { Ok(Value::Array(ms.iter().enumerate().map(|(i, m)| marker_json(c, o, i, m)).collect())) } +/// The nested comp's markers on a precomp layer, at their times in the containing comp `comp` +/// and within the layer's In–Out range, as After Effects shows them on the precomp layer's bar: +/// `(comp time, index in the nested comp, marker)`. The layer's start time and stretch map them; +/// with time remapping, or a stretch over protected regions (Responsive Design), a marker sits at +/// the first frame the layer shows its time. +pub fn nested_markers<'a>(project: &'a Project, comp_id: ItemId, comp: &Comp, layer: &Layer) -> Vec<(Tick, usize, &'a Marker)> { + let LayerSource::Comp { item } = layer.source else { return vec![] }; + let Some(nc) = project.comp(item) else { return vec![] }; + if nc.markers.is_empty() || item == comp_id { + return vec![]; + } + let shown = |t: Tick| t >= layer.in_point && t < layer.out_point; + let stretched_regions = (layer.stretch - 100.0).abs() > 1e-9 && nc.markers.iter().any(|m| m.protected); + if layer.props.get("timeRemap").is_none() && !stretched_regions { + return nc.markers.iter().enumerate().map(|(i, m)| (layer.comp_time(m.time), i, m)).filter(|(t, _, _)| shown(*t)).collect(); + } + // The layer's source time at every frame of its range; each marker at the first frame that + // reaches it (within rounding: a thousandth of a nested frame). + let fr = comp.frame_rate; + let (f0, f1) = (fr.frame_at(layer.in_point), fr.frame_at(layer.out_point)); + let frames: Vec<(Tick, Tick)> = (f0..f1.min(f0.saturating_add(100_000))) + .map(|f| { + let t = fr.tick_of(f); + (t, effectcraft_render::EvalCtx::new(project, comp_id, comp, t).source_time(layer)) + }) + .collect(); + let eps = nc.frame_duration().0 / 1000; + nc.markers + .iter() + .enumerate() + .filter_map(|(i, m)| frames.iter().find(|(_, src)| src.0.saturating_add(eps) >= m.time.0).map(|(t, _)| (*t, i, m))) + .filter(|(t, _, _)| shown(*t)) + .collect() +} + +/// `markers.nested`: the nested comp's markers on a precomp layer (see [`nested_markers`]). +fn nested(s: &mut Session, p: &Value) -> Result { + let cmd = "markers.nested"; + let cid = comp_id(s, p)?; + let c = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let lid = p.get("layer").and_then(|v| resolve_layer(c, v)).ok_or_else(|| bad(cmd, "missing `layer` (a precomp layer)"))?; + let l = c.layer(lid).ok_or_else(|| bad(cmd, "no such layer"))?; + let LayerSource::Comp { item } = l.source else { return Err(bad(cmd, "not a precomp layer")) }; + let v: Vec = nested_markers(&s.project, cid, c, l) + .into_iter() + .map(|(t, i, m)| json!({"time": t.seconds(), "comp": item.0, "index": i, "nestedTime": m.time.seconds(), "duration": m.duration.seconds(), "comment": m.comment, "label": m.label.name()})) + .collect(); + Ok(json!(v)) +} + fn locked(c: &Comp, o: Owner) -> bool { matches!(o, Owner::Layer(l) if c.layer(l).is_some_and(|l| l.markers_locked)) } @@ -299,6 +349,12 @@ fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> { pub fn specs() -> Vec { vec![ query!("markers.list", "List Markers", "{layer? (omit for composition markers)}", list), + query!( + "markers.nested", + "Nested Comp Markers", + "{layer: a precomp layer, comp?} — its comp's markers at their times in this comp (as on the layer bar)", + nested + ), cmd!( "markers.set", "Marker Settings", diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 546eaa44..54236f41 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -34,7 +34,7 @@ mod layer_time; pub(crate) mod link; mod liquify; mod lottie; -mod markers; +pub mod markers; mod mask; pub mod mask_interp; pub(crate) mod model3d; diff --git a/crates/engine/src/tests_markers.rs b/crates/engine/src/tests_markers.rs index c87c3815..6673241d 100644 --- a/crates/engine/src/tests_markers.rs +++ b/crates/engine/src/tests_markers.rs @@ -147,3 +147,37 @@ fn precompose_leave_and_move_attributes() { assert_eq!((nl.in_point.seconds(), nl.out_point.seconds()), (1.0, 3.0)); assert_eq!(s.active_comp_id(), Some(inner)); } + +/// A precomp layer shows its comp's markers on its bar, mapped through its timing. +#[test] +fn nested_comp_markers_follow_the_precomp_layers_timing() { + let mut s = crate::Session::default(); + let pre = s.execute("comp.new", json!({"name": "Pre", "width": 32, "height": 32, "frameRate": 10, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + for (t, c) in [(1.0, "beat"), (3.0, "drop")] { + s.execute("markers.set", json!({"new": true, "time": t, "comment": c})).unwrap(); + } + s.execute("comp.new", json!({"name": "Main", "width": 32, "height": 32, "frameRate": 10, "duration": 20})).unwrap(); + let l = s.execute("layer.addItem", json!({"item": pre})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layers": [l], "start": 2.0})).unwrap(); + let times = |s: &mut crate::Session| -> Vec<(f64, String)> { + let v = s.execute_checked("markers.nested", json!({"layer": l})).unwrap(); + v.as_array().unwrap().iter().map(|m| (m["time"].as_f64().unwrap(), m["comment"].as_str().unwrap().to_string())).collect() + }; + assert_eq!(times(&mut s), vec![(3.0, "beat".to_string()), (5.0, "drop".to_string())]); + // Stretched to 200 %: twice as far from the start. + s.execute("layer.timeStretch", json!({"layers": [l], "percent": 200})).unwrap(); + assert_eq!(times(&mut s), vec![(4.0, "beat".to_string()), (8.0, "drop".to_string())]); + s.execute("layer.timeStretch", json!({"layers": [l], "percent": 100})).unwrap(); + // Trimmed: markers outside the In–Out range are not on the bar. + s.execute("layer.timing", json!({"layers": [l], "out": 4.0})).unwrap(); + assert_eq!(times(&mut s), vec![(3.0, "beat".to_string())]); + s.execute("layer.timing", json!({"layers": [l], "out": 6.0})).unwrap(); + // Time remapping at half speed: nested 1 s is reached 2 s into the layer. + s.execute("layer.enableTimeRemap", json!({"layers": [l]})).unwrap(); + s.execute("prop.addKey", json!({"layer": l, "path": "timeRemap", "time": 4.0, "value": 2.0})).unwrap(); + let t = times(&mut s); + assert_eq!(t.first(), Some(&(4.0, "beat".to_string())), "{t:?}"); + // Not a precomp layer. + let solid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.execute("markers.nested", json!({"layer": solid})).is_err()); +} diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index ce57e10f..e6bcfa64 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -367,14 +367,61 @@ pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &C } } -/// Layer markers on a layer's row `r`. +/// Layer markers on a layer's row `r`; on a precomp layer, its comp's markers too (read-only, +/// outlined: hover names them, a double-click opens the nested comp at the marker). pub(crate) fn layer_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, clip: Rect, comp: &Comp, layer: &Layer, tm: TMap, r: Rect) { let p = ui.painter().with_clip_rect(clip); + nested_markers(app, ui, &p, comp, layer, tm, r); for (i, m) in layer.markers.iter().enumerate() { marker(app, ui, &p, comp, tm, m, layer.comp_time(m.time).seconds(), MarkerRef { layer: Some(layer.id.0), index: i }, r.min.y + 3.0, r.max.y - 3.0); } } +/// The nested comp's markers on a precomp layer's bar (`markers.nested`). +fn nested_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, layer: &Layer, tm: TMap, r: Rect) { + let effectcraft_engine::project::LayerSource::Comp { item } = layer.source else { return }; + let Some(cid) = app.session.active_comp_id() else { return }; + let project = app.session.project.clone(); + let Some(nc) = project.comp(item) else { return }; + if nc.markers.is_empty() { + return; + } + // Where they land, once per revision (with time remapping that samples the layer's frames). + let (key, rev) = (egui::Id::new(("nested-markers", cid.0, layer.id.0)), app.session.revision); + let cached = ui.ctx().data(|d| d.get_temp::<(u64, Vec<(Tick, usize)>)>(key)).filter(|(r, _)| *r == rev); + let at = cached.map(|(_, v)| v).unwrap_or_else(|| { + let v: Vec<(Tick, usize)> = + effectcraft_engine::commands::markers::nested_markers(&project, cid, comp, layer).into_iter().map(|(t, i, _)| (t, i)).collect(); + ui.ctx().data_mut(|d| d.insert_temp(key, (rev, v.clone()))); + v + }); + let list: Vec<(Tick, usize, &Marker)> = at.into_iter().filter_map(|(t, i)| Some((t, i, nc.markers.get(i)?))).collect(); + let t = app.tokens; + let name = project.item(item).map(|i| i.name.clone()).unwrap_or_default(); + let (y0, h) = (r.min.y + 3.0, (r.height() - 6.0).min(10.0)); + for (ct, i, m) in list { + let x = tm.x(ct.seconds()); + let col = if m.label == effectcraft_engine::color::Label::None { Color32::from_rgb(0xd8, 0xd8, 0x60) } else { t.label(m.label) }.gamma_multiply(0.75); + let ym = y0 + h * 0.6; + let pts = vec![pos2(x - 4.0, y0), pos2(x + 4.0, y0), pos2(x + 4.0, ym), pos2(x, y0 + h), pos2(x - 4.0, ym)]; + p.add(egui::Shape::closed_line(pts, Stroke::new(1.2, col))); + if !m.comment.is_empty() { + p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text_dim); + } + let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, r.max.y - 3.0)); + let tip = if m.comment.is_empty() { format!("Marker in {name}") } else { format!("{} (marker in {name})", m.comment) }; + let resp = ui.interact(hit, egui::Id::new(("nested-marker", layer.id.0, i)), Sense::click()).on_hover_text(&tip); + app.auto.add(&format!("timeline.layer.{}.nestedMarker.{i}", layer.id.0), hit, &tip); + if resp.double_clicked() { + let opened = + app.session.execute("comp.open", json!({"comp": item.0})).and_then(|_| app.session.execute("time.set", json!({"time": m.time.seconds()}))); + if let Err(e) = opened { + app.ui.status = e.to_string(); + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index 11ddcb2b..d1d55a58 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -144,3 +144,25 @@ fn tab_drag_snapshot() { std::fs::create_dir_all(dir).unwrap(); h.render().expect("render").save(format!("{dir}/tab-drag.png")).unwrap(); } + +/// A precomp layer's bar shows its comp's markers; a double-click opens that comp at the marker. +#[test] +fn nested_comp_markers_show_on_the_precomp_bar() { + let (mut h, pre, main) = harness(); + { + let s = &mut h.state_mut().session; + s.execute("comp.open", json!({"comp": pre})).unwrap(); + s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "beat"})).unwrap(); + s.execute("comp.open", json!({"comp": main})).unwrap(); + } + h.run_steps(4); + let l = h.state().session.project.comp(ItemId(main)).unwrap().layers[0].id.0; + let id = format!("timeline.layer.{l}.nestedMarker.0"); + let m = rect(&h, &id); + let e = h.state().auto.find(&id).unwrap().clone(); + assert_eq!(e.label, "beat (marker in Pre)"); + click_n(&mut h, m.center(), 2); + assert_eq!(h.state().session.active_comp_id(), Some(ItemId(pre))); + // (29.97 fps: the marker's frame is at 1.001 s) + assert!((h.state().session.time().seconds() - 1.0).abs() < 0.034, "{}", h.state().session.time().seconds()); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ddb362df..bba88083 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -89,7 +89,7 @@ Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel. one), `panel.tab.Viewer()` and `viewers.` (View ▸ New Viewer's other Composition viewers; `viewers.0` is the Composition panel while another viewer is active), `header.workspace.Animation`, `project.item.` (a double-click opens the item, Enter renames it), `viewer.comp`, `viewer.handle..`, -`timeline.layer..bar`, `timeline.layer..twirl`, `timeline.layer..row` (click selects, +`timeline.layer..bar`, `timeline.layer..nestedMarker.` (a precomp's comp markers), `timeline.layer..twirl`, `timeline.layer..row` (click selects, Enter or a double-click renames, drag reorders), `timeline.prop..stopwatch`, `timeline.key..`, `timeline.cti`, `effectControls.prop..value` and `effects.item.`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`, diff --git a/docs/parity.md b/docs/parity.md index 404971e4..90d6367c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -422,6 +422,12 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph Editor keys are small squares whatever their interpolation, as in After Effects. +Since (M13.33): a precomp layer's bar shows its comp's markers, as outlined, read-only markers. +They are mapped through the layer's timing (start, stretch, time remapping, Responsive Design +stretch) and shown within its In–Out range. Hovering names them ("beat (marker in Pre)"), and a +double-click opens the nested comp at the marker. Agents read the same list with +`markers.nested {layer}`. + Since (M13.32): View ▸ New Viewer (Alt+Shift+N, also in the Composition panel menu) opens another Composition viewer as its own panel, as in After Effects. The viewer in use is locked, so it keeps its comp. One viewer is active: it is the interactive one and shows the active comp. @@ -751,8 +757,8 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r animators).~~ (M4.13) 17. ~~Content-keyed RAM preview frames, so an edit keeps the frames of comps it doesn't touch.~~ (M4.14) -18. ~~View ▸ New Viewer (several Composition viewers)~~ (M13.32); nested comp markers on the - precomp layer bar, audio scrubbing. +18. ~~View ▸ New Viewer (several Composition viewers)~~ (M13.32); ~~nested comp markers on the + precomp layer bar~~ (M13.33), audio scrubbing. At the original audit the engine underneath (keyframes, expressions, shape operators, text animators, Classic 3D, all 38 blend modes, the render queue) was already deep, and most of what was