M10.2: graphic clips — text and shape layers in the project model, vector renderer (CPU + GPU plan), graphics.* commands

- project: ItemKind::Graphic (transparent canvas source, hidden from the Project panel,
  unlimited duration); graphic layers are hidden effect instances graphic_text /
  graphic_shape on the clip (serde for free, keyframes on every animatable property,
  Source Text holds), with appearance (fill, two strokes outer/centre/inner, background
  box with radius, drop shadow) and transform (position, anchor, scale, rotation,
  opacity); eval_layer gives a plain LayerSpec. No schema migration: all additive.
- render: graphic_clip rasterises each layer straight to the output transform (crisp at
  any Motion scale/rotation), caches layer rasters, composes a tight image for the GPU
  plan; standard effects on a graphic run at source resolution first.
- engine: graphics.newText (Cmd+T) / newShape (new clip above the footage at the playhead
  or a layer in an existing clip), setText (typing coalesces into one undo step), set,
  selectLayer, deleteLayer, arrangeLayer, align (frame / selection), distribute,
  graphics.list, fonts.list. EditorState.graphic_layers holds the layer selection.
- goldens: graphic_title, graphic_shapes (CPU and GPU parity).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 01:38:04 -04:00
1 parent b141c406e9
commit 4918555906
21 files changed
+1767 -11

No files matched your search

+2
View File
@@ -37,6 +37,8 @@ procedural demo footage (`crates/media/src/generators.rs`); no external media. R
| `crates/golden/goldens/blend_modes.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `blend_modes`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/crop.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `crop`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/gaussian_blur.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `gaussian_blur`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/graphic_shapes.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `graphic_shapes`) from procedurally generated media and bundled OFL fonts | MIT OR Apache-2.0 |
| `crates/golden/goldens/graphic_title.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `graphic_title`) from procedurally generated media and bundled OFL fonts | MIT OR Apache-2.0 |
| `crates/golden/goldens/lumetri_basic.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `lumetri_basic`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/text_burnin.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `text_burnin`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/transform_opacity.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transform_opacity`) from procedurally generated media | MIT OR Apache-2.0 |
Generated
+1
View File
@@ -1436,6 +1436,7 @@ dependencies = [
"filmcraft-media",
"filmcraft-project",
"filmcraft-render",
"filmcraft-text",
"filmcraft-time",
"log",
"serde",
+1
View File
@@ -19,6 +19,7 @@ filmcraft-codecs = { workspace = true }
filmcraft-project = { workspace = true }
filmcraft-edit = { workspace = true }
filmcraft-render = { workspace = true }
filmcraft-text = { workspace = true }
filmcraft-export = { workspace = true }
filmcraft-format = { workspace = true }
filmcraft-interchange = { workspace = true }
+5 -1
View File
@@ -1575,6 +1575,9 @@ fn build() -> Vec<CommandSpec> {
cmd!("effects.apply", "Apply Effect", [], None, r#"{"clips":[id]?,"effect":"gaussian_blur|Gaussian Blur|…"}"#, has_seq, |s, p| {
let name = str_p(p, "effect").ok_or_else(|| bad("effects.apply", "need `effect`"))?;
let def = filmcraft_project::effect::find_effect_by_name(name).ok_or_else(|| bad("effects.apply", format!("unknown effect `{name}`")))?;
if filmcraft_project::graphic::is_layer_id(def.id) {
return Err(bad("effects.apply", "graphic layers are added with graphics.newText / graphics.newShape"));
}
if matches!(def.kind, filmcraft_project::EffectKind::VideoTransition | filmcraft_project::EffectKind::AudioTransition) {
let mut q = p.clone();
q["effect"] = json!(def.id);
@@ -1814,6 +1817,7 @@ fn build() -> Vec<CommandSpec> {
)),
];
v.extend(crate::captions::commands());
v.extend(crate::graphics::commands());
// Labels as individual commands (Edit ▸ Label ▸ <name>)
for l in Label::ALL {
let _ = l;
@@ -1982,7 +1986,7 @@ fn mark(s: &mut Session, p: &Value, is_in: bool) -> Result<Value> {
Ok(Value::Null)
}
fn json_to_param(template: &ParamValue, v: &Value) -> Option<ParamValue> {
pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option<ParamValue> {
Some(match template {
ParamValue::Float(_) => ParamValue::Float(v.as_f64()?),
ParamValue::Vec2(_) => {
+551
View File
@@ -0,0 +1,551 @@
//! 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::{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`.
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))
}
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.
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(|(_, i)| 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 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| {
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 t = rate.snap(t);
let dur = rate.snap_nearest(Tick::from_seconds_f64(seconds)).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);
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"…).
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`).
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")))?;
prm.set_at(mt, pv);
}
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 layers: Vec<Value> = layer_indices(&it.effects)
.iter()
.enumerate()
.filter_map(|(i, &ei)| {
let e = &it.effects[ei];
let sp = eval_layer(e, mt, size)?;
let q = layer_quad(&sp);
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],
"localBounds": layer_local_bounds(&sp),
"quad": q.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>(),
}))
})
.collect();
Ok(json!({"clip": clip.0, "name": it.name, "start": it.start.0, "duration": it.duration.0, "canvas": [size.0, size.1], "layers": layers}))
}
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 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 = eval_layer(&it.effects[ei], mt, size).ok_or_else(|| bad("graphics.align", "bad layer"))?;
out.push((ei, sp.transform.position, 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])
}
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 {
if let Some(prm) = it.effects.get_mut(*ei).and_then(|e| e.params.get_mut("position")) {
prm.set_at(mt, ParamValue::Vec2(*pos));
}
}
Ok(())
})
}
pub fn commands() -> Vec<CommandSpec> {
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?}"#,
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));
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",
&["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,
|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 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)
},
),
spec(
"graphics.align",
"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)}"#,
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 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)
},
),
spec(
"graphics.distribute",
"Distribute Layers",
&[],
None,
r#"{"clip":id?,"layers":[n] (3 or more)?,"axis":"horizontal|vertical"}"#,
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)
},
),
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<_>>()))
}),
]
}
#[cfg(test)]
#[path = "graphics_tests.rs"]
mod tests;
+130
View File
@@ -0,0 +1,130 @@
use serde_json::json;
use crate::Session;
use filmcraft_project::graphic::{LayerContent, eval_layer, layer_indices};
use filmcraft_project::{ClipId, ItemKind};
use filmcraft_time::Tick;
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
fn layers(s: &Session, clip: ClipId) -> Vec<filmcraft_project::graphic::LayerSpec> {
let q = s.active_sequence().unwrap();
let (_, it) = q.find_item(clip).unwrap();
layer_indices(&it.effects).into_iter().filter_map(|i| eval_layer(&it.effects[i], Tick::ZERO, (q.settings.width, q.settings.height))).collect()
}
fn text_of(l: &filmcraft_project::graphic::LayerSpec) -> String {
match &l.content {
LayerContent::Text(t) => t.text.clone(),
_ => panic!("not text"),
}
}
#[test]
fn new_text_makes_a_graphic_clip_above_the_footage() {
let mut s = demo();
let t = s.playhead();
let r = s.execute("graphics.newText", json!({"text": "Hello", "position": [200, 300]})).unwrap();
let clip = ClipId(r["clip"].as_u64().unwrap());
let q = s.active_sequence().unwrap();
let (tid, it) = q.find_item(clip).unwrap();
let track_index = q.video_tracks.iter().position(|tr| tr.id == tid).unwrap();
let below_busy = q.video_tracks[..track_index].iter().any(|tr| tr.item_at(t).is_some());
assert!(below_busy || track_index == 0, "placed above the clip under the playhead");
assert_eq!(it.name, "Hello");
assert!(matches!(s.project.item(it.item).unwrap().kind, ItemKind::Graphic { .. }));
assert_eq!(it.duration, q.settings.frame_rate.snap_nearest(Tick::from_seconds_f64(5.0)));
assert!(!it.has_standard_effects(), "layers are not fx");
assert_eq!(s.state.selection, vec![clip]);
let ls = layers(&s, clip);
assert_eq!(ls.len(), 1);
assert_eq!(text_of(&ls[0]), "Hello");
assert_eq!((ls[0].transform.position.x, ls[0].transform.position.y), (200.0, 300.0));
// a second text layer in the same clip, then a shape
let r2 = s.execute("graphics.newText", json!({"text": "World", "clip": clip.0})).unwrap();
assert_eq!(r2["layer"], 1);
s.execute("graphics.newShape", json!({"shape": "ellipse", "clip": clip.0, "size": [100, 50]})).unwrap();
assert_eq!(layers(&s, clip).len(), 3);
let list = s.execute("graphics.list", json!({"clip": clip.0})).unwrap();
assert_eq!(list["layers"][2]["kind"], "Ellipse");
assert_eq!(list["layers"][1]["name"], "World");
// undo removes the shape layer again
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layers(&s, clip).len(), 2);
}
#[test]
fn typing_coalesces_into_one_undo_step_and_props_set() {
let mut s = demo();
let r = s.execute("graphics.newText", json!({"text": ""})).unwrap();
let clip = ClipId(r["clip"].as_u64().unwrap());
let before = s.history.undo.len();
for (i, t) in ["T", "Ti", "Tit", "Titl", "Title"].iter().enumerate() {
s.execute("graphics.setText", json!({"clip": clip.0, "layer": 0, "text": t, "merge": i > 0})).unwrap();
}
assert_eq!(s.history.undo.len(), before + 1, "one undo step for the typing session");
assert_eq!(text_of(&layers(&s, clip)[0]), "Title");
s.execute("graphics.set", json!({"clip": clip.0, "props": {"font_style": "Bold", "fontSize": 140, "align": "center", "fill_color": "#ffcc00", "stroke": true, "caps": "small caps"}})).unwrap();
let l = &layers(&s, clip)[0];
let LayerContent::Text(t) = &l.content else { panic!() };
assert_eq!((t.style.as_str(), t.size, t.align, t.caps), ("Bold", 140.0, 1, 2));
assert_eq!(l.appearance.strokes.len(), 1);
assert!((l.appearance.fill.unwrap()[1] - 0.8).abs() < 0.01);
assert!(s.execute("graphics.set", json!({"clip": clip.0, "props": {"nope": 1}})).is_err());
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(text_of(&layers(&s, clip)[0]), "");
}
#[test]
fn align_and_distribute_layers() {
let mut s = demo();
let (w, h) = {
let q = s.active_sequence().unwrap();
(q.settings.width as f64, q.settings.height as f64)
};
let r = s.execute("graphics.newShape", json!({"shape": "rectangle", "position": [300, 200], "size": [100, 60]})).unwrap();
let clip = r["clip"].as_u64().unwrap();
s.execute("graphics.newShape", json!({"shape": "rectangle", "clip": clip, "position": [500, 400], "size": [40, 40]})).unwrap();
s.execute("graphics.newShape", json!({"shape": "rectangle", "clip": clip, "position": [1500, 900], "size": [40, 40]})).unwrap();
s.execute("graphics.align", json!({"clip": clip, "layers": [0], "align": "left"})).unwrap();
s.execute("graphics.align", json!({"clip": clip, "layers": [0], "align": "bottom"})).unwrap();
let l = layers(&s, ClipId(clip));
assert!((l[0].transform.position.x - 50.0).abs() < 1e-6, "left edge on the frame edge");
assert!((l[0].transform.position.y - (h - 30.0)).abs() < 1e-6);
s.execute("graphics.align", json!({"clip": clip, "layers": [1, 2], "align": "vcenter", "to": "selection"})).unwrap();
let l = layers(&s, ClipId(clip));
assert!((l[1].transform.position.y - l[2].transform.position.y).abs() < 1e-6);
s.execute("graphics.distribute", json!({"clip": clip, "layers": [0, 1, 2], "axis": "horizontal"})).unwrap();
let l = layers(&s, ClipId(clip));
let xs: Vec<f64> = l.iter().map(|x| x.transform.position.x).collect();
assert!(((xs[1] - xs[0]) - (xs[2] - xs[1])).abs() < 1e-6, "{xs:?}");
let _ = w;
}
#[test]
fn graphic_renders_in_the_program_and_survives_save() {
let mut s = demo();
let r = s.execute("graphics.newText", json!({"text": "BIG", "size": 300, "position": [100, 500]})).unwrap();
let clip = r["clip"].as_u64().unwrap();
s.execute("graphics.set", json!({"clip": clip, "props": {"fill_color": "#ff0000"}})).unwrap();
let img = s.render_program(0.25).unwrap();
let red = img.px.chunks(4).filter(|p| p[0] > 0.9 && p[1] < 0.05 && p[2] < 0.05).count();
assert!(red > 200, "red text in the program: {red}");
let dir = std::env::temp_dir().join(format!("fc-gfx-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("g.fcproj");
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(s2.project.items, s.project.items, "graphic clips round-trip");
let _ = std::fs::remove_dir_all(&dir);
let fonts = s.execute("fonts.list", json!({"system": false})).unwrap();
assert!(fonts.as_array().unwrap().iter().any(|f| f["family"] == "Inter"));
}
+5 -1
View File
@@ -12,6 +12,7 @@ pub mod autosave;
pub mod captions;
pub mod commands;
pub mod demo;
pub mod graphics;
pub mod interchange;
pub mod media_pool;
pub mod previews;
@@ -127,6 +128,9 @@ pub struct EditorState {
/// Selected captions (caption tracks / Captions panel).
#[serde(default)]
pub caption_selection: Vec<ClipId>,
/// Selected layers (indices among the graphic layers, 0 = back) of the selected graphic clip.
#[serde(default)]
pub graphic_layers: Vec<usize>,
}
/// Events for frontends (drained each frame).
@@ -479,7 +483,7 @@ pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option<Tick
},
filmcraft_project::ItemKind::Sequence(s) => Some(s.duration()),
filmcraft_project::ItemKind::Subclip { range, .. } => Some(range.end()),
filmcraft_project::ItemKind::AdjustmentLayer { .. } => None,
filmcraft_project::ItemKind::AdjustmentLayer { .. } | filmcraft_project::ItemKind::Graphic { .. } => None,
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,8 @@
asset: graphic_shapes.png
title: Graphic clip: ellipse, polygon, path and rotated text with background
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (graphic_shapes). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,8 @@
asset: graphic_title.png
title: Graphic clip: title text with stroke and shadow, lower-third bar
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (graphic_title). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
+109 -1
View File
@@ -233,6 +233,100 @@ fn text_burnin() -> Scene {
b.at(30)
}
/// A graphic clip on V2 over `bg` on V1, with `layers` (graphic layer effect instances).
fn graphic_scene(bg: DemoScene, layers: Vec<EffectInstance>) -> Scene {
let mut b = Builder::new(2);
let bgi = b.demo(bg);
b.place(0, bgi, 0, 48);
let g = b.p.add_item("Graphic", Label::Rose, ItemKind::Graphic { width: W, height: H, rate: RATE }, None);
let c = b.place(1, g, 0, 48);
for mut l in layers {
filmcraft_project::resolve_auto_points(&mut l, (W, H), (W, H));
b.clip(c).effects.push(l);
}
b.at(12)
}
/// A title: bold centred text with an outer stroke and a soft drop shadow, a lower-third bar with
/// rounded corners and a background-boxed caption line.
fn graphic_title() -> Scene {
use filmcraft_project::graphic::{new_shape_layer, new_text_layer};
let mut bar = new_shape_layer(0, Vec2::new(160.0, 150.0), Vec2::new(280.0, 34.0), vec![]);
set(&mut bar, &[("corner_radius", fl(8.0)), ("fill_color", ParamValue::Color([0.12, 0.35, 0.8, 1.0])), ("opacity", fl(85.0))]);
let mut title = new_text_layer("Night Drive", Vec2::new(160.0, 80.0), 44.0);
set(
&mut title,
&[
("font_style", ParamValue::Text("Bold".into())),
("align", ParamValue::Choice(1)),
("stroke", ParamValue::Bool(true)),
("stroke_width", fl(2.5)),
("stroke_color", ParamValue::Color([0.05, 0.05, 0.1, 1.0])),
("shadow", ParamValue::Bool(true)),
("shadow_distance", fl(5.0)),
("shadow_blur", fl(8.0)),
("tracking", fl(40.0)),
],
);
let mut sub = new_text_layer("Directed by Nobody", Vec2::new(160.0, 156.0), 16.0);
set(
&mut sub,
&[
("align", ParamValue::Choice(1)),
("font", ParamValue::Text("Noto Serif".into())),
("faux_italic", ParamValue::Bool(true)),
("caps", ParamValue::Choice(2)),
("fill_color", ParamValue::Color([1.0, 0.92, 0.6, 1.0])),
],
);
graphic_scene(DemoScene::CityNight, vec![bar, title, sub])
}
/// Shapes: ellipse with centre stroke, rotated polygon with inner stroke, a pen path, rotated
/// boxed text with a background and 2 strokes.
fn graphic_shapes() -> Scene {
use filmcraft_project::graphic::{new_shape_layer, new_text_layer};
let mut ell = new_shape_layer(1, Vec2::new(70.0, 60.0), Vec2::new(100.0, 70.0), vec![]);
set(
&mut ell,
&[
("stroke", ParamValue::Bool(true)),
("stroke_width", fl(6.0)),
("stroke_type", ParamValue::Choice(1)),
("stroke_color", ParamValue::Color([1.0, 1.0, 1.0, 1.0])),
],
);
let mut poly = new_shape_layer(2, Vec2::new(250.0, 60.0), Vec2::new(80.0, 80.0), vec![]);
set(
&mut poly,
&[
("sides", fl(5.0)),
("rotation", fl(18.0)),
("fill_color", ParamValue::Color([0.2, 0.8, 0.4, 1.0])),
("stroke", ParamValue::Bool(true)),
("stroke_type", ParamValue::Choice(2)),
("stroke_width", fl(5.0)),
],
);
let path = new_shape_layer(3, Vec2::new(70.0, 140.0), Vec2::new(0.0, 0.0), vec![[-40.0, 20.0], [0.0, -25.0], [40.0, 20.0], [0.0, 5.0]]);
let mut txt = new_text_layer("Rotated\nTwo lines", Vec2::new(215.0, 140.0), 20.0);
set(
&mut txt,
&[
("rotation", fl(-12.0)),
("align", ParamValue::Choice(1)),
("background", ParamValue::Bool(true)),
("background_radius", fl(6.0)),
("background_size", fl(6.0)),
("stroke", ParamValue::Bool(true)),
("stroke_width", fl(1.5)),
("stroke2", ParamValue::Bool(true)),
("stroke2_width", fl(3.5)),
],
);
graphic_scene(DemoScene::Aurora, vec![ell, poly, path, txt])
}
/// (name, title, scene).
fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> {
vec![
@@ -245,6 +339,8 @@ fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> {
("transition_dip_to_black", "Dip to Black at 25%", || transition("dip_to_black", 21)),
("transition_wipe", "Wipe at 50%", || transition("wipe", 24)),
("text_burnin", "Timecode and Clip Name burn-in text", text_burnin),
("graphic_title", "Graphic clip: title text with stroke and shadow, lower-third bar", graphic_title),
("graphic_shapes", "Graphic clip: ellipse, polygon, path and rotated text with background", graphic_shapes),
]
}
@@ -286,7 +382,19 @@ macro_rules! goldens {
};
}
goldens!(transform_opacity, blend_modes, gaussian_blur, lumetri_basic, crop, transition_cross_dissolve, transition_dip_to_black, transition_wipe, text_burnin);
goldens!(
transform_opacity,
blend_modes,
gaussian_blur,
lumetri_basic,
crop,
transition_cross_dissolve,
transition_dip_to_black,
transition_wipe,
text_burnin,
graphic_title,
graphic_shapes
);
/// Sanity: the scenes are not trivially empty or identical to each other.
#[test]
+1 -1
View File
@@ -427,7 +427,7 @@ pub fn merge_into(target: &mut Project, fragment: Imported, bin: Option<BinId>)
mk.id = MarkerId(target.alloc_id());
}
}
ItemKind::AdjustmentLayer { .. } => {}
ItemKind::AdjustmentLayer { .. } | ItemKind::Graphic { .. } => {}
}
new_items.insert(it.id, it);
}
+1
View File
@@ -935,6 +935,7 @@ fn build_effects() -> Vec<EffectDef> {
yuv: false,
},
];
v.extend(crate::graphic::layer_defs());
// YUV badge for the colour/intrinsic set
for e in &mut v {
if matches!(e.id, "brightness_contrast" | "proc_amp" | "crop" | "gaussian_blur" | "tint" | "lumetri") {
+419
View File
@@ -0,0 +1,419 @@
//! Graphic clips: text and shape layers.
//!
//! A graphic clip is a track item whose project item is an [`ItemKind::Graphic`](crate::ItemKind)
//! source (a transparent canvas the size of the sequence frame). Its **layers** are effect
//! instances of the hidden effects [`TEXT_LAYER`] and [`SHAPE_LAYER`] in the clip's effect list,
//! in paint order (first = back). Being effect instances, layers serialize with the project, get
//! keyframes for every animatable property (Source Text holds), show in Effect Controls, and copy
//! with the clip — like Premiere's Text / Shape components. Standard effects on the clip apply to
//! the composed graphic; Motion and Opacity apply last.
//!
//! [`eval_layer`] evaluates a layer at a clip time into a plain [`LayerSpec`] that renderers and
//! editors use.
use filmcraft_geom::Vec2;
use filmcraft_time::Tick;
use crate::effect::{EffectDef, EffectInstance, EffectKind, ParamDef, ParamKind};
use crate::keyframe::{Param, ParamValue};
pub const TEXT_LAYER: &str = "graphic_text";
pub const SHAPE_LAYER: &str = "graphic_shape";
pub const ALIGN_OPTS: &[&str] = &["Left", "Center", "Right", "Justify"];
pub const CAPS_OPTS: &[&str] = &["Normal", "All Caps", "Small Caps"];
pub const STROKE_OPTS: &[&str] = &["Outer", "Center", "Inner"];
pub const SHAPE_OPTS: &[&str] = &["Rectangle", "Ellipse", "Polygon", "Path"];
/// Whether an effect instance is a graphic layer.
pub fn is_layer_id(id: &str) -> bool {
id == TEXT_LAYER || id == SHAPE_LAYER
}
pub fn is_layer(e: &EffectInstance) -> bool {
is_layer_id(&e.effect)
}
fn p(id: &'static str, label: &'static str, kind: ParamKind, default: ParamValue, animatable: bool, group: &'static str) -> ParamDef {
ParamDef { id, label, kind, default, animatable, group: if group.is_empty() { None } else { Some(group) } }
}
fn fl(id: &'static str, label: &'static str, def: f64, (min, max): (f64, f64), (smin, smax): (f64, f64), unit: &'static str, group: &'static str) -> ParamDef {
p(id, label, ParamKind::Float { min, max, soft_min: smin, soft_max: smax, unit, decimals: 1 }, ParamValue::Float(def), true, group)
}
fn bo(id: &'static str, label: &'static str, v: bool, group: &'static str) -> ParamDef {
p(id, label, ParamKind::Bool, ParamValue::Bool(v), false, group)
}
fn co(id: &'static str, label: &'static str, c: [f32; 4], group: &'static str) -> ParamDef {
p(id, label, ParamKind::Color, ParamValue::Color(c), true, group)
}
fn chc(id: &'static str, label: &'static str, opts: &'static [&'static str], def: u32, group: &'static str) -> ParamDef {
p(id, label, ParamKind::Choice(opts), ParamValue::Choice(def), false, group)
}
fn tx(id: &'static str, label: &'static str, def: &str, animatable: bool, group: &'static str) -> ParamDef {
p(id, label, ParamKind::Text, ParamValue::Text(def.into()), animatable, group)
}
fn appearance() -> Vec<ParamDef> {
const A: &str = "Appearance";
vec![
bo("fill", "Fill", true, A),
co("fill_color", "Fill Color", [1.0, 1.0, 1.0, 1.0], A),
bo("stroke", "Stroke", false, A),
co("stroke_color", "Stroke Color", [0.0, 0.0, 0.0, 1.0], A),
fl("stroke_width", "Stroke Width", 4.0, (0.0, 1000.0), (0.0, 100.0), "", A),
chc("stroke_type", "Stroke Type", STROKE_OPTS, 0, A),
bo("stroke2", "Stroke 2", false, A),
co("stroke2_color", "Stroke 2 Color", [1.0, 0.85, 0.2, 1.0], A),
fl("stroke2_width", "Stroke 2 Width", 10.0, (0.0, 1000.0), (0.0, 100.0), "", A),
chc("stroke2_type", "Stroke 2 Type", STROKE_OPTS, 0, A),
bo("background", "Background", false, A),
co("background_color", "Background Color", [0.0, 0.0, 0.0, 1.0], A),
fl("background_opacity", "Background Opacity", 75.0, (0.0, 100.0), (0.0, 100.0), "%", A),
fl("background_size", "Background Size", 12.0, (0.0, 1000.0), (0.0, 100.0), "", A),
fl("background_radius", "Background Corner Radius", 0.0, (0.0, 1000.0), (0.0, 100.0), "", A),
bo("shadow", "Shadow", false, A),
co("shadow_color", "Shadow Color", [0.0, 0.0, 0.0, 1.0], A),
fl("shadow_opacity", "Shadow Opacity", 75.0, (0.0, 100.0), (0.0, 100.0), "%", A),
p("shadow_angle", "Shadow Angle", ParamKind::Angle, ParamValue::Float(135.0), true, A),
fl("shadow_distance", "Shadow Distance", 10.0, (0.0, 1000.0), (0.0, 100.0), "", A),
fl("shadow_size", "Shadow Size", 0.0, (0.0, 1000.0), (0.0, 100.0), "", A),
fl("shadow_blur", "Shadow Blur", 40.0, (0.0, 1000.0), (0.0, 200.0), "", A),
]
}
fn transform() -> Vec<ParamDef> {
const T: &str = "Transform";
vec![
p("position", "Position", ParamKind::Point, ParamValue::Vec2(Vec2::new(f64::NAN, f64::NAN)), true, T),
p("anchor", "Anchor Point", ParamKind::Point, ParamValue::Vec2(Vec2::new(0.0, 0.0)), true, T),
fl("scale", "Scale", 100.0, (0.0, 100_000.0), (0.0, 400.0), "%", T),
fl("scale_width", "Scale Width", 100.0, (0.0, 100_000.0), (0.0, 400.0), "%", T),
bo("uniform_scale", "Uniform Scale", true, T),
p("rotation", "Rotation", ParamKind::Angle, ParamValue::Float(0.0), true, T),
fl("opacity", "Opacity", 100.0, (0.0, 100.0), (0.0, 100.0), "%", T),
]
}
fn layer_def(id: &'static str, name: &'static str, mut params: Vec<ParamDef>) -> EffectDef {
params.extend(appearance());
params.extend(transform());
EffectDef { id, name, kind: EffectKind::Video, category: &[], params, intrinsic: false, accelerated: true, float32: true, yuv: false }
}
/// Definitions of the two layer kinds (appended to the effect registry; not in the Effects panel).
pub(crate) fn layer_defs() -> Vec<EffectDef> {
const X: &str = "Text";
const S: &str = "Shape";
vec![
layer_def(
TEXT_LAYER,
"Text",
vec![
tx("name", "Layer Name", "", false, ""),
tx("text", "Source Text", "", true, ""),
tx("font", "Font", "Inter", false, X),
tx("font_style", "Font Style", "Regular", false, X),
fl("size", "Font Size", 100.0, (1.0, 2000.0), (6.0, 400.0), "", X),
chc("align", "Alignment", ALIGN_OPTS, 0, X),
fl("tracking", "Tracking", 0.0, (-1000.0, 10_000.0), (-200.0, 1000.0), "", X),
bo("kerning", "Metrics Kerning", true, X),
bo("ligatures", "Ligatures", true, X),
fl("leading", "Leading", 0.0, (-5000.0, 5000.0), (-100.0, 400.0), "", X),
fl("baseline_shift", "Baseline Shift", 0.0, (-5000.0, 5000.0), (-100.0, 100.0), "", X),
bo("faux_bold", "Faux Bold", false, X),
bo("faux_italic", "Faux Italic", false, X),
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),
],
),
layer_def(
SHAPE_LAYER,
"Shape",
vec![
tx("name", "Layer Name", "", false, ""),
chc("shape", "Shape", SHAPE_OPTS, 0, S),
p("size", "Size", ParamKind::Point, ParamValue::Vec2(Vec2::new(400.0, 200.0)), true, S),
fl("sides", "Polygon Sides", 6.0, (3.0, 64.0), (3.0, 16.0), "", S),
fl("corner_radius", "Corner Radius", 0.0, (0.0, 10_000.0), (0.0, 200.0), "", S),
p("points", "Path Points", ParamKind::Curve { hue: false }, ParamValue::Curve(vec![]), false, S),
],
),
]
}
/// Fill / strokes / background / shadow of a layer (colours are sRGB-encoded straight RGBA).
#[derive(Clone, Debug, PartialEq)]
pub struct Appearance {
pub fill: Option<[f32; 4]>,
/// (colour, width px, type: 0 outer, 1 centre, 2 inner), outermost last.
pub strokes: Vec<([f32; 4], f32, u32)>,
/// (colour with opacity in alpha, padding px, corner radius px).
pub background: Option<([f32; 4], f32, f32)>,
pub shadow: Option<Shadow>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Shadow {
/// Colour with opacity in alpha.
pub color: [f32; 4],
/// Offset in layer pixels (from angle and distance; 135° casts down-right).
pub offset: (f32, f32),
pub size: f32,
pub blur: f32,
}
/// Layer → graphic-source transform parameters.
#[derive(Clone, Debug, PartialEq)]
pub struct LayerTransform {
pub position: Vec2,
pub anchor: Vec2,
pub scale: Vec2,
pub rotation: f64,
pub opacity: f32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TextProps {
pub text: String,
pub font: String,
pub style: String,
pub size: f32,
/// 0 left, 1 centre, 2 right, 3 justify.
pub align: u32,
pub tracking: f32,
pub kerning: bool,
pub ligatures: bool,
pub leading: f32,
pub baseline_shift: f32,
pub faux_bold: bool,
pub faux_italic: bool,
/// 0 normal, 1 all caps, 2 small caps.
pub caps: u32,
pub underline: bool,
/// Area-text width (0 = point text).
pub box_width: f32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ShapeProps {
/// 0 rectangle, 1 ellipse, 2 polygon, 3 path.
pub shape: u32,
pub size: (f32, f32),
pub sides: u32,
pub corner_radius: f32,
/// Path vertices in layer pixels (shape 3), closed.
pub points: Vec<[f32; 2]>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum LayerContent {
Text(TextProps),
Shape(ShapeProps),
}
/// A layer evaluated at one time.
#[derive(Clone, Debug, PartialEq)]
pub struct LayerSpec {
pub name: String,
pub enabled: bool,
pub content: LayerContent,
pub appearance: Appearance,
pub transform: LayerTransform,
}
fn val(e: &EffectInstance, id: &str, t: Tick) -> ParamValue {
match e.params.get(id) {
Some(p) => p.value_at(t),
None => crate::effect::find_effect(&e.effect).and_then(|d| d.param(id)).map(|d| d.default.clone()).unwrap_or(ParamValue::Float(0.0)),
}
}
fn ff(e: &EffectInstance, id: &str, t: Tick) -> f32 {
val(e, id, t).as_f64().unwrap_or(0.0) as f32
}
fn bb(e: &EffectInstance, id: &str, t: Tick) -> bool {
val(e, id, t).as_bool().unwrap_or(false)
}
fn cc(e: &EffectInstance, id: &str, t: Tick) -> [f32; 4] {
val(e, id, t).as_color().unwrap_or([1.0, 1.0, 1.0, 1.0])
}
fn ch(e: &EffectInstance, id: &str, t: Tick) -> u32 {
match val(e, id, t) {
ParamValue::Choice(c) => c,
v => v.as_f64().unwrap_or(0.0) as u32,
}
}
fn ss(e: &EffectInstance, id: &str, t: Tick) -> String {
match val(e, id, t) {
ParamValue::Text(s) => s,
_ => String::new(),
}
}
fn vv(e: &EffectInstance, id: &str, t: Tick) -> Vec2 {
val(e, id, t).as_vec2().unwrap_or_default()
}
/// Evaluate a layer at clip (media) time `t`. `frame` is the graphic's canvas size; a NaN position
/// means the canvas centre.
pub fn eval_layer(e: &EffectInstance, t: Tick, frame: (u32, u32)) -> Option<LayerSpec> {
let content = match e.effect.as_str() {
TEXT_LAYER => LayerContent::Text(TextProps {
text: ss(e, "text", t),
font: ss(e, "font", t),
style: ss(e, "font_style", t),
size: ff(e, "size", t).max(0.1),
align: ch(e, "align", t),
tracking: ff(e, "tracking", t),
kerning: bb(e, "kerning", t),
ligatures: bb(e, "ligatures", t),
leading: ff(e, "leading", t),
baseline_shift: ff(e, "baseline_shift", t),
faux_bold: bb(e, "faux_bold", t),
faux_italic: bb(e, "faux_italic", t),
caps: ch(e, "caps", t),
underline: bb(e, "underline", t),
box_width: ff(e, "box_width", t).max(0.0),
}),
SHAPE_LAYER => {
let sz = vv(e, "size", t);
LayerContent::Shape(ShapeProps {
shape: ch(e, "shape", t),
size: (sz.x.max(0.0) as f32, sz.y.max(0.0) as f32),
sides: ff(e, "sides", t).round().clamp(3.0, 64.0) as u32,
corner_radius: ff(e, "corner_radius", t).max(0.0),
points: val(e, "points", t).as_curve().map(<[_]>::to_vec).unwrap_or_default(),
})
}
_ => return None,
};
let mut strokes = Vec::new();
for (on, c, w, k) in [("stroke", "stroke_color", "stroke_width", "stroke_type"), ("stroke2", "stroke2_color", "stroke2_width", "stroke2_type")] {
if bb(e, on, t) && ff(e, w, t) > 0.0 {
strokes.push((cc(e, c, t), ff(e, w, t), ch(e, k, t)));
}
}
let background = bb(e, "background", t).then(|| {
let mut c = cc(e, "background_color", t);
c[3] *= (ff(e, "background_opacity", t) / 100.0).clamp(0.0, 1.0);
(c, ff(e, "background_size", t).max(0.0), ff(e, "background_radius", t).max(0.0))
});
let shadow = bb(e, "shadow", t).then(|| {
let mut c = cc(e, "shadow_color", t);
c[3] *= (ff(e, "shadow_opacity", t) / 100.0).clamp(0.0, 1.0);
// Premiere: 135° casts the shadow down and to the right.
let a = (ff(e, "shadow_angle", t) - 90.0).to_radians();
let d = ff(e, "shadow_distance", t);
Shadow { color: c, offset: (a.cos() * d, a.sin() * d), size: ff(e, "shadow_size", t).max(0.0), blur: ff(e, "shadow_blur", t).max(0.0) }
});
let pos = vv(e, "position", t);
let position = if pos.x.is_nan() || pos.y.is_nan() { Vec2::new(frame.0 as f64 / 2.0, frame.1 as f64 / 2.0) } else { pos };
let s = ff(e, "scale", t) as f64 / 100.0;
let sw = if bb(e, "uniform_scale", t) { s } else { ff(e, "scale_width", t) as f64 / 100.0 };
Some(LayerSpec {
name: ss(e, "name", t),
enabled: e.enabled,
content,
appearance: Appearance { fill: bb(e, "fill", t).then(|| cc(e, "fill_color", t)), strokes, background, shadow },
transform: LayerTransform {
position,
anchor: vv(e, "anchor", t),
scale: Vec2::new(sw, s),
rotation: ff(e, "rotation", t) as f64,
opacity: (ff(e, "opacity", t) / 100.0).clamp(0.0, 1.0),
},
})
}
fn set(e: &mut EffectInstance, id: &str, v: ParamValue) {
e.params.insert(id.to_string(), Param::new(v));
}
/// A new text layer at `position` (graphic canvas pixels).
pub fn new_text_layer(text: &str, position: Vec2, size: f64) -> EffectInstance {
let mut e = crate::effect::find_effect(TEXT_LAYER).expect("registered").instance();
set(&mut e, "text", ParamValue::Text(text.into()));
set(&mut e, "position", ParamValue::Vec2(position));
set(&mut e, "size", ParamValue::Float(size));
e
}
/// 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();
set(&mut e, "shape", ParamValue::Choice(shape.min(3)));
set(&mut e, "position", ParamValue::Vec2(position));
set(&mut e, "size", ParamValue::Vec2(size));
set(&mut e, "points", ParamValue::Curve(points));
// shapes default to a red fill (Premiere's new-shape default is a coloured fill)
set(&mut e, "fill_color", ParamValue::Color([0.85, 0.2, 0.25, 1.0]));
e
}
/// Display name of a layer: its name, else the text (first line), else "Shape 01"-style.
pub fn layer_display_name(e: &EffectInstance, index: usize) -> String {
let name = ss(e, "name", Tick::ZERO);
if !name.is_empty() {
return name;
}
if e.effect == TEXT_LAYER {
let t = ss(e, "text", Tick::ZERO);
let first = t.lines().next().unwrap_or("").trim().to_string();
if first.is_empty() { "Text".into() } else { first.chars().take(40).collect() }
} else {
format!("Shape {:02}", index + 1)
}
}
/// Indices (into `effects`) of the graphic layers, in paint order.
pub fn layer_indices(effects: &[EffectInstance]) -> Vec<usize> {
effects.iter().enumerate().filter(|(_, e)| is_layer(e)).map(|(i, _)| i).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layers_round_trip_and_evaluate() {
let mut e = new_text_layer("Hello", Vec2::new(100.0, 200.0), 72.0);
e.params.get_mut("fill_color").unwrap().value = ParamValue::Color([1.0, 0.0, 0.0, 1.0]);
let json = serde_json::to_string(&e).unwrap();
let back: EffectInstance = serde_json::from_str(&json).unwrap();
assert_eq!(back, e);
let s = eval_layer(&back, Tick::ZERO, (1920, 1080)).unwrap();
let LayerContent::Text(t) = &s.content else { panic!() };
assert_eq!(t.text, "Hello");
assert_eq!(t.size, 72.0);
assert_eq!(t.font, "Inter");
assert_eq!(s.appearance.fill, Some([1.0, 0.0, 0.0, 1.0]));
assert_eq!(s.transform.position, Vec2::new(100.0, 200.0));
assert!(s.appearance.strokes.is_empty() && s.appearance.shadow.is_none());
assert!(is_layer(&e));
assert_eq!(layer_display_name(&e, 0), "Hello");
}
#[test]
fn defaults_centre_and_shadow_direction() {
let mut e = crate::effect::find_effect(SHAPE_LAYER).unwrap().instance();
e.params.get_mut("shadow").unwrap().value = ParamValue::Bool(true);
let s = eval_layer(&e, Tick::ZERO, (1920, 1080)).unwrap();
assert_eq!(s.transform.position, Vec2::new(960.0, 540.0));
let sh = s.appearance.shadow.unwrap();
assert!(sh.offset.0 > 5.0 && sh.offset.1 > 5.0, "135° casts down-right: {:?}", sh.offset);
assert_eq!(layer_display_name(&e, 2), "Shape 03");
}
#[test]
fn source_text_keyframes_hold() {
let mut e = new_text_layer("A", Vec2::new(0.0, 0.0), 50.0);
let p = e.params.get_mut("text").unwrap();
p.toggle_animation(Tick(0));
p.set_at(Tick(100), ParamValue::Text("B".into()));
let at = |t| match eval_layer(&e, Tick(t), (10, 10)).unwrap().content {
LayerContent::Text(x) => x.text,
_ => unreachable!(),
};
assert_eq!(at(50), "A");
assert_eq!(at(150), "B");
}
}
+18 -3
View File
@@ -9,6 +9,7 @@
pub mod caption;
pub mod effect;
pub mod graphic;
pub mod keyframe;
use std::collections::BTreeMap;
@@ -191,6 +192,14 @@ pub enum ItemKind {
rate: FrameRate,
duration: Tick,
},
/// The source of graphic clips: a transparent canvas of the sequence frame size. The clip's
/// text and shape layers are effect instances on the track item (see [`graphic`]). Not shown
/// in the Project panel; unlimited duration.
Graphic {
width: u32,
height: u32,
rate: FrameRate,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -235,7 +244,7 @@ impl ProjectItem {
pub fn has_video(&self) -> bool {
match &self.kind {
ItemKind::Media(m) => m.info.has_video(),
ItemKind::Sequence(_) | ItemKind::AdjustmentLayer { .. } => true,
ItemKind::Sequence(_) | ItemKind::AdjustmentLayer { .. } | ItemKind::Graphic { .. } => true,
ItemKind::Subclip { .. } => true,
}
}
@@ -252,13 +261,14 @@ impl ProjectItem {
ItemKind::Sequence(s) => s.duration(),
ItemKind::Subclip { range, .. } => range.duration,
ItemKind::AdjustmentLayer { duration, .. } => *duration,
ItemKind::Graphic { .. } => Tick(3600 * TICKS_PER_SECOND),
}
}
pub fn frame_rate(&self) -> FrameRate {
match &self.kind {
ItemKind::Media(m) => m.frame_rate(),
ItemKind::Sequence(s) => s.settings.frame_rate,
ItemKind::AdjustmentLayer { rate, .. } => *rate,
ItemKind::AdjustmentLayer { rate, .. } | ItemKind::Graphic { rate, .. } => *rate,
ItemKind::Subclip { .. } => FrameRate::default(),
}
}
@@ -275,6 +285,7 @@ impl ProjectItem {
ItemKind::Sequence(_) => "Sequence",
ItemKind::Subclip { .. } => "Subclip",
ItemKind::AdjustmentLayer { .. } => "Adjustment Layer",
ItemKind::Graphic { .. } => "Graphic",
}
}
}
@@ -452,7 +463,11 @@ impl TrackItem {
}
/// Standard (non-intrinsic) effects applied, for the fx badge.
pub fn has_standard_effects(&self) -> bool {
self.effects.iter().any(|e| e.def().is_some_and(|d| !d.intrinsic))
self.effects.iter().any(|e| e.def().is_some_and(|d| !d.intrinsic) && !graphic::is_layer(e))
}
/// The graphic layers (text / shape) of a graphic clip, in paint order (first = back).
pub fn graphic_layers(&self) -> impl Iterator<Item = &EffectInstance> {
self.effects.iter().filter(|e| graphic::is_layer(e))
}
pub fn has_modified_intrinsics(&self) -> bool {
self.effects.iter().any(|e| {
+469
View File
@@ -0,0 +1,469 @@
//! Graphic clips: text and shape layers drawn as vectors straight into the output (no resampling),
//! with fill, up to two strokes (outer / centre / inner), a background box and a drop shadow.
//! Each layer is rasterised into a tight premultiplied linear-light image that is cached while
//! the layer and its transform stay the same.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use filmcraft_geom::{Affine, Vec2};
use filmcraft_project::EffectInstance;
use filmcraft_project::graphic::{LayerContent, LayerSpec, ShapeProps, TextProps, eval_layer};
use filmcraft_text::raster::{Xform, apply, fill_into, xform_scale};
use filmcraft_text::{Align, Caps, Layout, Mask, ParagraphStyle, StrokeKind, TextStyle, layout, mask, render};
use filmcraft_time::Tick;
use rayon::prelude::*;
use crate::graphics::premul_linear;
use crate::image::Image;
/// f64 affine → the text crate's f32 transform.
pub fn xform(a: &Affine) -> Xform {
[a.a as f32, a.b as f32, a.c as f32, a.d as f32, a.e as f32, a.f as f32]
}
/// Layer pixels → graphic canvas pixels (position / anchor / scale / rotation).
pub fn layer_matrix(spec: &LayerSpec) -> Affine {
let t = &spec.transform;
Affine::motion(t.position, t.scale, t.rotation, t.anchor)
}
/// Text style + paragraph style of a text layer.
pub fn text_styles(t: &TextProps) -> (TextStyle, ParagraphStyle) {
(
TextStyle {
family: if t.font.is_empty() { filmcraft_text::fonts::DEFAULT_FAMILY.into() } else { t.font.clone() },
style: if t.style.is_empty() { "Regular".into() } else { t.style.clone() },
size: t.size,
tracking: t.tracking,
kerning: t.kerning,
ligatures: t.ligatures,
baseline_shift: t.baseline_shift,
faux_bold: t.faux_bold,
faux_italic: t.faux_italic,
caps: match t.caps {
1 => Caps::All,
2 => Caps::Small,
_ => Caps::Normal,
},
underline: t.underline,
},
ParagraphStyle {
align: match t.align {
1 => Align::Center,
2 => Align::Right,
3 => Align::Justify,
_ => Align::Left,
},
leading: t.leading,
width: (t.box_width > 0.0).then_some(t.box_width),
rtl: None,
},
)
}
/// The laid-out text of a text layer (cached by the text engine).
pub fn text_layout(t: &TextProps) -> Arc<Layout> {
let (st, ps) = text_styles(t);
layout(&t.text, &st, &ps)
}
/// A shape layer's outline in layer pixels (centred on the layer origin).
pub fn shape_path(s: &ShapeProps) -> filmcraft_text::Path {
let (w, h) = (s.size.0 / 2.0, s.size.1 / 2.0);
match s.shape {
1 => filmcraft_text::Path::ellipse(0.0, 0.0, w, h),
2 => {
let n = s.sides.max(3);
let pts: Vec<(f32, f32)> = (0..n)
.map(|i| {
let a = std::f32::consts::TAU * i as f32 / n as f32 - std::f32::consts::FRAC_PI_2;
(a.cos() * w, a.sin() * h)
})
.collect();
filmcraft_text::Path::polygon(&pts)
}
3 if s.points.len() >= 3 => filmcraft_text::Path::polygon(&s.points.iter().map(|p| (p[0], p[1])).collect::<Vec<_>>()),
_ => filmcraft_text::Path::round_rect(-w, -h, w, h, s.corner_radius),
}
}
/// Content bounds `[x0, y0, x1, y1]` of a layer in layer pixels (text box or shape box).
pub fn layer_local_bounds(spec: &LayerSpec) -> [f32; 4] {
match &spec.content {
LayerContent::Text(t) => text_layout(t).bounds,
LayerContent::Shape(s) => match shape_path(s).bounds() {
Some(b) => [b.0, b.1, b.2, b.3],
None => [0.0; 4],
},
}
}
/// The four corners of a layer's content box in graphic canvas pixels (selection boxes, hit
/// testing, alignment), clockwise from the top-left.
pub fn layer_quad(spec: &LayerSpec) -> [Vec2; 4] {
let b = layer_local_bounds(spec);
let m = layer_matrix(spec);
[Vec2::new(b[0] as f64, b[1] as f64), Vec2::new(b[2] as f64, b[1] as f64), Vec2::new(b[2] as f64, b[3] as f64), Vec2::new(b[0] as f64, b[3] as f64)]
.map(|p| m.apply(p))
}
/// A rendered layer: premultiplied linear RGBA at device position (`x`, `y`).
#[derive(Debug)]
pub struct LayerRaster {
pub x: i32,
pub y: i32,
pub img: Image,
}
fn stroke_kind(k: u32) -> StrokeKind {
match k {
1 => StrokeKind::Center,
2 => StrokeKind::Inner,
_ => StrokeKind::Outer,
}
}
fn bbox_of(m: &Xform, b: [f32; 4], pad: f32) -> [f32; 4] {
let pts = [(b[0] - pad, b[1] - pad), (b[2] + pad, b[1] - pad), (b[0] - pad, b[3] + pad), (b[2] + pad, b[3] + pad)];
let mut o = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
for (x, y) in pts {
let (a, c) = apply(m, x, y);
o = [o[0].min(a), o[1].min(c), o[2].max(a), o[3].max(c)];
}
o
}
fn union(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
[a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]
}
/// Over-composite `c` (premultiplied) through `m` into `px` (premultiplied RGBA, same size).
fn over_mask(px: &mut [f32], m: &Mask, c: [f32; 4]) {
if c[3] <= 0.0 || m.w == 0 {
return;
}
px.par_chunks_mut(4 * m.w).zip(m.a.par_chunks(m.w)).for_each(|(row, mrow)| {
for (p, &a) in row.chunks_exact_mut(4).zip(mrow) {
if a > 0.0 {
let k = 1.0 - c[3] * a;
for i in 0..4 {
p[i] = c[i] * a + p[i] * k;
}
}
}
});
}
/// Rasterise one layer with layer→device transform `m`, clipped to a `cw`×`ch` canvas.
pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option<LayerRaster> {
if !spec.enabled || spec.transform.opacity <= 0.0 {
return None;
}
let s = xform_scale(m).max(1e-6);
let ap = &spec.appearance;
let text = match &spec.content {
LayerContent::Text(t) => {
let l = text_layout(t);
if l.glyphs.is_empty() && l.underlines.is_empty() && ap.background.is_none() {
return None;
}
Some(l)
}
LayerContent::Shape(_) => None,
};
let lb = match &text {
Some(l) => l.bounds,
None => layer_local_bounds(spec),
};
let shape = match &spec.content {
LayerContent::Shape(sh) => Some(shape_path(sh).transformed(m)),
_ => None,
};
let stroke_out = ap
.strokes
.iter()
.map(|(_, w, k)| {
if *k == 2 {
0.0
} else if *k == 1 {
w / 2.0
} else {
*w
}
})
.fold(0.0f32, f32::max);
let overhang = text.as_ref().map_or(0.0, |l| l.glyphs.iter().map(|g| g.size).fold(0.0f32, f32::max) * 0.35);
let mut bb = bbox_of(m, lb, overhang + stroke_out + 2.0 / s);
if let Some((_, pad, _)) = ap.background {
bb = union(bb, bbox_of(m, lb, pad + 1.0 / s));
}
let mut shadow_dev = (0.0f32, 0.0f32);
if let Some(sh) = &ap.shadow {
// the offset is in layer space, so it turns and scales with the layer
shadow_dev = (m[0] * sh.offset.0 + m[2] * sh.offset.1, m[1] * sh.offset.0 + m[3] * sh.offset.1);
let grow = (sh.size + sh.blur) * s + 2.0;
bb = union(bb, [bb[0] + shadow_dev.0 - grow, bb[1] + shadow_dev.1 - grow, bb[2] + shadow_dev.0 + grow, bb[3] + shadow_dev.1 + grow]);
}
// clip to the canvas, keeping enough margin for blur to read content just outside it
let margin = ap.shadow.as_ref().map_or(2.0, |sh| (sh.blur + sh.size) * s + shadow_dev.0.abs().max(shadow_dev.1.abs()) + 2.0);
let x0 = bb[0].floor().max(-margin) as i32;
let y0 = bb[1].floor().max(-margin) as i32;
let x1 = bb[2].ceil().min(cw as f32 + margin) as i32;
let y1 = bb[3].ceil().min(ch as f32 + margin) as i32;
if x1 <= x0 || y1 <= y0 || x1 <= 0 || y1 <= 0 || x0 >= cw as i32 || y0 >= ch as i32 {
return None;
}
let (w, h) = ((x1 - x0) as usize, (y1 - y0) as usize);
if w * h > 64 << 20 {
return None;
}
let mut cover = Mask::new(w, h);
if let Some(l) = &text {
render::draw(l, m, &mut cover, (x0, y0));
}
if let Some(p) = &shape {
fill_into(p, &mut cover, -x0 as f32, -y0 as f32);
}
let strokes: Vec<(Mask, [f32; 4])> = ap.strokes.iter().map(|(c, sw, k)| (mask::stroke(&cover, sw * s, stroke_kind(*k)), *c)).collect();
let bg = ap.background.map(|(c, pad, r)| {
let p = filmcraft_text::Path::round_rect(lb[0] - pad, lb[1] - pad, lb[2] + pad, lb[3] + pad, r).transformed(m);
let mut bm = Mask::new(w, h);
fill_into(&p, &mut bm, -x0 as f32, -y0 as f32);
(bm, c)
});
let mut px = vec![0.0f32; w * h * 4];
if let Some(sh) = &ap.shadow {
let mut u = if ap.fill.is_some() { cover.clone() } else { Mask::new(w, h) };
for (sm, _) in &strokes {
u.max_with(sm);
}
if let Some((bm, _)) = &bg {
u.max_with(bm);
}
if sh.size > 0.0 {
u = mask::stroke(&u, sh.size * s, StrokeKind::Outer);
}
let u = mask::offset(&u, shadow_dev.0.round() as i32, shadow_dev.1.round() as i32);
let u = mask::blur(&u, sh.blur * s / 3.0);
over_mask(&mut px, &u, premul_linear(sh.color));
}
if let Some((bm, c)) = &bg {
over_mask(&mut px, bm, premul_linear(*c));
}
// outer strokes sit under the fill; centre and inner strokes are drawn over it
let outer = |k: u32| k != 1 && k != 2;
for ((sm, c), (_, _, k)) in strokes.iter().zip(&ap.strokes).rev() {
if outer(*k) {
over_mask(&mut px, sm, premul_linear(*c));
}
}
if let Some(c) = ap.fill {
over_mask(&mut px, &cover, premul_linear(c));
}
for ((sm, c), (_, _, k)) in strokes.iter().zip(&ap.strokes).rev() {
if !outer(*k) {
over_mask(&mut px, sm, premul_linear(*c));
}
}
let op = spec.transform.opacity;
if op < 1.0 {
px.iter_mut().for_each(|v| *v *= op);
}
Some(LayerRaster { x: x0, y: y0, img: Image { w, h, px } })
}
type Cache = Mutex<HashMap<u64, (u64, Option<Arc<LayerRaster>>)>>;
fn raster_cache() -> &'static Cache {
static C: OnceLock<Cache> = OnceLock::new();
C.get_or_init(Default::default)
}
/// [`raster_layer`] through a small cache (static titles re-composite without re-rasterising).
pub fn raster_layer_cached(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option<Arc<LayerRaster>> {
let mut hs = std::collections::hash_map::DefaultHasher::new();
format!("{spec:?}").hash(&mut hs);
m.iter().for_each(|v| v.to_bits().hash(&mut hs));
(cw, ch).hash(&mut hs);
let key = hs.finish();
static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if let Some(e) = raster_cache().lock().unwrap_or_else(|e| e.into_inner()).get_mut(&key) {
e.0 = now;
return e.1.clone();
}
let r = raster_layer(spec, m, cw, ch).map(Arc::new);
let mut c = raster_cache().lock().unwrap_or_else(|e| e.into_inner());
if c.len() >= 96
&& let Some(k) = c.iter().min_by_key(|(_, v)| v.0).map(|(k, _)| *k)
{
c.remove(&k);
}
c.insert(key, (now, r.clone()));
r
}
/// Over-composite a raster at (`r.x` + `dx`, `r.y` + `dy`) onto `img`.
pub fn composite_raster(img: &mut Image, r: &LayerRaster, dx: i32, dy: i32) {
let (rx, ry) = (r.x + dx, r.y + dy);
let (w, h) = (img.w as i32, img.h as i32);
let ys = ry.max(0)..(ry + r.img.h as i32).min(h);
let xs = rx.max(0)..(rx + r.img.w as i32).min(w);
if ys.is_empty() || xs.is_empty() {
return;
}
let iw = img.w;
img.px[ys.start as usize * iw * 4..ys.end as usize * iw * 4].par_chunks_mut(iw * 4).enumerate().for_each(|(row_i, row)| {
let sy = (ys.start + row_i as i32 - ry) as usize;
for x in xs.clone() {
let si = (sy * r.img.w + (x - rx) as usize) * 4;
let s = &r.img.px[si..si + 4];
if s[3] <= 0.0 {
continue;
}
let d = &mut row[x as usize * 4..x as usize * 4 + 4];
let k = 1.0 - s[3];
for c in 0..4 {
d[c] = s[c] + d[c] * k;
}
}
});
}
/// Evaluated layers of a graphic clip's effect list at clip time `t`, in paint order.
pub fn graphic_specs(effects: &[EffectInstance], t: Tick, canvas: (u32, u32)) -> Vec<LayerSpec> {
effects.iter().filter(|e| filmcraft_project::graphic::is_layer(e)).filter_map(|e| eval_layer(e, t, canvas)).collect()
}
/// Draw all layers of a graphic clip onto `out`; `m` maps graphic canvas pixels to `out` pixels.
pub fn render_graphic(effects: &[EffectInstance], t: Tick, canvas: (u32, u32), m: &Affine, out: &mut Image) {
for spec in graphic_specs(effects, t, canvas) {
let lm = xform(&m.then_apply(&layer_matrix(&spec)));
if let Some(r) = raster_layer_cached(&spec, &lm, out.w, out.h) {
composite_raster(out, &r, 0, 0);
}
}
}
/// All layers composed into one tight image (for the GPU plan): `(image, x, y)` in output pixels.
pub fn render_graphic_tight(effects: &[EffectInstance], t: Tick, canvas: (u32, u32), m: &Affine, w: usize, h: usize) -> Option<(Image, i32, i32)> {
let rasters: Vec<Arc<LayerRaster>> =
graphic_specs(effects, t, canvas).iter().filter_map(|spec| raster_layer_cached(spec, &xform(&m.then_apply(&layer_matrix(spec))), w, h)).collect();
let x0 = rasters.iter().map(|r| r.x).min()?.max(0);
let y0 = rasters.iter().map(|r| r.y).min()?.max(0);
let x1 = rasters.iter().map(|r| r.x + r.img.w as i32).max()?.min(w as i32);
let y1 = rasters.iter().map(|r| r.y + r.img.h as i32).max()?.min(h as i32);
if x1 <= x0 || y1 <= y0 {
return None;
}
let mut img = Image::new((x1 - x0) as usize, (y1 - y0) as usize);
for r in &rasters {
composite_raster(&mut img, r, -x0, -y0);
}
Some((img, x0, y0))
}
#[cfg(test)]
mod tests {
use super::*;
use filmcraft_project::graphic::{new_shape_layer, new_text_layer};
use filmcraft_project::{ParamValue, find_effect};
fn set(e: &mut EffectInstance, k: &str, v: ParamValue) {
e.params.get_mut(k).unwrap_or_else(|| panic!("{k}")).value = v;
}
#[test]
fn text_layer_renders_white_text_at_position() {
let e = new_text_layer("HELLO", Vec2::new(100.0, 150.0), 60.0);
let mut img = Image::new(400, 200);
render_graphic(&[e], Tick::ZERO, (400, 200), &Affine::IDENTITY, &mut img);
let ink: Vec<(usize, usize)> = (0..200).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[3] > 0.5).collect();
assert!(ink.len() > 500);
let minx = ink.iter().map(|p| p.0).min().unwrap();
let maxy = ink.iter().map(|p| p.1).max().unwrap();
assert!((98..=106).contains(&minx), "starts at the position: {minx}");
assert!((145..=152).contains(&maxy), "sits on the baseline: {maxy}");
assert!(img.get(ink[0].0, ink[0].1)[0] > 0.4, "white");
}
#[test]
fn shape_appearance_layers() {
let mut e = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(80.0, 40.0), vec![]);
set(&mut e, "fill_color", ParamValue::Color([0.0, 0.0, 1.0, 1.0]));
set(&mut e, "stroke", ParamValue::Bool(true));
set(&mut e, "stroke_width", ParamValue::Float(6.0));
set(&mut e, "stroke_color", ParamValue::Color([1.0, 0.0, 0.0, 1.0]));
set(&mut e, "shadow", ParamValue::Bool(true));
set(&mut e, "shadow_distance", ParamValue::Float(20.0));
set(&mut e, "shadow_blur", ParamValue::Float(0.0));
let mut img = Image::new(200, 200);
render_graphic(&[e], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img);
let p = img.get(100, 100);
assert!(p[2] > 0.9 && p[0] < 0.05, "blue fill {p:?}");
let s = img.get(100, 78);
assert!(s[0] > 0.9 && s[2] < 0.05, "red outer stroke above the box {s:?}");
// shadow: below-right of the box, outside the stroke
let sh = img.get(150, 135);
assert!(sh[3] > 0.5 && sh[0] < 0.05 && sh[2] < 0.05, "black shadow {sh:?}");
assert_eq!(img.get(20, 20)[3], 0.0);
}
#[test]
fn inner_and_centre_strokes() {
for (kind, inside, outside) in [(2u32, true, false), (1, true, true)] {
let mut e = new_shape_layer(2, Vec2::new(100.0, 100.0), Vec2::new(120.0, 120.0), vec![]);
set(&mut e, "sides", ParamValue::Float(4.0));
set(&mut e, "stroke", ParamValue::Bool(true));
set(&mut e, "stroke_width", ParamValue::Float(6.0));
set(&mut e, "stroke_type", ParamValue::Choice(kind));
set(&mut e, "stroke_color", ParamValue::Color([0.0, 1.0, 0.0, 1.0]));
let mut img = Image::new(200, 200);
render_graphic(&[e], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img);
// diamond: top vertex at y = 40; probe straight below/above the right vertex (160, 100)
let green = |x: usize| {
let p = img.get(x, 100);
p[1] > 0.9 && p[0] < 0.1
};
assert_eq!(green(157), inside, "kind {kind} inside");
assert_eq!(green(161), outside, "kind {kind} outside");
assert!(!green(120), "fill in the middle");
}
}
#[test]
fn rotation_scale_and_opacity() {
let mut e = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(100.0, 10.0), vec![]);
set(&mut e, "rotation", ParamValue::Float(90.0));
set(&mut e, "opacity", ParamValue::Float(50.0));
let mut img = Image::new(200, 200);
render_graphic(&[e.clone()], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img);
assert!((img.get(100, 60)[3] - 0.5).abs() < 0.02, "vertical after 90°");
assert_eq!(img.get(60, 100)[3], 0.0);
// a 2× output transform (e.g. Motion scale) draws vectors crisply at the larger size
let mut big = Image::new(400, 400);
render_graphic(&[e], Tick::ZERO, (200, 200), &Affine::scale(2.0, 2.0), &mut big);
assert!((big.get(200, 110)[3] - 0.5).abs() < 0.02);
let q = layer_quad(&eval_layer(&find_effect("graphic_shape").unwrap().instance(), Tick::ZERO, (200, 200)).unwrap());
assert!((q[0].x - (100.0 - 200.0)).abs() < 1e-6 && (q[2].y - (100.0 + 100.0)).abs() < 1e-6);
}
#[test]
fn tight_image_matches_full_render() {
let a = new_text_layer("Tight", Vec2::new(50.0, 80.0), 40.0);
let b = new_shape_layer(1, Vec2::new(150.0, 120.0), Vec2::new(60.0, 60.0), vec![]);
let fx = vec![b, a];
let mut full = Image::new(220, 160);
render_graphic(&fx, Tick::ZERO, (220, 160), &Affine::IDENTITY, &mut full);
let (tight, x, y) = render_graphic_tight(&fx, Tick::ZERO, (220, 160), &Affine::IDENTITY, 220, 160).unwrap();
assert!(tight.w < 220 && tight.h < 160);
for yy in 0..tight.h {
for xx in 0..tight.w {
let (p, q) = (tight.get(xx, yy), full.get(xx + x as usize, yy + y as usize));
assert!((p[3] - q[3]).abs() < 1e-5);
}
}
}
}
+18 -2
View File
@@ -11,6 +11,7 @@ pub mod audio;
pub mod audio_fx;
pub mod blend;
pub mod effects;
pub mod graphic_clip;
pub mod graphics;
pub mod image;
pub mod plan;
@@ -169,7 +170,7 @@ pub(crate) fn source_size(project: &Project, item: ItemId) -> Option<(u32, u32)>
match &project.item(item)?.kind {
ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)),
ItemKind::Sequence(s) => Some((s.settings.width, s.settings.height)),
ItemKind::AdjustmentLayer { width, height, .. } => Some((*width, *height)),
ItemKind::AdjustmentLayer { width, height, .. } | ItemKind::Graphic { width, height, .. } => Some((*width, *height)),
ItemKind::Subclip { parent, .. } => source_size(project, *parent),
}
}
@@ -236,11 +237,26 @@ pub(crate) fn item_layer(
render_seq(project, nested, mt, sub, sources)
}
ItemKind::AdjustmentLayer { .. } => return None,
ItemKind::Graphic { .. } => {
if !(opts.effects && item.has_standard_effects()) {
// vectors straight to the output: no resampling, crisp at any Motion scale
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion);
let mut canvas = Image::new(w, h);
graphic_clip::render_graphic(&item.effects, mt, src_size, &m, &mut canvas);
let (op, bl) = opacity_blend(item, mt);
return Some((canvas, op, bl));
}
// standard effects work on the graphic at source resolution, then Motion places it
let (gw, gh) = (((src_size.0 as f32 * want).ceil() as usize).max(1), ((src_size.1 as f32 * want).ceil() as usize).max(1));
let mut img = Image::new(gw, gh);
graphic_clip::render_graphic(&item.effects, mt, src_size, &Affine::scale(want as f64, want as f64), &mut img);
img
}
};
let px_scale = layer.w as f32 / src_size.0.max(1) as f32;
if opts.effects {
let cx = effects::FxCtx { t: mt, px_scale, seconds: (t - item.start).seconds(), timecode: tc, clip_name: &item.name };
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) {
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e)) {
effects::apply(&mut layer, e, &cx);
}
}
+14
View File
@@ -141,6 +141,20 @@ fn push_item(
) {
let mt = item.source_time_at(t);
let (op, bl) = crate::opacity_blend(item, mt);
// Graphic clips without standard effects: the layers are rasterised (cached) into one tight
// image the GPU places as a layer.
if bl == Blend::Normal
&& !(opts.effects && item.has_standard_effects())
&& project.item(item.item).is_some_and(|p| matches!(p.kind, ItemKind::Graphic { .. }))
{
let Some(size) = crate::source_size(project, item.item) else { return };
let (w, h) = output_size(seq, opts.scale);
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion_matrix(seq, item, size, mt));
if let Some((img, x, y)) = crate::graphic_clip::render_graphic_tight(&item.effects, mt, size, &m, w, h) {
out.push(PlanLayer { frame: cpu_frame(img), matrix: Affine::translate(x as f64, y as f64), opacity: op * extra_opacity });
}
return;
}
if gpu_simple(project, item, mt) && bl == Blend::Normal {
let Some(src) = sources.source(item.item) else { return };
let Some(size) = crate::source_size(project, item.item) else { return };
+3
View File
@@ -339,6 +339,7 @@ fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) {
hash_source(h, project, *parent, depth + 1);
}
ItemKind::AdjustmentLayer { .. } => h.json(&("adjustment", &pi.kind)),
ItemKind::Graphic { .. } => h.json(&("graphic", &pi.kind)),
ItemKind::Sequence(s) => {
h.json(&("sequence", &s.settings));
for (ti, tr) in s.video_tracks.iter().enumerate() {
@@ -415,6 +416,8 @@ fn decode_cost_ms(project: &Project, id: ItemId, depth: u32) -> f64 {
}
ItemKind::Subclip { parent, .. } => decode_cost_ms(project, *parent, depth + 1),
ItemKind::AdjustmentLayer { .. } => 0.0,
// vector layers are drawn on the CPU (cached while static)
ItemKind::Graphic { .. } => 1.5,
ItemKind::Sequence(s) => {
if depth > 8 {
return 0.0;
+4 -2
View File
@@ -21,7 +21,7 @@ fn item_icon(k: &ItemKind) -> Icon {
},
ItemKind::Sequence(_) => Icon::Sequence,
ItemKind::Subclip { .. } => Icon::Film,
ItemKind::AdjustmentLayer { .. } => Icon::Adjust,
ItemKind::AdjustmentLayer { .. } | ItemKind::Graphic { .. } => Icon::Adjust,
}
}
@@ -166,7 +166,9 @@ fn columns(r: Rect) -> [f32; 4] {
}
fn matches(app: &FilmcraftApp, id: ItemId, filter: &str) -> bool {
filter.is_empty() || app.session.project.item(id).is_some_and(|i| i.name.to_ascii_lowercase().contains(filter))
// graphic clip sources are internal (Premiere doesn't list graphics in the Project panel)
app.session.project.item(id).is_some_and(|i| !matches!(i.kind, ItemKind::Graphic { .. }))
&& (filter.is_empty() || app.session.project.item(id).is_some_and(|i| i.name.to_ascii_lowercase().contains(filter)))
}
#[allow(clippy::too_many_arguments)]