Files
effectcraft/crates/ui-egui/src/panels/markers_ui.rs
T
bflatasticandClaude Opus 5.5 f013bdd8d7 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]>
2026-10-05 15:12:06 +07:00

461 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Markers in the Timeline (composition markers in the ruler, layer markers on layer bars) and
//! the Composition/Layer Marker dialog.
//!
//! Gestures, as in After Effects: drag moves a marker (snapping to the current time, the work
//! area and other markers), Alt-drag sets its duration, Cmd-click (Ctrl-click elsewhere)
//! deletes it, double-click opens the Marker dialog, right-click offers Settings / Convert /
//! Delete. Protected regions (Responsive Design) shade the timeline. Every change is a
//! `markers.*` engine command, so it is undoable and agent-drivable.
use effectcraft_engine::project::{Comp, Layer, Marker};
use effectcraft_engine::time::Tick;
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::{Value, json};
use super::timeline::TMap;
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
/// Which marker a gesture or the dialog refers to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MarkerRef {
/// `None` = composition marker.
pub layer: Option<u64>,
pub index: usize,
}
impl MarkerRef {
fn params(&self) -> Value {
match self.layer {
Some(l) => json!({"layer": l, "index": self.index}),
None => json!({"index": self.index}),
}
}
fn auto_id(&self) -> String {
match self.layer {
Some(l) => format!("timeline.layer.{l}.marker.{}", self.index),
None => format!("timeline.marker.{}", self.index),
}
}
}
/// Marker dialog fields.
#[derive(Clone, Debug, Default)]
pub struct MarkerDraft {
pub target: Option<MarkerRef>,
pub title: String,
pub time: f64,
pub duration: f64,
pub comment: String,
pub chapter: String,
pub url: String,
pub frame_target: String,
pub cue: bool,
pub cue_navigation: bool,
pub cue_name: String,
pub cue_params: Vec<(String, String)>,
pub protected: bool,
pub label: usize,
}
/// Open the Composition/Layer Marker dialog for `m`.
pub fn open_dialog(app: &mut EffectcraftApp, m: MarkerRef) -> Result<(), String> {
let mut p = json!({});
if let Some(l) = m.layer {
p["layer"] = json!(l);
}
let list = app.session.execute("markers.list", p).map_err(|e| e.to_string())?;
let v = list.get(m.index).ok_or("no such marker")?;
let s = |k: &str| v[k].as_str().unwrap_or_default().to_string();
let cue = &v["cuePoint"];
app.dialog_state.marker = MarkerDraft {
target: Some(m),
title: if m.layer.is_some() { "Layer Marker".into() } else { "Composition Marker".into() },
time: v["time"].as_f64().unwrap_or(0.0),
duration: v["duration"].as_f64().unwrap_or(0.0),
comment: s("comment"),
chapter: s("chapter"),
url: s("url"),
frame_target: s("frameTarget"),
cue: !cue.is_null(),
cue_navigation: cue["navigation"].as_bool().unwrap_or(false),
cue_name: cue["name"].as_str().unwrap_or_default().to_string(),
cue_params: cue["params"]
.as_array()
.map(|a| a.iter().map(|kv| (kv[0].as_str().unwrap_or_default().to_string(), kv[1].as_str().unwrap_or_default().to_string())).collect())
.unwrap_or_default(),
protected: v["protected"].as_bool().unwrap_or(false),
label: effectcraft_engine::color::Label::from_name(&s("label"))
.and_then(|l| effectcraft_engine::color::Label::ALL.iter().position(|x| *x == l))
.unwrap_or(0),
};
app.dialog = Some(Dialog::Marker);
Ok(())
}
/// The command parameters of the dialog's current fields.
pub fn draft_params(d: &MarkerDraft) -> Value {
let mut p = d.target.map(|t| t.params()).unwrap_or_else(|| json!({}));
p["time"] = json!(d.time);
p["duration"] = json!(d.duration);
p["comment"] = json!(d.comment);
p["chapter"] = json!(d.chapter);
p["url"] = json!(d.url);
p["frameTarget"] = json!(d.frame_target);
p["protected"] = json!(d.protected);
p["label"] = json!(d.label);
p["cuePoint"] = if d.cue {
json!({"name": d.cue_name, "navigation": d.cue_navigation, "params": d.cue_params.iter().filter(|(k, _)| !k.is_empty()).map(|(k, v)| json!([k, v])).collect::<Vec<_>>()})
} else {
Value::Null
};
p
}
fn text_field(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut String, w: f32) {
let r = ui.add(egui::TextEdit::singleline(v).desired_width(w));
app.auto.add(id, r.rect, id);
}
/// The Composition/Layer Marker dialog.
pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut d = app.dialog_state.marker.clone();
let (mut ok, mut cancel, mut delete) = (false, false, false);
let title = d.title.clone();
super::dialogs::modal(ctx, &title, vec2(520.0, 560.0), t, |ui| {
egui::Grid::new("marker-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
ui.label("Time:");
let r = ui.add(egui::DragValue::new(&mut d.time).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3));
app.auto.add("dialog.marker.time", r.rect, "Time");
ui.end_row();
ui.label("Duration:");
let r = ui.add(egui::DragValue::new(&mut d.duration).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3));
app.auto.add("dialog.marker.duration", r.rect, "Duration");
ui.end_row();
ui.label("Comment:");
let r = ui.add(egui::TextEdit::multiline(&mut d.comment).desired_rows(3).desired_width(320.0));
app.auto.add("dialog.marker.comment", r.rect, "Comment");
ui.end_row();
ui.label("Chapter:");
text_field(ui, app, "dialog.marker.chapter", &mut d.chapter, 320.0);
ui.end_row();
ui.label("URL:");
text_field(ui, app, "dialog.marker.url", &mut d.url, 320.0);
ui.end_row();
ui.label("Frame Target:");
text_field(ui, app, "dialog.marker.frameTarget", &mut d.frame_target, 320.0);
ui.end_row();
});
ui.add_space(8.0);
let r = ui.checkbox(&mut d.cue, "Cue Point");
app.auto.add("dialog.marker.cue", r.rect, "Cue Point");
ui.add_enabled_ui(d.cue, |ui| {
ui.horizontal(|ui| {
let r = ui.radio_value(&mut d.cue_navigation, false, "Event");
app.auto.add("dialog.marker.cue.event", r.rect, "Event");
let r = ui.radio_value(&mut d.cue_navigation, true, "Navigation");
app.auto.add("dialog.marker.cue.navigation", r.rect, "Navigation");
ui.label("Name:");
text_field(ui, app, "dialog.marker.cue.name", &mut d.cue_name, 160.0);
});
let mut remove = None;
for (i, (k, v)) in d.cue_params.iter_mut().enumerate() {
ui.horizontal(|ui| {
ui.label("Parameter Name:");
text_field(ui, app, &format!("dialog.marker.cue.param.{i}.name"), k, 110.0);
ui.label("Value:");
text_field(ui, app, &format!("dialog.marker.cue.param.{i}.value"), v, 110.0);
let r = ui.small_button("−");
app.auto.add(&format!("dialog.marker.cue.param.{i}.remove"), r.rect, "Remove");
if r.clicked() {
remove = Some(i);
}
});
}
if let Some(i) = remove {
d.cue_params.remove(i);
}
let r = ui.small_button("+ Parameter");
app.auto.add("dialog.marker.cue.addParam", r.rect, "Add Parameter");
if r.clicked() {
d.cue_params.push((String::new(), String::new()));
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
let r = ui.checkbox(&mut d.protected, "Protected Region");
app.auto.add("dialog.marker.protected", r.rect, "Protected Region");
ui.add_space(20.0);
ui.label("Label:");
let labels = effectcraft_engine::color::Label::ALL;
let cur = labels.get(d.label).copied().unwrap_or_default();
let r = egui::ComboBox::from_id_salt("dialog.marker.label").selected_text(app.session.prefs.label_name(cur)).show_ui(ui, |ui| {
for (i, l) in labels.iter().enumerate() {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
ui.painter().rect_filled(r, 2.0, app.tokens.label(*l));
ui.selectable_value(&mut d.label, i, app.session.prefs.label_name(*l));
});
}
});
app.auto.add("dialog.marker.label", r.response.rect, "Label");
});
ui.add_space(14.0);
ui.horizontal(|ui| {
let r = ui.button("Delete");
app.auto.add("dialog.marker.delete", r.rect, "Delete");
delete = r.clicked();
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(app.tokens.accent));
app.auto.add("dialog.marker.ok", r.rect, "OK");
ok = r.clicked();
let r = ui.button("Cancel");
app.auto.add("dialog.marker.cancel", r.rect, "Cancel");
cancel = r.clicked();
});
});
});
app.dialog_state.marker = d.clone();
if ok {
if let Err(e) = app.session.execute("markers.set", draft_params(&d)) {
app.ui.status = e.to_string();
}
cancel = true;
}
if delete && let Some(m) = d.target {
if let Err(e) = app.session.execute("markers.delete", m.params()) {
app.ui.status = e.to_string();
}
cancel = true;
}
if cancel {
app.dialog = None;
}
}
// ---------------------------------------------------------------- timeline drawing + gestures
fn drag_state_id() -> egui::Id {
egui::Id::new("marker-drag")
}
/// Snap `t` (comp seconds) to the CTI, the work area and other marker times within 8 px.
fn snap(app: &EffectcraftApp, comp: &Comp, tm: TMap, t: f64, skip: MarkerRef) -> f64 {
let fr = comp.frame_rate;
let mut t = fr.snap_nearest(Tick::from_seconds_f64(t.max(0.0))).seconds();
if !app.session.state.snapping {
return t;
}
let mut cands = vec![app.session.time().seconds(), comp.work_area.0.seconds(), comp.work_area.1.seconds()];
cands.extend(comp.markers.iter().enumerate().filter(|(i, _)| skip != MarkerRef { layer: None, index: *i }).map(|(_, m)| m.time.seconds()));
let px = |a: f64| (a - t).abs() * tm.pps;
if let Some(best) = cands.into_iter().filter(|c| px(*c) < 8.0).min_by(|a, b| px(*a).total_cmp(&px(*b))) {
t = best;
}
t
}
/// Draw one marker (triangle + duration bar + comment) at `x` in a strip `[y0, y1]` and handle
/// its gestures. `comp_t` is the marker's comp time.
#[allow(clippy::too_many_arguments)]
fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, tm: TMap, m: &Marker, comp_t: f64, r: MarkerRef, y0: f32, y1: f32) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let x = tm.x(comp_t);
let col = if m.label == effectcraft_engine::color::Label::None { Color32::from_rgb(0xd8, 0xd8, 0x60) } else { t.label(m.label) };
let h = (y1 - y0).min(10.0);
let ym = y0 + h * 0.6;
if m.duration > Tick(0) {
let xe = tm.x(comp_t + m.duration.seconds());
let bar = Rect::from_min_max(pos2(x, y0 + 1.0), pos2(xe, y0 + h - 1.0));
p.rect_filled(bar, 1.0, col.gamma_multiply(if m.protected { 0.35 } else { 0.5 }));
if m.protected {
let mut hx = bar.min.x;
while hx < bar.max.x {
p.line_segment([pos2(hx, bar.max.y), pos2((hx + bar.height()).min(bar.max.x), bar.min.y)], Stroke::new(1.0, col));
hx += 5.0;
}
}
p.line_segment([pos2(xe, y0), pos2(xe, y0 + h)], Stroke::new(1.0, col));
}
p.add(egui::Shape::convex_polygon(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)], col, Stroke::NONE));
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);
}
let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, y1));
let id = egui::Id::new(("marker", r.layer, r.index));
let resp = ui.interact(hit, id, Sense::click_and_drag());
app.auto.add(&r.auto_id(), hit, if m.comment.is_empty() { "marker" } else { &m.comment });
let tip = if m.comment.is_empty() { "Marker".to_string() } else { m.comment.clone() };
let resp = resp.on_hover_text(tip);
let mods = ui.input(|i| i.modifiers);
if resp.double_clicked() {
if let Err(e) = open_dialog(app, r) {
app.ui.status = e;
}
return;
}
if resp.clicked() && (if cfg!(target_os = "macos") { mods.mac_cmd || mods.command } else { mods.ctrl }) {
let _ = app.session.execute("markers.delete", r.params());
return;
}
if resp.drag_started() {
ctx.data_mut(|d| d.insert_temp(drag_state_id(), (r.layer, r.index, mods.alt)));
}
if resp.dragged()
&& let Some(pt) = resp.interact_pointer_pos()
&& let Some((layer, index, alt)) = ctx.data(|d| d.get_temp::<(Option<u64>, usize, bool)>(drag_state_id()))
{
let cur = MarkerRef { layer, index };
let merge = format!("marker-drag-{layer:?}");
let mut params = cur.params();
params["merge"] = json!(merge);
let tt = snap(app, comp, tm, tm.t(pt.x), cur);
if alt {
params["duration"] = json!((tt - comp_t).max(0.0));
} else {
params["time"] = json!(tt);
}
if let Ok(v) = app.session.execute("markers.set", params)
&& let Some(ni) = v["index"].as_u64()
{
ctx.data_mut(|d| d.insert_temp(drag_state_id(), (layer, ni as usize, alt)));
}
}
if resp.drag_stopped() {
ctx.data_mut(|d| d.remove::<(Option<u64>, usize, bool)>(drag_state_id()));
app.session.history.merge_key = None;
}
resp.context_menu(|ui| {
let run = |ui: &mut egui::Ui, label: &str, cmd: &str, params: Value, app: &mut EffectcraftApp| {
if ui.button(label).clicked() {
if cmd == "dialog" {
let _ = open_dialog(app, r);
} else if let Err(e) = app.session.execute(cmd, params) {
app.ui.status = e.to_string();
}
ui.close();
}
};
run(ui, "Settings…", "dialog", Value::Null, app);
if r.layer.is_some() {
run(ui, "Convert to Composition Marker", "markers.convert", r.params(), app);
run(ui, "Lock Markers", "layer.markersLock", json!({"layers": [r.layer]}), app);
} else {
run(ui, "Convert to Layer Marker (selected layers)", "markers.convert", r.params(), app);
}
ui.separator();
run(ui, "Delete This Marker", "markers.delete", r.params(), app);
if let Some(l) = r.layer {
run(ui, "Delete All Markers", "layer.deleteAllMarkers", json!({"layers": [l]}), app);
}
});
}
/// Composition markers in the ruler strip `[y0, y1]`, protected regions over `rows`.
pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &Comp, tm: TMap, strip: Rect, rows: Rect) {
let p = ui.painter().with_clip_rect(strip);
let rp = ui.painter().with_clip_rect(rows);
for m in comp.markers.iter().filter(|m| m.protected && m.duration > Tick(0)) {
let (x0, x1) = (tm.x(m.time.seconds()), tm.x((m.time + m.duration).seconds()));
rp.rect_filled(Rect::from_min_max(pos2(x0, rows.min.y), pos2(x1, rows.max.y)), 0.0, Color32::from_rgba_unmultiplied(0x60, 0x90, 0xd0, 22));
rp.line_segment([pos2(x0, rows.min.y), pos2(x0, rows.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(0x60, 0x90, 0xd0, 90)));
rp.line_segment([pos2(x1, rows.min.y), pos2(x1, rows.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(0x60, 0x90, 0xd0, 90)));
}
for (i, m) in comp.markers.iter().enumerate() {
marker(app, ui, &p, comp, tm, m, m.time.seconds(), MarkerRef { layer: None, index: i }, strip.min.y, strip.max.y);
}
}
/// 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::*;
#[test]
fn marker_dialog_reads_and_writes_every_field() {
let mut s = effectcraft_engine::Session::default();
s.execute("comp.new", json!({"name": "M", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap();
s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "hello", "cuePoint": {"name": "c", "params": [["a", "1"]]}, "label": "Blue"}))
.unwrap();
let mut app = EffectcraftApp::new(s);
open_dialog(&mut app, MarkerRef { layer: None, index: 0 }).unwrap();
assert_eq!(app.dialog, Some(Dialog::Marker));
let d = &mut app.dialog_state.marker;
assert_eq!((d.time, d.comment.as_str(), d.cue, d.cue_name.as_str()), (1.0, "hello", true, "c"));
assert_eq!(d.cue_params, vec![("a".to_string(), "1".to_string())]);
assert_eq!(effectcraft_engine::color::Label::ALL[d.label].name(), "Blue");
d.duration = 0.5;
d.chapter = "Ch".into();
d.url = "https://getartcraft.com".into();
d.frame_target = "_self".into();
d.protected = true;
d.cue = false;
let p = draft_params(d);
app.session.execute("markers.set", p).unwrap();
let m = &app.session.execute("markers.list", json!({})).unwrap()[0];
assert_eq!(m["duration"], 0.5);
assert_eq!(m["chapter"], "Ch");
assert_eq!(m["frameTarget"], "_self");
assert_eq!(m["protected"], true);
assert!(m["cuePoint"].is_null());
assert_eq!(m["label"], "Blue");
app.session.undo();
assert_eq!(app.session.execute("markers.list", json!({})).unwrap()[0]["chapter"], "");
}
}