mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #56 from storytold/m13-nested-markers
M13.33: nested comp markers on the precomp layer bar
This commit is contained in:
7 files changed
+171
-6
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user