M13.33: nested comp markers on the precomp layer bar

- 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.<id>.nestedMarker.<i>.
- 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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 15:12:06 +07:00
1 parent 1ac55c33a3
commit f013bdd8d7
7 files changed
+171 -6

No files matched your search

+57 -1
View File
@@ -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<Value> {
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<Value> {
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<Value> = 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<CommandSpec> {
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",
+1 -1
View File
@@ -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;
+34
View File
@@ -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());
}
+48 -1
View File
@@ -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::*;
+22
View File
@@ -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());
}
+1 -1
View File
@@ -89,7 +89,7 @@ Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.
one), `panel.tab.Viewer(<n>)` and `viewers.<n>` (View ▸ New Viewer's other Composition viewers;
`viewers.0` is the Composition panel while another viewer is active), `header.workspace.Animation`, `project.item.<id>` (a double-click opens the item, Enter
renames it), `viewer.comp`, `viewer.handle.<layer>.<i>`,
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.layer.<id>.row` (click selects,
`timeline.layer.<id>.bar`, `timeline.layer.<id>.nestedMarker.<i>` (a precomp's comp markers), `timeline.layer.<id>.twirl`, `timeline.layer.<id>.row` (click selects,
Enter or a double-click renames, drag reorders), `timeline.prop.<uid>.stopwatch`,
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` and
`effects.item.<name>`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`,
+8 -2
View File
@@ -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