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 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 15:12:06 +07:00
co-authored by Claude Opus 5.5
parent 1ac55c33a3
commit f013bdd8d7
7 changed files with 171 additions and 6 deletions
+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());
}