filmcraft/crates/engine/src/graphics.rs

941 lines
46 KiB
Rust

//! Graphics commands (`graphics.*`, `fonts.list`): graphic clips with text and shape layers.
//!
//! A graphic clip's layers are the `graphic_text` / `graphic_shape` effect instances on the clip
//! (see `filmcraft_project::graphic`). Commands address a clip by `clip` (default: the first
//! selected graphic clip, else the topmost graphic clip under the playhead) and a layer by
//! `layer` = index among the clip's graphic layers, 0 = back (default: the selected layer, else
//! the frontmost). Property values are keyframe-aware: on an animated property they set the value
//! at the playhead.
use filmcraft_geom::Vec2;
use filmcraft_project::graphic::{self, LayerContent, SHAPE_OPTS, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer};
use filmcraft_project::{ClipId, ItemId, ItemKind, Label, ParamValue, Sequence, TrackKind};
use filmcraft_render::graphic_clip::{item_layer_specs, layer_local_bounds, layer_quad};
use filmcraft_time::{Tick, TimeRange};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, f64_p, has_seq, str_p, time_p, u64_p};
use crate::{EngineError, Result, Session};
type Run = fn(&mut Session, &Value) -> Result<Value>;
type Enabled = fn(&Session) -> std::result::Result<(), String>;
fn spec(
id: &'static str,
label: &'static str,
menu: &'static [&'static str],
shortcut: Option<&'static str>,
params: &'static str,
enabled: Enabled,
run: Run,
) -> CommandSpec {
CommandSpec { id, label, menu, shortcut, params, enabled, run, journal: true }
}
fn query(id: &'static str, label: &'static str, params: &'static str, run: Run) -> CommandSpec {
CommandSpec { id, label, menu: &[], shortcut: None, params, enabled: always, run, journal: false }
}
/// Whether a track item is a graphic clip.
pub fn is_graphic(s: &Session, seq: &Sequence, c: ClipId) -> bool {
seq.find_item(c).and_then(|(_, it)| s.project.item(it.item)).is_some_and(|p| matches!(p.kind, ItemKind::Graphic { .. }))
}
/// The graphic clip a command acts on: `clip`, else the first selected graphic clip, else the
/// topmost graphic clip under the playhead.
pub fn target_clip(s: &Session, p: &Value) -> Option<ClipId> {
let seq = s.active_sequence()?;
if let Some(c) = u64_p(p, "clip").map(ClipId) {
return is_graphic(s, seq, c).then_some(c);
}
if let Some(c) = s.state.selection.iter().copied().find(|c| is_graphic(s, seq, *c)) {
return Some(c);
}
let t = s.playhead();
seq.video_tracks.iter().rev().filter_map(|tr| tr.item_at(t)).map(|it| it.id).find(|c| is_graphic(s, seq, *c))
}
fn has_graphic(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
target_clip(s, &Value::Null).map(|_| ()).ok_or_else(|| "select a graphic clip".into())
}
/// Effect index of graphic layer `layer` (or the selected / frontmost layer) of `clip`.
pub(crate) fn layer_effect_index(s: &Session, clip: ClipId, p: &Value) -> Result<(usize, usize)> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (_, it) = seq.find_item(clip).ok_or_else(|| bad("graphics", "no such clip"))?;
let idx = layer_indices(&it.effects);
if idx.is_empty() {
return Err(bad("graphics", "the graphic has no layers"));
}
let l = match p.get("layer") {
Some(Value::Number(n)) => n.as_u64().unwrap_or(0) as usize,
Some(Value::String(name)) => (0..idx.len())
.find(|&i| layer_display_name(&it.effects[idx[i]], i).eq_ignore_ascii_case(name))
.ok_or_else(|| bad("graphics", format!("no layer named `{name}`")))?,
_ => s.state.graphic_layers.first().copied().filter(|l| *l < idx.len()).unwrap_or(idx.len() - 1),
};
let e = *idx.get(l).ok_or_else(|| bad("graphics", format!("no layer {l}")))?;
Ok((l, e))
}
pub(crate) fn vec2_p(p: &Value, k: &str) -> Option<Vec2> {
let a = p.get(k)?.as_array()?;
Some(Vec2::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}
/// Find or create the graphic source item for the active sequence's frame size (source graphics
/// have their own items and are never reused here).
pub(crate) fn graphic_source(p: &mut filmcraft_project::Project, w: u32, h: u32, rate: filmcraft_time::FrameRate) -> ItemId {
if let Some((id, _)) = p.items.iter().find(|(id, i)| {
!p.source_graphics.contains_key(id) && matches!(i.kind, ItemKind::Graphic { width, height, rate: r } if width == w && height == h && r == rate)
}) {
return *id;
}
p.add_item("Graphic", Label::Rose, ItemKind::Graphic { width: w, height: h, rate }, None)
}
/// Place a new graphic clip holding `layer` at the playhead: on the first video track above the
/// topmost clip at the playhead that is free for the duration (a track is added if needed).
fn new_graphic_clip(s: &mut Session, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result<ClipId> {
let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01);
place_video_clip(s, name, p, "New Graphic", vec![layer], move |pr, (w, h, rate)| {
let src = graphic_source(pr, w, h, rate);
(src, rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration()))
})
}
/// Place a video clip of the item `source` returns (with its duration) at the playhead (or
/// `time`), above the clips there, as one undo step; selects it.
pub(crate) fn place_video_clip(
s: &mut Session,
name: &str,
p: &Value,
label: &str,
extra: Vec<filmcraft_project::EffectInstance>,
source: impl FnOnce(&mut filmcraft_project::Project, (u32, u32, filmcraft_time::FrameRate)) -> (ItemId, Tick),
) -> Result<ClipId> {
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let t = time_p(s, p, "").unwrap_or_else(|| s.playhead());
let want_track = u64_p(p, "track").map(|v| v as usize);
let name = name.to_string();
s.edit(label, |pr, st| {
let (w, h, rate) = {
let q = pr.sequence(seq_id).ok_or(EngineError::NoSequence)?;
(q.settings.width, q.settings.height, q.settings.frame_rate)
};
let (src, dur) = source(pr, (w, h, rate));
let t = rate.snap(t);
let dur = dur.max(rate.frame_duration());
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad("graphics.newText", "bad item"))?;
ti.name = name;
ti.effects.extend(extra);
for e in &mut ti.effects {
filmcraft_project::resolve_auto_points(e, (w, h), (w, h));
}
let id = ti.id;
let track_id = filmcraft_project::TrackId(pr.alloc_id());
let q = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
let range = TimeRange::new(t, dur);
let free = |tr: &filmcraft_project::Track| !tr.items.iter().any(|i| i.range().overlaps(&range));
let idx = match want_track {
Some(i) if i < q.video_tracks.len() && free(&q.video_tracks[i]) => i,
Some(i) if i < q.video_tracks.len() => return Err(bad("graphics.newText", format!("V{} is not free here", i + 1))),
_ => {
let top = q.video_tracks.iter().rposition(|tr| tr.item_at(t).is_some()).map_or(0, |k| k + 1);
match (top..q.video_tracks.len()).find(|&i| free(&q.video_tracks[i]) && !q.video_tracks[i].locked) {
Some(i) => i,
None => {
let n = q.video_tracks.len() + 1;
q.video_tracks.push(filmcraft_project::Track::new(track_id, TrackKind::Video, format!("Video {n}")));
q.video_tracks.len() - 1
}
}
}
};
q.video_tracks[idx].items.push(ti);
q.video_tracks[idx].sort();
q.check().map_err(EngineError::Other)?;
st.selection = vec![id];
st.graphic_layers = vec![0];
Ok(id)
})
}
/// Add a layer to an existing graphic clip; returns the new layer index.
fn add_layer(s: &mut Session, clip: ClipId, mut layer: filmcraft_project::EffectInstance) -> Result<usize> {
let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
filmcraft_project::resolve_auto_points(&mut layer, frame, frame);
s.edit_sequence("Add Graphic Layer", |q, _, st| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
// new layers go in front: after the last graphic layer
let pos = layer_indices(&it.effects).last().map_or(it.effects.len(), |l| l + 1);
it.effects.insert(pos, layer);
let n = layer_indices(&it.effects).len() - 1;
st.selection = vec![clip];
st.graphic_layers = vec![n];
Ok(n)
})
}
/// Map friendly property names to parameter ids.
fn param_id(k: &str) -> &str {
match k {
"fontSize" => "size",
"fontStyle" | "style" => "font_style",
"fillColor" | "color" => "fill_color",
"strokeColor" => "stroke_color",
"strokeWidth" => "stroke_width",
"backgroundColor" => "background_color",
"shadowColor" => "shadow_color",
"baselineShift" => "baseline_shift",
"fauxBold" => "faux_bold",
"fauxItalic" => "faux_italic",
"boxWidth" => "box_width",
"anchorPoint" => "anchor",
"cornerRadius" => "corner_radius",
other => other,
}
}
/// JSON value for a property, accepting names for choices ("center", "small caps", "ellipse"…).
pub(crate) fn to_param(template: &ParamValue, id: &str, v: &Value) -> Option<ParamValue> {
if let (ParamValue::Choice(_), Value::String(name)) = (template, v) {
let opts: &[&str] = match id {
"align" => graphic::ALIGN_OPTS,
"caps" => graphic::CAPS_OPTS,
"stroke_type" | "stroke2_type" => graphic::STROKE_OPTS,
"shape" => SHAPE_OPTS,
_ => &[],
};
let n = name.to_ascii_lowercase().replace('_', " ");
let n = if n == "centre" { "center".to_string() } else { n };
return opts.iter().position(|o| o.to_ascii_lowercase() == n).map(|i| ParamValue::Choice(i as u32));
}
crate::commands::json_to_param(template, v)
}
/// Set properties `props` on a layer (keyframe-aware at time `tl`). Changing the text keeps
/// per-character styles on their characters.
pub(crate) fn set_props(s: &mut Session, clip: ClipId, eidx: usize, props: &serde_json::Map<String, Value>, tl: Tick, label: &str) -> Result<()> {
let props = props.clone();
s.edit_sequence(label, |q, _, _| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1)));
let e = it.effects.get_mut(eidx).ok_or_else(|| bad("graphics.set", "no such layer"))?;
for (k, v) in &props {
if k == "enabled" {
e.enabled = v.as_bool().unwrap_or(true);
continue;
}
let id = param_id(k);
let prm = e.params.get_mut(id).ok_or_else(|| bad("graphics.set", format!("no property `{k}`")))?;
let pv = to_param(&prm.value, id, v).ok_or_else(|| bad("graphics.set", format!("`{k}`: value has the wrong type")))?;
let old = prm.value_at(mt);
prm.set_at(mt, pv.clone());
if let (ParamValue::Text(a), ParamValue::Text(b), "text") = (&old, &pv, id)
&& let Some(x) = e.layer.as_mut()
{
x.runs = filmcraft_project::graphic_design::adjust_runs(a, b, &x.runs);
}
}
Ok(())
})
}
/// Layers of a graphic clip with their evaluated bounds (sequence/canvas pixels).
fn list_layers(s: &Session, clip: ClipId) -> Result<Value> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (_, it) = seq.find_item(clip).ok_or_else(|| bad("graphics.list", "no such clip"))?;
let size = match s.project.item(it.item).map(|p| &p.kind) {
Some(ItemKind::Graphic { width, height, .. }) => (*width, *height),
_ => (seq.settings.width, seq.settings.height),
};
let ph = s.playhead();
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
let shown = item_layer_specs(it, mt, size);
let layers: Vec<Value> = layer_indices(&it.effects)
.iter()
.enumerate()
.filter_map(|(i, &ei)| {
let e = &it.effects[ei];
let sp = shown.iter().find(|(x, _)| *x == ei).map(|(_, s)| s.clone()).or_else(|| eval_layer(e, mt, size))?;
let q = layer_quad(&sp);
let extra = e.layer.as_deref();
let (kind, text) = match &sp.content {
LayerContent::Text(t) => ("text", Some(t.text.clone())),
LayerContent::Shape(sh) => (SHAPE_OPTS.get(sh.shape as usize).copied().unwrap_or("Shape"), None),
};
Some(json!({
"layer": i,
"effectIndex": ei,
"name": layer_display_name(e, i),
"kind": kind,
"text": text,
"enabled": e.enabled,
"position": [sp.transform.position.x, sp.transform.position.y],
"uid": extra.map_or(0, |x| x.uid),
"pin": extra.and_then(|x| x.pin.as_ref()).map(|p| serde_json::to_value(p).unwrap_or_default()),
"styles": extra.map(|x| serde_json::to_value(&x.runs).unwrap_or_default()).unwrap_or(json!([])),
"localBounds": layer_local_bounds(&sp),
"quad": q.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>(),
}))
})
.collect();
let meta = it.graphic.as_deref().map(|m| serde_json::to_value(m).unwrap_or_default());
let source = s.project.source_graphics.contains_key(&it.item).then_some(it.item.0);
Ok(
json!({"clip": clip.0, "name": it.name, "start": it.start.0, "duration": it.duration.0, "canvas": [size.0, size.1], "layers": layers, "graphic": meta, "sourceGraphic": source}),
)
}
fn quad_bounds(q: &[Vec2; 4]) -> [f64; 4] {
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for p in q {
b = [b[0].min(p.x), b[1].min(p.y), b[2].max(p.x), b[3].max(p.y)];
}
b
}
/// Layer bounds of `layers` (layer indices) of `clip` at the playhead: (effect index, position, bounds).
fn layer_boxes(s: &Session, clip: ClipId, layers: &[usize]) -> Result<(Vec<(usize, Vec2, [f64; 4])>, (u32, u32))> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (_, it) = seq.find_item(clip).ok_or_else(|| bad("graphics.align", "no such clip"))?;
let size = match s.project.item(it.item).map(|p| &p.kind) {
Some(ItemKind::Graphic { width, height, .. }) => (*width, *height),
_ => (seq.settings.width, seq.settings.height),
};
let mt = it.source_time_at(s.playhead().clamp(it.start, it.end() - Tick(1)));
let idx = layer_indices(&it.effects);
let shown = item_layer_specs(it, mt, size);
let mut out = Vec::new();
for &l in layers {
let ei = *idx.get(l).ok_or_else(|| bad("graphics.align", format!("no layer {l}")))?;
let sp = shown.iter().find(|(x, _)| *x == ei).map(|(_, s)| s.clone()).ok_or_else(|| bad("graphics.align", "bad layer"))?;
// the position stored in the layer (what moving it changes)
let own = eval_layer(&it.effects[ei], mt, size).map_or(sp.transform.position, |o| o.transform.position);
// bounds where it is shown (after pins / roll); moving `own` by d moves those by d
out.push((ei, own, quad_bounds(&layer_quad(&sp))));
}
Ok((out, size))
}
fn layers_p(s: &Session, clip: ClipId, p: &Value) -> Result<Vec<usize>> {
if let Some(a) = p.get("layers").and_then(Value::as_array) {
return Ok(a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect());
}
if !s.state.graphic_layers.is_empty() {
return Ok(s.state.graphic_layers.clone());
}
let (l, _) = layer_effect_index(s, clip, p)?;
Ok(vec![l])
}
pub(crate) fn move_layers(s: &mut Session, clip: ClipId, moves: Vec<(usize, Vec2)>, label: &str) -> Result<()> {
let tl = s.playhead();
s.edit_sequence(label, |q, _, _| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1)));
for (ei, pos) in &moves {
let Some(e) = it.effects.get_mut(*ei) else { continue };
let Some(prm) = e.params.get_mut("position") else { continue };
let old = prm.value_at(mt).as_vec2().unwrap_or(*pos);
prm.set_at(mt, ParamValue::Vec2(*pos));
// a pinned layer keeps following its target from its new place
if let Some(pin) = e.layer.as_mut().and_then(|x| x.pin.as_mut()) {
let (dx, dy) = (pos.x - old.x, pos.y - old.y);
pin.offsets[0] += dx;
pin.offsets[2] += dx;
pin.offsets[1] += dy;
pin.offsets[3] += dy;
}
}
Ok(())
})
}
#[derive(Clone, Copy, PartialEq)]
enum AlignTo {
/// Each layer to the video frame.
Frame,
/// The layers' union to the video frame (they keep their relative positions).
FrameGroup,
/// Each layer to the union of the selection.
Selection,
}
fn align_delta(how: &str, r: [f64; 4], b: [f64; 4]) -> Result<(f64, f64)> {
Ok(match how {
"left" => (r[0] - b[0], 0.0),
"right" => (r[2] - b[2], 0.0),
"hcenter" | "center" => ((r[0] + r[2]) / 2.0 - (b[0] + b[2]) / 2.0, 0.0),
"top" => (0.0, r[1] - b[1]),
"bottom" => (0.0, r[3] - b[3]),
"vcenter" | "middle" => (0.0, (r[1] + r[3]) / 2.0 - (b[1] + b[3]) / 2.0),
o => return Err(bad("graphics.align", format!("unknown alignment `{o}`"))),
})
}
fn union_box(boxes: &[(usize, Vec2, [f64; 4])]) -> [f64; 4] {
boxes.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |a, b| [a[0].min(b.2[0]), a[1].min(b.2[1]), a[2].max(b.2[2]), a[3].max(b.2[3])])
}
/// Graphics ▸ Align to Video Frame / Align to Video Frame as Group / Align to Selection.
fn align_layers(s: &mut Session, p: &Value, how: &str, mode: AlignTo) -> Result<Value> {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.align", "no graphic clip"))?;
let layers = layers_p(s, clip, p)?;
let (boxes, size) = layer_boxes(s, clip, &layers)?;
let frame = [0.0, 0.0, size.0 as f64, size.1 as f64];
let mode = if mode == AlignTo::Selection && boxes.len() < 2 { AlignTo::Frame } else { mode };
let union = union_box(&boxes);
let group = align_delta(how, frame, union)?;
let mut moves = Vec::new();
for (ei, pos, b) in &boxes {
let (dx, dy) = match mode {
AlignTo::Frame => align_delta(how, frame, *b)?,
AlignTo::FrameGroup => group,
AlignTo::Selection => align_delta(how, union, *b)?,
};
moves.push((*ei, Vec2::new(pos.x + dx, pos.y + dy)));
}
move_layers(s, clip, moves, "Align Layers")?;
Ok(json!({"clip": clip.0, "layers": layers}))
}
/// Graphics ▸ Distribute: equal centre spacing, or (`space`) equal gaps between the layers.
fn distribute_layers(s: &mut Session, p: &Value, vertical: bool, space: bool) -> Result<Value> {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.distribute", "no graphic clip"))?;
let layers = layers_p(s, clip, p)?;
let (mut boxes, _) = layer_boxes(s, clip, &layers)?;
if boxes.len() < 3 {
return Err(bad("graphics.distribute", "select three or more layers"));
}
let (lo, hi) = if vertical { (1, 3) } else { (0, 2) };
let c = |b: &[f64; 4]| (b[lo] + b[hi]) / 2.0;
boxes.sort_by(|a, b| c(&a.2).total_cmp(&c(&b.2)));
let n = boxes.len() - 1;
let deltas: Vec<f64> = if space {
let span = boxes[n].2[hi] - boxes[0].2[lo];
let total: f64 = boxes.iter().map(|b| b.2[hi] - b.2[lo]).sum();
let gap = (span - total) / n as f64;
let mut at = boxes[0].2[lo];
boxes
.iter()
.map(|b| {
let d = at - b.2[lo];
at += b.2[hi] - b.2[lo] + gap;
d
})
.collect()
} else {
let (first, last) = (c(&boxes[0].2), c(&boxes[n].2));
boxes.iter().enumerate().map(|(i, b)| first + (last - first) * i as f64 / n as f64 - c(&b.2)).collect()
};
let moves =
boxes.iter().zip(deltas).map(|((ei, pos, _), d)| (*ei, if vertical { Vec2::new(pos.x, pos.y + d) } else { Vec2::new(pos.x + d, pos.y) })).collect();
move_layers(s, clip, moves, "Distribute Layers")?;
Ok(json!({"clip": clip.0, "layers": layers}))
}
/// Move a graphic layer in the paint order (`to`: front / back / forward / backward / index).
fn arrange_layer(s: &mut Session, p: &Value, to: Value) -> Result<Value> {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.arrangeLayer", "no graphic clip"))?;
let (l, _) = layer_effect_index(s, clip, p)?;
let dest = s.edit_sequence("Arrange Graphic Layer", |q, _, st| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let idx = layer_indices(&it.effects);
let last = idx.len() - 1;
let dest = match &to {
Value::Number(v) => (v.as_u64().unwrap_or(0) as usize).min(last),
Value::String(w) => match w.as_str() {
"front" => last,
"back" => 0,
"forward" => (l + 1).min(last),
"backward" => l.saturating_sub(1),
o => return Err(bad("graphics.arrangeLayer", format!("unknown `to` `{o}`"))),
},
_ => last,
};
let e = it.effects.remove(idx[l]);
let mut idx2 = layer_indices(&it.effects);
let insert_at = if dest >= idx2.len() { idx2.pop().map_or(idx[0], |x| x + 1) } else { idx2[dest] };
it.effects.insert(insert_at, e);
st.selection = vec![clip];
st.graphic_layers = vec![dest];
Ok(dest)
})?;
Ok(json!({"clip": clip.0, "layer": dest}))
}
/// Graphic clips of the active sequence in timeline order (start, then track bottom → top).
fn graphic_clips(s: &Session) -> Vec<(Tick, usize, ClipId)> {
let Some(q) = s.active_sequence() else { return Vec::new() };
let mut v: Vec<(Tick, usize, ClipId)> = q
.video_tracks
.iter()
.enumerate()
.flat_map(|(ti, tr)| tr.items.iter().map(move |it| (it.start, ti, it.id)))
.filter(|(_, _, c)| is_graphic(s, q, *c))
.collect();
v.sort();
v
}
/// Graphics ▸ Select ▸ Select Next / Previous Graphic.
fn select_graphic(s: &mut Session, forward: bool) -> Result<Value> {
let all = graphic_clips(s);
let cur = s.state.selection.iter().find_map(|c| all.iter().position(|g| g.2 == *c));
let ph = s.playhead();
let pick = match (cur, forward) {
(Some(i), true) => all.get(i + 1),
(Some(i), false) => i.checked_sub(1).and_then(|j| all.get(j)),
(None, true) => all.iter().find(|g| g.0 >= ph).or(all.first()),
(None, false) => all.iter().rev().find(|g| g.0 < ph).or(all.last()),
};
let (start, _, clip) = *pick.ok_or_else(|| bad("graphics.select", if forward { "no next graphic" } else { "no previous graphic" }))?;
let dur = s.active_sequence().and_then(|q| q.find_item(clip)).map_or(Tick::ZERO, |(_, it)| it.duration);
s.state.selection = vec![clip];
s.state.graphic_layers.clear();
if !(ph >= start && ph < start + dur) {
s.set_playhead(start);
}
Ok(json!({"clip": clip.0}))
}
/// Graphics ▸ Select ▸ Select Next / Previous Layer (cycles through the clip's layers).
fn select_layer_step(s: &mut Session, forward: bool) -> Result<Value> {
let clip = target_clip(s, &Value::Null).ok_or_else(|| bad("graphics.selectLayer", "no graphic clip"))?;
let n = s.active_sequence().and_then(|q| q.find_item(clip)).map_or(0, |(_, it)| layer_indices(&it.effects).len());
if n == 0 {
return Err(bad("graphics.selectLayer", "the graphic has no layers"));
}
let l = match (s.state.graphic_layers.first().copied().filter(|l| *l < n), forward) {
(Some(l), true) => (l + 1) % n,
(Some(l), false) => (l + n - 1) % n,
(None, true) => 0,
(None, false) => n - 1,
};
s.state.selection = vec![clip];
s.state.graphic_layers = vec![l];
Ok(json!({"clip": clip.0, "layer": l}))
}
fn has_two_layers(s: &Session) -> std::result::Result<(), String> {
has_graphic(s)?;
if s.state.graphic_layers.len() < 2 { Err("select two or more graphic layers".into()) } else { Ok(()) }
}
fn has_three_layers(s: &Session) -> std::result::Result<(), String> {
has_graphic(s)?;
if s.state.graphic_layers.len() < 3 { Err("select three or more graphic layers".into()) } else { Ok(()) }
}
fn has_graphics_in_seq(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
if graphic_clips(s).is_empty() { Err("the sequence has no graphics".into()) } else { Ok(()) }
}
const ALIGN_PARAMS: &str = r#"{"clip":id?,"layers":[n]? (default: the selected layers)}"#;
macro_rules! align_cmd {
($id:literal, $label:literal, $menu:expr, $how:literal, $mode:expr, $en:expr) => {
spec($id, $label, &["Graphics and Titles", $menu], None, ALIGN_PARAMS, $en, |s, p| align_layers(s, p, $how, $mode))
};
}
macro_rules! arrange_cmd {
($id:literal, $label:literal, $sc:literal, $to:literal) => {
spec($id, $label, &["Graphics and Titles", "Arrange"], Some($sc), r#"{"clip":id?,"layer":n?}"#, has_graphic, |s, p| arrange_layer(s, p, json!($to)))
};
}
fn new_shape_cmd(s: &mut Session, p: &Value, shape: &str) -> Result<Value> {
let mut q = p.clone();
q["shape"] = json!(shape);
(find_spec("graphics.newShape").run)(s, &q)
}
fn find_spec(id: &str) -> CommandSpec {
commands().into_iter().find(|c| c.id == id).expect("graphics command")
}
/// Graphics menu commands (New Layer shapes, Align/Distribute/Arrange/Select submenus, resets).
fn menu_commands() -> Vec<CommandSpec> {
const F: &str = "Align to Video Frame";
const G: &str = "Align to Video Frame as Group";
const S: &str = "Align to Selection";
const D: &[&str] = &["Graphics and Titles", "Distribute"];
const SEL: &[&str] = &["Graphics and Titles", "Select"];
vec![
spec(
"graphics.newVerticalText",
"Vertical Text",
&["Graphics and Titles", "New Layer"],
None,
r#"{"text":str="New Text","position":[x,y]?,"clip":id?,"size":px=100,"seconds":f64=5,"time":ticks?}"#,
has_seq,
|s, p| {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
p["vertical"] = json!(true);
(find_spec("graphics.newText").run)(s, &p)
},
),
spec(
"graphics.newRectangle",
"Rectangle",
&["Graphics and Titles", "New Layer"],
Some("Cmd+Alt+R"),
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#,
has_seq,
|s, p| new_shape_cmd(s, p, "rectangle"),
),
spec(
"graphics.newEllipse",
"Ellipse",
&["Graphics and Titles", "New Layer"],
Some("Cmd+Alt+E"),
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#,
has_seq,
|s, p| new_shape_cmd(s, p, "ellipse"),
),
spec(
"graphics.newPolygon",
"Polygon",
&["Graphics and Titles", "New Layer"],
None,
r#"{"position":[x,y]?,"size":[w,h]=[300,300],"sides":n=6,"clip":id?}"#,
has_seq,
|s, p| {
let mut q = if p.is_object() { p.clone() } else { json!({}) };
if q.get("size").is_none() {
q["size"] = json!([300, 300]);
}
let r = new_shape_cmd(s, &q, "polygon")?;
if let Some(n) = f64_p(p, "sides") {
// fold the side count into the same undo step
let clip = ClipId(r["clip"].as_u64().unwrap_or(0));
let (_, ei) = layer_effect_index(s, clip, &json!({"layer": r["layer"]}))?;
let mut props = serde_json::Map::new();
props.insert("sides".into(), json!(n));
let ph = s.playhead();
let before = s.history.undo.len();
set_props(s, clip, ei, &props, ph, "Change Graphic Property")?;
if s.history.undo.len() > before {
s.history.undo.pop();
}
}
Ok(r)
},
),
spec(
"graphics.newFromFile",
"From file…",
&["Graphics and Titles", "New Layer"],
None,
r#"{"path":str,"time":ticks?,"track":index?} (imports the image or video and places it above the clips at the playhead)"#,
has_seq,
|s, p| {
let path = str_p(p, "path").ok_or_else(|| bad("graphics.newFromFile", "need `path`"))?.to_string();
let r = s.execute("file.import", json!({"paths": [path]}))?;
let item = r["items"].as_array().and_then(|a| a.first()).and_then(Value::as_u64).map(ItemId).ok_or_else(|| {
bad(
"graphics.newFromFile",
r["errors"].as_array().and_then(|e| e.first()).and_then(Value::as_str).unwrap_or("nothing imported").to_string(),
)
})?;
let pi = s.project.item(item).ok_or_else(|| bad("graphics.newFromFile", "no such item"))?;
if matches!(&pi.kind, ItemKind::Media(m) if m.info.video.is_none()) {
return Err(bad("graphics.newFromFile", "the file has no picture"));
}
let (name, dur) = (pi.name.clone(), pi.duration());
let clip = place_video_clip(s, &name, p, "New Layer from File", Vec::new(), move |_, _| (item, dur))?;
s.state.graphic_layers.clear();
Ok(json!({"clip": clip.0, "item": item.0}))
},
),
align_cmd!("graphics.alignFrame.left", "Left", F, "left", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.hcenter", "Center Horizontally", F, "hcenter", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.right", "Right", F, "right", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.top", "Top", F, "top", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.vcenter", "Center Vertically", F, "vcenter", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.bottom", "Bottom", F, "bottom", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignGroup.left", "Left", G, "left", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.hcenter", "Center Horizontally", G, "hcenter", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.right", "Right", G, "right", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.top", "Top", G, "top", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.vcenter", "Center Vertically", G, "vcenter", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.bottom", "Bottom", G, "bottom", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignSelection.left", "Left", S, "left", AlignTo::Selection, has_two_layers),
align_cmd!("graphics.alignSelection.hcenter", "Center Horizontally", S, "hcenter", AlignTo::Selection, has_two_layers),
align_cmd!("graphics.alignSelection.right", "Right", S, "right", AlignTo::Selection, has_two_layers),
align_cmd!("graphics.alignSelection.top", "Top", S, "top", AlignTo::Selection, has_two_layers),
align_cmd!("graphics.alignSelection.vcenter", "Center Vertically", S, "vcenter", AlignTo::Selection, has_two_layers),
align_cmd!("graphics.alignSelection.bottom", "Bottom", S, "bottom", AlignTo::Selection, has_two_layers),
spec("graphics.distributeVertically", "Distribute Vertically", D, None, ALIGN_PARAMS, has_three_layers, |s, p| distribute_layers(s, p, true, false)),
spec("graphics.distributeSpaceVertically", "Distribute Space Vertically", D, None, ALIGN_PARAMS, has_three_layers, |s, p| {
distribute_layers(s, p, true, true)
}),
spec("graphics.distributeHorizontally", "Distribute Horizontally", D, None, ALIGN_PARAMS, has_three_layers, |s, p| {
distribute_layers(s, p, false, false)
}),
spec("graphics.distributeSpaceHorizontally", "Distribute Space Horizontally", D, None, ALIGN_PARAMS, has_three_layers, |s, p| {
distribute_layers(s, p, false, true)
}),
arrange_cmd!("graphics.bringToFront", "Bring to Front", "Cmd+Shift+]", "front"),
arrange_cmd!("graphics.bringForward", "Bring Forward", "Cmd+]", "forward"),
arrange_cmd!("graphics.sendBackward", "Send Backward", "Cmd+[", "backward"),
arrange_cmd!("graphics.sendToBack", "Send to Back", "Cmd+Shift+[", "back"),
spec("graphics.selectNextGraphic", "Select Next Graphic", SEL, None, "{}", has_graphics_in_seq, |s, _| select_graphic(s, true)),
spec("graphics.selectPreviousGraphic", "Select Previous Graphic", SEL, None, "{}", has_graphics_in_seq, |s, _| select_graphic(s, false)),
spec("graphics.selectNextLayer", "Select Next Layer", SEL, Some("Cmd+Alt+]"), "{}", has_graphic, |s, _| select_layer_step(s, true)),
spec("graphics.selectPreviousLayer", "Select Previous Layer", SEL, Some("Cmd+Alt+["), "{}", has_graphic, |s, _| select_layer_step(s, false)),
spec(
"graphics.resetAllParameters",
"Reset All Parameters",
&["Graphics and Titles"],
None,
r#"{"clip":id?,"layers":[n]? (default: the selected layers, else all)}"#,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.resetAllParameters", "no graphic clip"))?;
let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
let want: Vec<usize> = match p.get("layers").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect(),
None => s.state.graphic_layers.clone(),
};
let n = s.edit_sequence("Reset All Parameters", |q, _, _| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let mut n = 0;
for (l, ei) in layer_indices(&it.effects).into_iter().enumerate() {
if want.is_empty() || want.contains(&l) {
graphic::reset_layer_params(&mut it.effects[ei]);
filmcraft_project::resolve_auto_points(&mut it.effects[ei], frame, frame);
n += 1;
}
}
Ok(n)
})?;
Ok(json!({"clip": clip.0, "reset": n}))
},
),
spec(
"graphics.resetDuration",
"Reset Duration",
&["Graphics and Titles"],
None,
r#"{"clip":id?,"seconds":f64=5 (the default graphic duration; limited by the next clip on the track)}"#,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.resetDuration", "no graphic clip"))?;
let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01);
let d = s.edit_sequence("Reset Duration", |q, _, _| {
let rate = q.settings.frame_rate;
let want = rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration());
let tr = q.video_tracks.iter_mut().find(|tr| tr.items.iter().any(|i| i.id == clip)).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let i = tr.items.iter().position(|i| i.id == clip).expect("found");
let start = tr.items[i].start;
let limit = tr.items.get(i + 1).map_or(want, |n| n.start - start);
let d = want.min(limit).max(rate.frame_duration());
tr.items[i].duration = d;
Ok(d)
})?;
Ok(json!({"clip": clip.0, "duration": d.0}))
},
),
]
}
pub fn commands() -> Vec<CommandSpec> {
let mut v = vec![
spec(
"graphics.newText",
"Text",
&["Graphics and Titles", "New Layer"],
Some("Cmd+T"),
r#"{"text":str="New Text","position":[x,y]?,"clip":id?,"newClip":bool?,"vertical":bool=false,"size":px=100,"font":str?,"fontStyle":str?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
has_seq,
|s, p| {
let text = str_p(p, "text").unwrap_or("New Text").to_string();
// Settings ▸ Graphics ▸ Text: smart quotes, ligatures, default font
let gp = s.prefs.graphics.clone();
let text = if gp.smart_quotes { crate::settings::smart_quotes(&text) } else { text };
let (w, h) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
let pos = vec2_p(p, "position").unwrap_or(Vec2::new(w as f64 / 2.0, h as f64 / 2.0));
let size = f64_p(p, "size").unwrap_or(100.0);
let mut layer = if p.get("vertical").and_then(Value::as_bool) == Some(true) {
graphic::new_vertical_text_layer(&text, pos, size)
} else {
new_text_layer(&text, pos, size)
};
layer.params.insert("ligatures".into(), filmcraft_project::Param::new(ParamValue::Bool(gp.ligatures)));
if !gp.default_font.trim().is_empty() {
layer.params.insert("font".into(), filmcraft_project::Param::new(ParamValue::Text(gp.default_font.trim().into())));
}
for (k, id) in [("font", "font"), ("fontStyle", "font_style")] {
if let Some(v) = str_p(p, k) {
layer.params.insert(id.into(), filmcraft_project::Param::new(ParamValue::Text(v.into())));
}
}
let into = if p.get("newClip").and_then(Value::as_bool) == Some(true) { None } else { u64_p(p, "clip").map(ClipId) };
let into = into.filter(|c| s.active_sequence().is_some_and(|q| is_graphic(s, q, *c)));
let (clip, layer_i) = match into {
Some(c) => (c, add_layer(s, c, layer)?),
None => {
let name = text.lines().next().filter(|l| !l.trim().is_empty()).unwrap_or("Graphic").to_string();
(new_graphic_clip(s, layer, &name, p)?, 0)
}
};
Ok(json!({"clip": clip.0, "layer": layer_i}))
},
),
spec(
"graphics.newShape",
"Shape",
&[],
None,
r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]?,"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id?,"seconds":f64=5}"#,
has_seq,
|s, p| {
let shape = str_p(p, "shape").unwrap_or("rectangle").to_ascii_lowercase();
let k = SHAPE_OPTS
.iter()
.position(|o| o.to_ascii_lowercase() == shape)
.ok_or_else(|| bad("graphics.newShape", format!("unknown shape `{shape}`")))? as u32;
let (w, h) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
let pos = vec2_p(p, "position").unwrap_or(Vec2::new(w as f64 / 2.0, h as f64 / 2.0));
let size = vec2_p(p, "size").unwrap_or(Vec2::new(400.0, 200.0));
let points: Vec<[f32; 2]> = p
.get("points")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|q| Some([q.get(0)?.as_f64()? as f32, q.get(1)?.as_f64()? as f32])).collect())
.unwrap_or_default();
if k == 3 && points.len() < 3 {
return Err(bad("graphics.newShape", "a path needs at least 3 points"));
}
let layer = new_shape_layer(k, pos, size, points);
let into = u64_p(p, "clip").map(ClipId).filter(|c| s.active_sequence().is_some_and(|q| is_graphic(s, q, *c)));
let (clip, layer_i) = match into {
Some(c) => (c, add_layer(s, c, layer)?),
None => (new_graphic_clip(s, layer, "Shape", p)?, 0),
};
Ok(json!({"clip": clip.0, "layer": layer_i}))
},
),
spec(
"graphics.setText",
"Edit Text",
&[],
None,
r#"{"clip":id?,"layer":n|name?,"text":str,"merge":bool? (coalesce with the previous Edit Text undo step)}"#,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.setText", "no graphic clip"))?;
let (_, ei) = layer_effect_index(s, clip, p)?;
let text = str_p(p, "text").ok_or_else(|| bad("graphics.setText", "need `text`"))?.to_string();
let merge = p.get("merge").and_then(Value::as_bool).unwrap_or(false) && s.history.undo.last().is_some_and(|h| h.0 == "Edit Text");
let mut props = serde_json::Map::new();
props.insert("text".into(), Value::String(text));
let ph = s.playhead();
set_props(s, clip, ei, &props, ph, "Edit Text")?;
if merge && s.history.undo.len() >= 2 {
// keep the snapshot from before the typing session
s.history.undo.pop();
}
Ok(Value::Null)
},
),
spec(
"graphics.set",
"Set Graphic Properties",
&[],
None,
r##"{"clip":id?,"layer":n|name?,"props":{"font":"Inter","font_style":"Bold","size":120,"align":"center","tracking":50,"leading":0,"fill_color":"#ffcc00","stroke":true,"stroke_width":6,"background":true,"shadow":true,"position":[x,y],"scale":100,"rotation":0,"opacity":100,…},"time":ticks?}"##,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.set", "no graphic clip"))?;
let (_, ei) = layer_effect_index(s, clip, p)?;
let props = p.get("props").and_then(Value::as_object).ok_or_else(|| bad("graphics.set", "need `props`"))?.clone();
let tl = time_p(s, p, "").unwrap_or_else(|| s.playhead());
set_props(s, clip, ei, &props, tl, "Change Graphic Property")?;
Ok(Value::Null)
},
),
spec("graphics.selectLayer", "Select Graphic Layer", &[], None, r#"{"clip":id?,"layers":[n]}"#, has_graphic, |s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.selectLayer", "no graphic clip"))?;
let layers: Vec<usize> =
p.get("layers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()).unwrap_or_default();
s.state.selection = vec![clip];
s.state.graphic_layers = layers;
Ok(Value::Null)
}),
spec("graphics.deleteLayer", "Delete Graphic Layer", &[], None, r#"{"clip":id?,"layer":n?}"#, has_graphic, |s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.deleteLayer", "no graphic clip"))?;
let (_, ei) = layer_effect_index(s, clip, p)?;
s.edit_sequence("Delete Graphic Layer", |q, _, st| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
it.effects.remove(ei);
st.graphic_layers.clear();
Ok(())
})?;
Ok(Value::Null)
}),
spec(
"graphics.arrangeLayer",
"Arrange Graphic Layer",
&[],
None,
r#"{"clip":id?,"layer":n?,"to":"front|back|forward|backward"|index}"#,
has_graphic,
|s, p| {
let to = p.get("to").cloned().unwrap_or(json!("front"));
arrange_layer(s, p, to)
},
),
spec(
"graphics.align",
"Align Layers",
&[],
None,
r#"{"clip":id?,"layers":[n]?,"align":"left|hcenter|right|top|vcenter|bottom","to":"frame|group|selection"="frame" (frame: each layer; group: the layers' union; one layer always aligns to the frame)}"#,
has_graphic,
|s, p| {
let how = str_p(p, "align").ok_or_else(|| bad("graphics.align", "need `align`"))?.to_string();
let mode = match str_p(p, "to") {
Some("selection") => AlignTo::Selection,
Some("group") => AlignTo::FrameGroup,
_ => AlignTo::Frame,
};
align_layers(s, p, &how, mode)
},
),
spec(
"graphics.distribute",
"Distribute Layers",
&[],
None,
r#"{"clip":id?,"layers":[n] (3 or more)?,"axis":"horizontal|vertical","space":bool=false (equal gaps instead of equal centre spacing)}"#,
has_graphic,
|s, p| {
let vertical = str_p(p, "axis") == Some("vertical");
let space = p.get("space").and_then(Value::as_bool).unwrap_or(false);
distribute_layers(s, p, vertical, space)
},
),
query("graphics.list", "List Graphic Layers", r#"{"clip":id?}"#, |s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.list", "no graphic clip"))?;
list_layers(s, clip)
}),
query("fonts.list", "List Fonts", r#"{"system":bool=true (scan the system font folders)}"#, |_, p| {
if p.get("system").and_then(Value::as_bool).unwrap_or(true) {
filmcraft_text::fonts::scan_system();
}
Ok(json!(filmcraft_text::families().into_iter().map(|(f, st)| json!({"family": f, "styles": st})).collect::<Vec<_>>()))
}),
];
v.extend(menu_commands());
v
}
#[cfg(test)]
#[path = "graphics_tests.rs"]
mod tests;