mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-24 10:10:05 -06:00
M10.6: Graphics and Titles menu: New Layer (Vertical Text, Rectangle, Ellipse, Polygon, From file), Align to Video Frame / as Group / to Selection, Distribute (centres and gaps), Arrange, Select Next/Previous Graphic and Layer, Reset All Parameters, Reset Duration
Vertical text layout in the text engine (one line per character, columns right to left). All edits are undoable engine commands; engine tests cover each menu group. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
89cfbf24f6
commit
012bcf7aa5
+435
-83
@@ -96,22 +96,38 @@ fn graphic_source(p: &mut filmcraft_project::Project, w: u32, h: u32, rate: film
|
||||
/// 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.
|
||||
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 seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01);
|
||||
let want_track = u64_p(p, "track").map(|v| v as usize);
|
||||
let name = name.to_string();
|
||||
s.edit("New Graphic", |pr, st| {
|
||||
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 = graphic_source(pr, w, h, rate);
|
||||
let (src, dur) = source(pr, (w, h, rate));
|
||||
let t = rate.snap(t);
|
||||
let dur = rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration());
|
||||
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.push(layer);
|
||||
ti.effects.extend(extra);
|
||||
for e in &mut ti.effects {
|
||||
filmcraft_project::resolve_auto_points(e, (w, h), (w, h));
|
||||
}
|
||||
@@ -307,20 +323,416 @@ fn move_layers(s: &mut Session, clip: ClipId, moves: Vec<(usize, Vec2)>, label:
|
||||
})
|
||||
}
|
||||
|
||||
pub fn commands() -> Vec<CommandSpec> {
|
||||
#[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?,"size":px=100,"font":str?,"fontStyle":str?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
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();
|
||||
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 mut layer = new_text_layer(&text, pos, f64_p(p, "size").unwrap_or(100.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)
|
||||
};
|
||||
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())));
|
||||
@@ -341,7 +753,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
spec(
|
||||
"graphics.newShape",
|
||||
"Shape",
|
||||
&["Graphics and Titles", "New Layer"],
|
||||
&[],
|
||||
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,
|
||||
@@ -437,34 +849,8 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
r#"{"clip":id?,"layer":n?,"to":"front|back|forward|backward"|index}"#,
|
||||
has_graphic,
|
||||
|s, p| {
|
||||
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.arrangeLayer", "no graphic clip"))?;
|
||||
let (l, _) = layer_effect_index(s, clip, p)?;
|
||||
let to = p.get("to").cloned().unwrap_or(json!("front"));
|
||||
let n = s.state.graphic_layers.len();
|
||||
let _ = n;
|
||||
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.graphic_layers = vec![dest];
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
arrange_layer(s, p, to)
|
||||
},
|
||||
),
|
||||
spec(
|
||||
@@ -472,34 +858,16 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
"Align Layers",
|
||||
&[],
|
||||
None,
|
||||
r#"{"clip":id?,"layers":[n]?,"align":"left|hcenter|right|top|vcenter|bottom","to":"frame|selection"="frame" (one layer always aligns to the frame)}"#,
|
||||
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 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 how = str_p(p, "align").ok_or_else(|| bad("graphics.align", "need `align`"))?;
|
||||
let to_sel = str_p(p, "to") == Some("selection") && boxes.len() > 1;
|
||||
let r = if to_sel {
|
||||
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])])
|
||||
} else {
|
||||
[0.0, 0.0, size.0 as f64, size.1 as f64]
|
||||
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,
|
||||
};
|
||||
let mut moves = Vec::new();
|
||||
for (ei, pos, b) in boxes {
|
||||
let (dx, dy) = 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}`"))),
|
||||
};
|
||||
moves.push((ei, Vec2::new(pos.x + dx, pos.y + dy)));
|
||||
}
|
||||
move_layers(s, clip, moves, "Align Layers")?;
|
||||
Ok(Value::Null)
|
||||
align_layers(s, p, &how, mode)
|
||||
},
|
||||
),
|
||||
spec(
|
||||
@@ -507,30 +875,12 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
"Distribute Layers",
|
||||
&[],
|
||||
None,
|
||||
r#"{"clip":id?,"layers":[n] (3 or more)?,"axis":"horizontal|vertical"}"#,
|
||||
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 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 vertical = str_p(p, "axis") == Some("vertical");
|
||||
let c = |b: &[f64; 4]| if vertical { (b[1] + b[3]) / 2.0 } else { (b[0] + b[2]) / 2.0 };
|
||||
boxes.sort_by(|a, b| c(&a.2).total_cmp(&c(&b.2)));
|
||||
let (first, last) = (c(&boxes[0].2), c(&boxes[boxes.len() - 1].2));
|
||||
let n = boxes.len() - 1;
|
||||
let moves = boxes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (ei, pos, b))| {
|
||||
let d = first + (last - first) * i as f64 / n as f64 - c(b);
|
||||
(*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(Value::Null)
|
||||
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| {
|
||||
@@ -543,7 +893,9 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!(filmcraft_text::families().into_iter().map(|(f, st)| json!({"family": f, "styles": st})).collect::<Vec<_>>()))
|
||||
}),
|
||||
]
|
||||
];
|
||||
v.extend(menu_commands());
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -128,3 +128,221 @@ fn graphic_renders_in_the_program_and_survives_save() {
|
||||
let fonts = s.execute("fonts.list", json!({"system": false})).unwrap();
|
||||
assert!(fonts.as_array().unwrap().iter().any(|f| f["family"] == "Inter"));
|
||||
}
|
||||
|
||||
fn quad_box(s: &mut Session, clip: u64, layer: usize) -> [f64; 4] {
|
||||
let l = s.execute("graphics.list", json!({"clip": clip})).unwrap();
|
||||
let q = l["layers"][layer]["quad"].as_array().unwrap().clone();
|
||||
let xs: Vec<f64> = q.iter().map(|p| p[0].as_f64().unwrap()).collect();
|
||||
let ys: Vec<f64> = q.iter().map(|p| p[1].as_f64().unwrap()).collect();
|
||||
[
|
||||
xs.iter().copied().fold(f64::MAX, f64::min),
|
||||
ys.iter().copied().fold(f64::MAX, f64::min),
|
||||
xs.iter().copied().fold(f64::MIN, f64::max),
|
||||
ys.iter().copied().fold(f64::MIN, f64::max),
|
||||
]
|
||||
}
|
||||
|
||||
fn three_shapes(s: &mut Session) -> u64 {
|
||||
let r = s.execute("graphics.newRectangle", json!({"position": [300, 200], "size": [100, 60]})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
s.execute("graphics.newEllipse", json!({"clip": clip, "position": [500, 400], "size": [40, 40]})).unwrap();
|
||||
s.execute("graphics.newPolygon", json!({"clip": clip, "position": [1500, 900], "size": [80, 80], "sides": 5})).unwrap();
|
||||
clip
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layer_menu_items_make_shapes_and_vertical_text() {
|
||||
let mut s = demo();
|
||||
let clip = three_shapes(&mut s);
|
||||
let list = s.execute("graphics.list", json!({"clip": clip})).unwrap();
|
||||
let kinds: Vec<&str> = list["layers"].as_array().unwrap().iter().map(|l| l["kind"].as_str().unwrap()).collect();
|
||||
assert_eq!(kinds, ["Rectangle", "Ellipse", "Polygon"]);
|
||||
let l = layers(&s, ClipId(clip));
|
||||
let LayerContent::Shape(p) = &l[2].content else { panic!() };
|
||||
assert_eq!(p.sides, 5, "sides set");
|
||||
// the polygon (with its side count) is one undo step
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layers(&s, ClipId(clip)).len(), 2);
|
||||
// vertical text: taller than wide
|
||||
let r = s.execute("graphics.newVerticalText", json!({"text": "TALL", "newClip": true})).unwrap();
|
||||
let v = r["clip"].as_u64().unwrap();
|
||||
let LayerContent::Text(t) = &layers(&s, ClipId(v))[0].content else { panic!() };
|
||||
assert!(t.vertical);
|
||||
let b = quad_box(&mut s, v, 0);
|
||||
assert!(b[3] - b[1] > 2.5 * (b[2] - b[0]), "vertical text is tall: {b:?}");
|
||||
// the menu has the New Layer entries and no generic "Shape"
|
||||
let ids: Vec<&str> = crate::command_specs().iter().filter(|c| c.menu == ["Graphics and Titles", "New Layer"]).map(|c| c.id).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
["graphics.newText", "graphics.newVerticalText", "graphics.newRectangle", "graphics.newEllipse", "graphics.newPolygon", "graphics.newFromFile"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_menu_commands_frame_group_and_selection() {
|
||||
let mut s = demo();
|
||||
let (w, h) = {
|
||||
let q = s.active_sequence().unwrap();
|
||||
(q.settings.width as f64, q.settings.height as f64)
|
||||
};
|
||||
let clip = three_shapes(&mut s);
|
||||
// one layer: align to the video frame
|
||||
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [0]})).unwrap();
|
||||
assert!(s.execute("graphics.alignGroup.left", json!({})).is_err(), "group align needs two layers");
|
||||
s.execute("graphics.alignFrame.right", json!({})).unwrap();
|
||||
let b = quad_box(&mut s, clip, 0);
|
||||
assert!((b[2] - w).abs() < 1e-6, "{b:?}");
|
||||
s.execute("graphics.alignFrame.vcenter", json!({})).unwrap();
|
||||
let b = quad_box(&mut s, clip, 0);
|
||||
assert!(((b[1] + b[3]) / 2.0 - h / 2.0).abs() < 1e-6);
|
||||
// group: the union moves, relative positions kept
|
||||
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [1, 2]})).unwrap();
|
||||
let (b1, b2) = (quad_box(&mut s, clip, 1), quad_box(&mut s, clip, 2));
|
||||
s.execute("graphics.alignGroup.top", json!({})).unwrap();
|
||||
let (c1, c2) = (quad_box(&mut s, clip, 1), quad_box(&mut s, clip, 2));
|
||||
assert!(c1[1].abs() < 1e-6, "topmost layer on the frame top: {c1:?}");
|
||||
assert!(((c2[1] - c1[1]) - (b2[1] - b1[1])).abs() < 1e-6, "relative offset kept");
|
||||
// each frame-aligned: both on the left edge individually
|
||||
s.execute("graphics.alignFrame.left", json!({})).unwrap();
|
||||
assert!(quad_box(&mut s, clip, 1)[0].abs() < 1e-6 && quad_box(&mut s, clip, 2)[0].abs() < 1e-6);
|
||||
// to selection: bottoms meet the lowest bottom
|
||||
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [0, 1, 2]})).unwrap();
|
||||
let lowest = (0..3).map(|i| quad_box(&mut s, clip, i)[3]).fold(f64::MIN, f64::max);
|
||||
s.execute("graphics.alignSelection.bottom", json!({})).unwrap();
|
||||
for i in 0..3 {
|
||||
assert!((quad_box(&mut s, clip, i)[3] - lowest).abs() < 1e-6);
|
||||
}
|
||||
// undo restores
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!((0..3).any(|i| (quad_box(&mut s, clip, i)[3] - lowest).abs() > 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribute_centres_and_spaces() {
|
||||
let mut s = demo();
|
||||
let r = s.execute("graphics.newRectangle", json!({"position": [100, 500], "size": [100, 40]})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
s.execute("graphics.newRectangle", json!({"clip": clip, "position": [300, 500], "size": [20, 40]})).unwrap();
|
||||
s.execute("graphics.newRectangle", json!({"clip": clip, "position": [1000, 500], "size": [300, 40]})).unwrap();
|
||||
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [0, 1]})).unwrap();
|
||||
assert!(s.execute("graphics.distributeHorizontally", json!({})).is_err(), "needs three");
|
||||
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [0, 1, 2]})).unwrap();
|
||||
s.execute("graphics.distributeSpaceHorizontally", json!({})).unwrap();
|
||||
let b: Vec<[f64; 4]> = (0..3).map(|i| quad_box(&mut s, clip, i)).collect();
|
||||
let (g1, g2) = (b[1][0] - b[0][2], b[2][0] - b[1][2]);
|
||||
assert!((g1 - g2).abs() < 1e-6, "equal gaps {g1} {g2}");
|
||||
s.execute("graphics.distributeHorizontally", json!({})).unwrap();
|
||||
let c: Vec<f64> = (0..3).map(|i| quad_box(&mut s, clip, i)).map(|b| (b[0] + b[2]) / 2.0).collect();
|
||||
assert!(((c[1] - c[0]) - (c[2] - c[1])).abs() < 1e-6, "{c:?}");
|
||||
// vertical variants move only y
|
||||
s.execute("graphics.newRectangle", json!({"clip": clip, "position": [800, 100], "size": [40, 40]})).unwrap();
|
||||
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [0, 1, 3]})).unwrap();
|
||||
let x0 = quad_box(&mut s, clip, 3)[0];
|
||||
s.execute("graphics.distributeVertically", json!({})).unwrap();
|
||||
s.execute("graphics.distributeSpaceVertically", json!({})).unwrap();
|
||||
assert!((quad_box(&mut s, clip, 3)[0] - x0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_select_and_reset() {
|
||||
let mut s = demo();
|
||||
let clip = three_shapes(&mut s);
|
||||
let kind = |s: &mut Session, i: usize| s.execute("graphics.list", json!({"clip": clip})).unwrap()["layers"][i]["kind"].as_str().unwrap().to_string();
|
||||
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [0]})).unwrap();
|
||||
s.execute("graphics.bringToFront", json!({})).unwrap();
|
||||
assert_eq!(kind(&mut s, 2), "Rectangle");
|
||||
assert_eq!(s.state.graphic_layers, vec![2], "selection follows the layer");
|
||||
s.execute("graphics.sendBackward", json!({})).unwrap();
|
||||
assert_eq!(kind(&mut s, 1), "Rectangle");
|
||||
s.execute("graphics.sendToBack", json!({})).unwrap();
|
||||
assert_eq!(kind(&mut s, 0), "Rectangle");
|
||||
s.execute("graphics.bringForward", json!({})).unwrap();
|
||||
assert_eq!(kind(&mut s, 1), "Rectangle");
|
||||
// menu shortcuts
|
||||
let spec = |id: &str| crate::command_specs().iter().find(|c| c.id == id).unwrap();
|
||||
assert_eq!(spec("graphics.bringToFront").shortcut, Some("Cmd+Shift+]"));
|
||||
assert_eq!(spec("graphics.sendBackward").shortcut, Some("Cmd+["));
|
||||
// select next / previous layer cycles
|
||||
s.execute("graphics.selectNextLayer", json!({})).unwrap();
|
||||
assert_eq!(s.state.graphic_layers, vec![2]);
|
||||
s.execute("graphics.selectNextLayer", json!({})).unwrap();
|
||||
assert_eq!(s.state.graphic_layers, vec![0]);
|
||||
s.execute("graphics.selectPreviousLayer", json!({})).unwrap();
|
||||
assert_eq!(s.state.graphic_layers, vec![2]);
|
||||
// reset all parameters of the selected layer: position back to the centre, content kept
|
||||
s.execute("graphics.set", json!({"clip": clip, "layer": 2, "props": {"rotation": 30, "opacity": 50}})).unwrap();
|
||||
s.execute("graphics.resetAllParameters", json!({})).unwrap();
|
||||
let l = &layers(&s, ClipId(clip))[2];
|
||||
let (w, h) = {
|
||||
let q = s.active_sequence().unwrap();
|
||||
(q.settings.width as f64, q.settings.height as f64)
|
||||
};
|
||||
assert_eq!((l.transform.rotation, l.transform.opacity), (0.0, 1.0));
|
||||
assert_eq!((l.transform.position.x, l.transform.position.y), (w / 2.0, h / 2.0));
|
||||
let LayerContent::Shape(sh) = &l.content else { panic!() };
|
||||
assert_eq!(sh.shape, 2, "still a polygon");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layers(&s, ClipId(clip))[2].transform.rotation, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_next_graphic_and_reset_duration() {
|
||||
let mut s = demo();
|
||||
s.execute("playhead.set", json!({"seconds": 1})).unwrap();
|
||||
let a = s.execute("graphics.newText", json!({"text": "A"})).unwrap()["clip"].as_u64().unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 8})).unwrap();
|
||||
let b = s.execute("graphics.newText", json!({"text": "B", "seconds": 2})).unwrap()["clip"].as_u64().unwrap();
|
||||
s.execute("graphics.selectPreviousGraphic", json!({})).unwrap();
|
||||
assert_eq!(s.state.selection, vec![ClipId(a)]);
|
||||
assert!(s.playhead() < Tick::from_seconds_f64(6.0), "playhead moved onto the graphic");
|
||||
s.execute("graphics.selectNextGraphic", json!({})).unwrap();
|
||||
assert_eq!(s.state.selection, vec![ClipId(b)]);
|
||||
assert!(s.execute("graphics.selectNextGraphic", json!({})).is_err(), "no next graphic");
|
||||
// reset duration: 2 s → 5 s
|
||||
let r = s.execute("graphics.resetDuration", json!({})).unwrap();
|
||||
let rate = s.active_sequence().unwrap().settings.frame_rate;
|
||||
assert_eq!(Tick(r["duration"].as_i64().unwrap()), rate.snap_nearest(Tick::from_seconds_f64(5.0)));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(q.find_item(ClipId(b)).unwrap().1.duration, rate.snap_nearest(Tick::from_seconds_f64(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layer_from_file_places_the_image_above() {
|
||||
let mut s = demo();
|
||||
let dir = crate::media_test_util::tmp_dir("gfx-file");
|
||||
// a 4×2 24-bit BMP
|
||||
let (w, h) = (4u32, 2u32);
|
||||
let row = (w * 3).div_ceil(4) * 4;
|
||||
let size = 54 + row * h;
|
||||
let mut b = Vec::new();
|
||||
b.extend(b"BM");
|
||||
b.extend(size.to_le_bytes());
|
||||
b.extend([0u8; 4]);
|
||||
b.extend(54u32.to_le_bytes());
|
||||
b.extend(40u32.to_le_bytes());
|
||||
b.extend((w as i32).to_le_bytes());
|
||||
b.extend((h as i32).to_le_bytes());
|
||||
b.extend(1u16.to_le_bytes());
|
||||
b.extend(24u16.to_le_bytes());
|
||||
b.extend([0u8; 24]);
|
||||
for _ in 0..h {
|
||||
for _ in 0..w {
|
||||
b.extend([0u8, 0, 255]);
|
||||
}
|
||||
b.resize(b.len() + (row - w * 3) as usize, 0);
|
||||
}
|
||||
let path = dir.join("logo.bmp");
|
||||
std::fs::write(&path, &b).unwrap();
|
||||
let t = s.playhead();
|
||||
let r = s.execute("graphics.newFromFile", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (tid, it) = q.find_item(clip).unwrap();
|
||||
assert_eq!(it.start, q.settings.frame_rate.snap(t));
|
||||
let ti = q.video_tracks.iter().position(|tr| tr.id == tid).unwrap();
|
||||
assert!(q.video_tracks[..ti].iter().any(|tr| tr.item_at(t).is_some()), "above the footage");
|
||||
assert_eq!(s.state.selection, vec![clip]);
|
||||
assert!(s.execute("graphics.newFromFile", json!({})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ pub(crate) fn layer_defs() -> Vec<EffectDef> {
|
||||
chc("caps", "Capitalisation", CAPS_OPTS, 0, X),
|
||||
bo("underline", "Underline", false, X),
|
||||
fl("box_width", "Text Box Width", 0.0, (0.0, 100_000.0), (0.0, 3840.0), "", X),
|
||||
bo("vertical", "Vertical Text", false, X),
|
||||
],
|
||||
),
|
||||
layer_def(
|
||||
@@ -193,6 +194,8 @@ pub struct TextProps {
|
||||
pub underline: bool,
|
||||
/// Area-text width (0 = point text).
|
||||
pub box_width: f32,
|
||||
/// Vertical text (characters stacked top to bottom; columns right to left).
|
||||
pub vertical: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -273,6 +276,7 @@ pub fn eval_layer(e: &EffectInstance, t: Tick, frame: (u32, u32)) -> Option<Laye
|
||||
caps: ch(e, "caps", t),
|
||||
underline: bb(e, "underline", t),
|
||||
box_width: ff(e, "box_width", t).max(0.0),
|
||||
vertical: bb(e, "vertical", t),
|
||||
}),
|
||||
SHAPE_LAYER => {
|
||||
let sz = vv(e, "size", t);
|
||||
@@ -337,6 +341,27 @@ pub fn new_text_layer(text: &str, position: Vec2, size: f64) -> EffectInstance {
|
||||
e
|
||||
}
|
||||
|
||||
/// A new vertical text layer (Graphics ▸ New Layer ▸ Vertical Text).
|
||||
pub fn new_vertical_text_layer(text: &str, position: Vec2, size: f64) -> EffectInstance {
|
||||
let mut e = new_text_layer(text, position, size);
|
||||
set(&mut e, "vertical", ParamValue::Bool(true));
|
||||
e
|
||||
}
|
||||
|
||||
/// Reset a layer's parameters to their defaults, keeping its content (name, text, shape kind,
|
||||
/// geometry and path) — Graphics ▸ Reset All Parameters.
|
||||
pub fn reset_layer_params(e: &mut EffectInstance) {
|
||||
const KEEP: &[&str] = &["name", "text", "shape", "size", "sides", "points", "vertical"];
|
||||
let Some(def) = crate::effect::find_effect(&e.effect) else { return };
|
||||
let mut fresh = def.instance();
|
||||
for k in KEEP {
|
||||
if let Some(p) = e.params.remove(*k) {
|
||||
fresh.params.insert((*k).to_string(), p);
|
||||
}
|
||||
}
|
||||
e.params = fresh.params;
|
||||
}
|
||||
|
||||
/// A new shape layer centred at `position`.
|
||||
pub fn new_shape_layer(shape: u32, position: Vec2, size: Vec2, points: Vec<[f32; 2]>) -> EffectInstance {
|
||||
let mut e = crate::effect::find_effect(SHAPE_LAYER).expect("registered").instance();
|
||||
|
||||
@@ -57,8 +57,9 @@ pub fn text_styles(t: &TextProps) -> (TextStyle, ParagraphStyle) {
|
||||
_ => Align::Left,
|
||||
},
|
||||
leading: t.leading,
|
||||
width: (t.box_width > 0.0).then_some(t.box_width),
|
||||
width: (t.box_width > 0.0 && !t.vertical).then_some(t.box_width),
|
||||
rtl: None,
|
||||
vertical: t.vertical,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ pub struct ParagraphStyle {
|
||||
pub width: Option<f32>,
|
||||
/// Base direction: None = from the first strong character.
|
||||
pub rtl: Option<bool>,
|
||||
/// Vertical text: characters stack top to bottom, paragraphs are columns laid out right to
|
||||
/// left. Alignment applies along the column (Left = top, Center, Right = bottom).
|
||||
pub vertical: bool,
|
||||
}
|
||||
|
||||
fn hf(h: &mut impl Hasher, v: f32) {
|
||||
@@ -106,6 +109,7 @@ impl Hash for ParagraphStyle {
|
||||
hf(h, self.leading);
|
||||
self.width.map(f32::to_bits).hash(h);
|
||||
self.rtl.hash(h);
|
||||
self.vertical.hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +154,8 @@ pub struct Layout {
|
||||
/// The face requested was missing (substituted).
|
||||
pub missing_font: bool,
|
||||
pub text_len: usize,
|
||||
/// Laid out vertically (one line per character; columns right to left).
|
||||
pub vertical: bool,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
@@ -172,7 +178,15 @@ impl Layout {
|
||||
}
|
||||
/// Nearest caret byte offset to point `(x, y)`.
|
||||
pub fn hit(&self, x: f32, y: f32) -> usize {
|
||||
let Some(l) = self.lines.iter().min_by(|a, b| line_dist(a, y).total_cmp(&line_dist(b, y))) else {
|
||||
let d = |l: &Line| {
|
||||
if self.vertical {
|
||||
let dx = if x < l.x - l.ascent * 0.5 { l.x - l.ascent * 0.5 - x } else { (x - l.x - l.width - l.ascent * 0.5).max(0.0) };
|
||||
line_dist(l, y) + dx
|
||||
} else {
|
||||
line_dist(l, y)
|
||||
}
|
||||
};
|
||||
let Some(l) = self.lines.iter().min_by(|a, b| d(a).total_cmp(&d(b))) else {
|
||||
return 0;
|
||||
};
|
||||
l.carets.iter().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
|
||||
@@ -504,6 +518,9 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
|
||||
let pm = fonts::face(primary.face).metrics(style.size);
|
||||
let line_h = (pm.ascent + pm.descent + pm.line_gap).max(style.size * 0.5) + para.leading;
|
||||
let mut lay = Layout { missing_font: primary.missing, text_len: text.len(), ..Default::default() };
|
||||
if para.vertical {
|
||||
return layout_vertical(text, style, para, primary, line_h, lay);
|
||||
}
|
||||
let mut base = 0usize;
|
||||
let mut plines = Vec::new();
|
||||
for p in text.split('\n') {
|
||||
@@ -558,6 +575,63 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
|
||||
lay
|
||||
}
|
||||
|
||||
/// Vertical layout: each character is its own line (so carets, hit testing and selection work
|
||||
/// unchanged), stacked top to bottom and centred on its column; columns run right to left.
|
||||
fn layout_vertical(text: &str, style: &TextStyle, para: &ParagraphStyle, primary: Resolved, col_w: f32, mut lay: Layout) -> Layout {
|
||||
let pm = fonts::face(primary.face).metrics(style.size);
|
||||
let row = (pm.ascent + pm.descent).max(style.size * 0.5) + style.tracking * style.size / 1000.0;
|
||||
let flat = ParagraphStyle::default();
|
||||
lay.vertical = true;
|
||||
let mut base = 0usize;
|
||||
let mut ncols = 0usize;
|
||||
for (c, p) in text.split('\n').enumerate() {
|
||||
ncols = c + 1;
|
||||
let p_clean = p.strip_suffix('\r').unwrap_or(p);
|
||||
let mut cells: Vec<ParaLine> = Vec::new();
|
||||
if p_clean.is_empty() {
|
||||
cells.extend(paragraph("", base, style, &flat, primary));
|
||||
}
|
||||
for (off, ch) in p_clean.char_indices() {
|
||||
cells.extend(paragraph(&p_clean[off..off + ch.len_utf8()], base + off, style, &flat, primary));
|
||||
}
|
||||
let h = row * cells.len() as f32;
|
||||
let y0 = match para.align {
|
||||
Align::Center => -h / 2.0,
|
||||
Align::Right => -h,
|
||||
_ => 0.0,
|
||||
};
|
||||
let cx = -(c as f32) * col_w;
|
||||
for (k, pl) in cells.into_iter().enumerate() {
|
||||
let baseline = y0 + row * k as f32;
|
||||
let shift = cx - pl.content / 2.0 - pl.left_trim;
|
||||
let g0 = lay.glyphs.len();
|
||||
lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| {
|
||||
g.x += shift;
|
||||
g.y += baseline;
|
||||
g
|
||||
}));
|
||||
lay.lines.push(Line {
|
||||
range: pl.range,
|
||||
baseline,
|
||||
x: shift + pl.left_trim,
|
||||
width: pl.content,
|
||||
ascent: pl.ascent,
|
||||
descent: pl.descent,
|
||||
glyphs: g0..lay.glyphs.len(),
|
||||
carets: pl.carets.into_iter().map(|(b, x)| (b, x + shift)).collect(),
|
||||
});
|
||||
}
|
||||
base += p.len() + 1;
|
||||
}
|
||||
let (mut y0, mut y1) = (f32::MAX, f32::MIN);
|
||||
for l in &lay.lines {
|
||||
y0 = y0.min(l.baseline - l.ascent);
|
||||
y1 = y1.max(l.baseline + l.descent);
|
||||
}
|
||||
lay.bounds = [-(ncols.max(1) as f32 - 0.5) * col_w, y0, col_w / 2.0, y1.max(y0)];
|
||||
lay
|
||||
}
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<u64, (u64, Arc<Layout>)>> {
|
||||
static C: OnceLock<Mutex<HashMap<u64, (u64, Arc<Layout>)>>> = OnceLock::new();
|
||||
C.get_or_init(Default::default)
|
||||
@@ -653,6 +727,29 @@ mod tests {
|
||||
assert!((l2.lines[1].baseline - l2.lines[0].baseline - gap - 10.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_text_stacks_characters_in_columns() {
|
||||
let p = ParagraphStyle { vertical: true, ..Default::default() };
|
||||
let l = layout("abc\nde", &st(40.0), &p);
|
||||
assert!(l.vertical);
|
||||
assert_eq!(l.lines.len(), 5, "one line per character");
|
||||
// first column: same centre x, increasing baselines
|
||||
let cx = |i: usize| l.lines[i].x + l.lines[i].width / 2.0;
|
||||
assert!((cx(0) - cx(2)).abs() < 1e-3 && l.lines[1].baseline > l.lines[0].baseline && l.lines[2].baseline > l.lines[1].baseline);
|
||||
// second column is to the left of the first
|
||||
assert!(cx(3) < cx(0) - 20.0, "{} {}", cx(3), cx(0));
|
||||
assert_eq!(l.lines[3].range, 4..5);
|
||||
// taller than wide for a single column
|
||||
let one = layout("abcdef", &st(40.0), &p);
|
||||
assert!(one.bounds[3] - one.bounds[1] > 3.0 * (one.bounds[2] - one.bounds[0]), "{:?}", one.bounds);
|
||||
// hit testing finds the character in the second column
|
||||
let (x, b, _) = l.caret(4);
|
||||
assert_eq!(l.hit(x + 1.0, b - 5.0), 4);
|
||||
// differs from horizontal layout in the cache
|
||||
let h = layout("abc\nde", &st(40.0), &ParagraphStyle::default());
|
||||
assert_eq!(h.lines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carets_and_hit_testing() {
|
||||
let l = layout("abc\nde", &st(40.0), &ParagraphStyle::default());
|
||||
|
||||
+20
-4
@@ -45,14 +45,26 @@ Timecode and Clip Name effects also draw with the text engine.
|
||||
|
||||
| Command | Menu / shortcut | Params |
|
||||
|---|---|---|
|
||||
| `graphics.newText` | Graphics and Titles ▸ New Layer ▸ Text (⌘T) | `text`, `position`, `clip` (add to this graphic), `size`, `font`, `fontStyle`, `seconds` (5), `track`, `time` |
|
||||
| `graphics.newShape` | Graphics and Titles ▸ New Layer ▸ Shape | `shape` (rectangle/ellipse/polygon/path), `position`, `size`, `points`, `clip` |
|
||||
| `graphics.newText` | Graphics and Titles ▸ New Layer ▸ Text (⌘T) | `text`, `position`, `clip` (add to this graphic), `vertical`, `size`, `font`, `fontStyle`, `seconds` (5), `track`, `time` |
|
||||
| `graphics.newVerticalText` | New Layer ▸ Vertical Text | as `graphics.newText`; characters stack top to bottom, paragraphs are columns right to left |
|
||||
| `graphics.newRectangle`, `graphics.newEllipse`, `graphics.newPolygon` | New Layer ▸ Rectangle (⌥⌘R), Ellipse (⌥⌘E), Polygon | `position`, `size`, `clip`; polygon `sides` (6) |
|
||||
| `graphics.newFromFile` | New Layer ▸ From file… | `path` — imports the image or video and places it above the clips at the playhead (a separate clip; graphics have no media layers yet) |
|
||||
| `graphics.newShape` | (agents) | `shape` (rectangle/ellipse/polygon/path), `position`, `size`, `points`, `clip` |
|
||||
| `graphics.setText` | typing on the monitor | `clip`, `layer`, `text`, `merge` (coalesce one typing session into one undo step) |
|
||||
| `graphics.set` | Properties panel | `clip`, `layer`, `props` {parameter id or camelCase alias: value; choices by index or name}, `time` |
|
||||
| `graphics.selectLayer` | layer list / monitor click | `clip`, `layers` |
|
||||
| `graphics.deleteLayer`, `graphics.arrangeLayer` | layer list | `clip`, `layer`, `to` (front/back/forward/backward/index) |
|
||||
| `graphics.align` | Align and Transform | `align` (left/hcenter/right/top/vcenter/bottom), `to` (frame/selection), `layers` |
|
||||
| `graphics.distribute` | Align and Transform | `axis` (horizontal/vertical), `layers` (3+) |
|
||||
| `graphics.align` | Align and Transform | `align` (left/hcenter/right/top/vcenter/bottom), `to` (frame/group/selection), `layers` |
|
||||
| `graphics.alignFrame.<how>` | Align to Video Frame ▸ Left / Center Horizontally / Right / Top / Center Vertically / Bottom | each selected layer to the frame |
|
||||
| `graphics.alignGroup.<how>` | Align to Video Frame as Group ▸ … | the selected layers' union to the frame (2+ layers; relative positions kept) |
|
||||
| `graphics.alignSelection.<how>` | Align to Selection ▸ … | each selected layer to the selection's union (2+ layers) |
|
||||
| `graphics.distribute` | Align and Transform | `axis` (horizontal/vertical), `space` (equal gaps), `layers` (3+) |
|
||||
| `graphics.distributeVertically`, `…SpaceVertically`, `…Horizontally`, `…SpaceHorizontally` | Distribute ▸ … | the selected layers (3+): equal centre spacing or equal gaps |
|
||||
| `graphics.bringToFront`, `graphics.bringForward`, `graphics.sendBackward`, `graphics.sendToBack` | Arrange ▸ … (⇧⌘], ⌘], ⌘[, ⇧⌘[) | `clip`, `layer` |
|
||||
| `graphics.selectNextGraphic`, `graphics.selectPreviousGraphic` | Select ▸ Select Next / Previous Graphic | selects the next / previous graphic clip in timeline order (moves the playhead onto it) |
|
||||
| `graphics.selectNextLayer`, `graphics.selectPreviousLayer` | Select ▸ Select Next / Previous Layer (⌥⌘], ⌥⌘[) | cycles the selected layer |
|
||||
| `graphics.resetAllParameters` | Reset All Parameters | `layers` (default: the selected layers, else all) — appearance, text formatting and transform back to defaults; text, shape and geometry kept |
|
||||
| `graphics.resetDuration` | Reset Duration | `seconds` (5), limited by the next clip on the track |
|
||||
| `graphics.list` | (query) | `clip` — layers with names, kinds, text, position and on-canvas quads |
|
||||
| `fonts.list` | (query) | `system` (scan system font folders, default true) — families and styles |
|
||||
|
||||
@@ -70,6 +82,10 @@ duration (a track is added if needed).
|
||||
| Rectangle / Ellipse | Drag to draw a shape layer. |
|
||||
| Pen (P) | Click to place points; click the first point (or Return) to close the path; Esc cancels. |
|
||||
|
||||
Moving a layer snaps its edges or centre to the frame edges, the frame centre and the guides
|
||||
(View ▸ Snap in Program Monitor, on by default; hold ⌘/Ctrl to move freely). See
|
||||
[monitors.md](monitors.md) for rulers and guides.
|
||||
|
||||
Automation ids: `program.layer.<clip>.<layer>`, `program.layer.<clip>.<layer>.handle.<n>`,
|
||||
`program.textEdit` (while editing).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user