diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1273dfea1..1b4f22ba5 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -54,6 +54,8 @@ procedural demo footage (`crates/media/src/generators.rs`); no external media. R | `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_template.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `graphic_template`; a built-in FilmCraft graphics template) from procedurally generated media and bundled OFL fonts | MIT OR Apache-2.0 | +| `crates/golden/goldens/graphic_rich_text.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `graphic_rich_text`) from procedurally generated media and bundled OFL fonts | MIT OR Apache-2.0 | | `crates/golden/goldens/log_to_rec709.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `log_to_rec709`) from procedurally generated media re-encoded as S-Log3 | MIT OR Apache-2.0 | | `crates/golden/goldens/hdr_tone_map.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `hdr_tone_map`) from procedurally generated media re-encoded as Rec. 2100 PQ | 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 | diff --git a/crates/golden/goldens/graphic_rich_text.png b/crates/golden/goldens/graphic_rich_text.png new file mode 100644 index 000000000..34ef3494a Binary files /dev/null and b/crates/golden/goldens/graphic_rich_text.png differ diff --git a/crates/golden/goldens/graphic_rich_text.png.attribution b/crates/golden/goldens/graphic_rich_text.png.attribution new file mode 100644 index 000000000..8d08d3ac4 --- /dev/null +++ b/crates/golden/goldens/graphic_rich_text.png.attribution @@ -0,0 +1,8 @@ +asset: graphic_rich_text.png +title: Per-character text styles and a box pinned around the text +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-03 by FilmCraft contributors (FILMCRAFT_BLESS=1) +notes: Golden reference image generated by crates/golden/tests/golden.rs (graphic_rich_text). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork. diff --git a/crates/golden/goldens/graphic_template.png b/crates/golden/goldens/graphic_template.png new file mode 100644 index 000000000..75ec9efff Binary files /dev/null and b/crates/golden/goldens/graphic_template.png differ diff --git a/crates/golden/goldens/graphic_template.png.attribution b/crates/golden/goldens/graphic_template.png.attribution new file mode 100644 index 000000000..4e1add78f --- /dev/null +++ b/crates/golden/goldens/graphic_template.png.attribution @@ -0,0 +1,8 @@ +asset: graphic_template.png +title: Graphics template (Lower Third – Slab) with overridden properties +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-03 by FilmCraft contributors (FILMCRAFT_BLESS=1) +notes: Golden reference image generated by crates/golden/tests/golden.rs (graphic_template). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork. diff --git a/crates/golden/tests/golden.rs b/crates/golden/tests/golden.rs index fbbc74db8..3a95baf8a 100644 --- a/crates/golden/tests/golden.rs +++ b/crates/golden/tests/golden.rs @@ -309,6 +309,55 @@ fn graphic_scene(bg: DemoScene, layers: Vec) -> Scene { b.at(12) } +/// A built-in graphics template (Lower Third – Slab) placed on a 320×180 sequence with its +/// editable properties overridden (name, slab and accent colours, hidden role line): checks the +/// template's scaling, pins (the slab widens with the longer name) and the overrides. +fn graphic_template() -> Scene { + use filmcraft_project::gtemplate::{builtin_templates, layer_uid}; + let t = builtin_templates().into_iter().find(|t| t.id == "builtin:lower-third-slab").unwrap(); + let (mut layers, meta) = t.instantiate((W, H)); + let mut over = |control: &str, v: ParamValue| { + let c = t.controls.iter().find(|c| c.id == control).unwrap(); + let l = layers.iter_mut().find(|l| layer_uid(l) == c.layer).unwrap(); + if c.param == "enabled" { + l.enabled = v.as_bool().unwrap(); + } else { + l.params.get_mut(&c.param).unwrap().value = v; + } + }; + over("name", ParamValue::Text("Golden Overrides".into())); + over("slab_color", ParamValue::Color([0.75, 0.15, 0.18, 1.0])); + over("accent_color", ParamValue::Color([0.2, 0.85, 0.4, 1.0])); + over("show_role", ParamValue::Bool(false)); + let mut s = graphic_scene(DemoScene::Forest, layers); + let seq = s.seq; + let q = s.project.sequence_mut(seq).unwrap(); + q.video_tracks[1].items[0].graphic = Some(Box::new(meta)); + s +} + +/// Per-character styles (a bold, bigger, coloured word and an underlined one) and a rounded box +/// pinned around the text on all four edges. +fn graphic_rich_text() -> Scene { + use filmcraft_project::graphic::{new_shape_layer, new_text_layer}; + use filmcraft_project::graphic_design::{CharStyle, LayerExtra, Pin, PinTarget, StyleRun}; + let mut text = new_text_layer("Mixed STYLES in one layer", Vec2::new(28.0, 100.0), 16.0); + let runs = vec![ + StyleRun { start: 6, end: 12, style: CharStyle { size: Some(26.0), faux_bold: Some(true), fill: Some([1.0, 0.8, 0.1, 1.0]), ..Default::default() } }, + StyleRun { + start: 16, + end: 19, + style: CharStyle { underline: Some(true), font: Some("Noto Serif".into()), faux_italic: Some(true), ..Default::default() }, + }, + ]; + text.layer = Some(Box::new(LayerExtra { uid: 1, runs, ..Default::default() })); + let mut bx = new_shape_layer(0, Vec2::new(150.0, 90.0), Vec2::new(10.0, 10.0), vec![]); + set(&mut bx, &[("corner_radius", fl(6.0)), ("fill_color", ParamValue::Color([0.1, 0.12, 0.3, 1.0])), ("opacity", fl(80.0))]); + let pin = Pin { to: PinTarget::Layer(1), left: true, top: true, right: true, bottom: true, offsets: [-10.0, -8.0, 10.0, 8.0] }; + bx.layer = Some(Box::new(LayerExtra { uid: 2, pin: Some(pin), ..Default::default() })); + graphic_scene(DemoScene::Dunes, vec![bx, text]) +} + /// 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 { @@ -555,6 +604,8 @@ fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> { ("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), + ("graphic_template", "Graphics template (Lower Third – Slab) with overridden properties", graphic_template), + ("graphic_rich_text", "Per-character text styles and a box pinned around the text", graphic_rich_text), ("log_to_rec709", "Colour management: S-Log3/S-Gamut3.Cine footage to Rec. 709 (tone mapped)", log_to_rec709), ("hdr_tone_map", "Colour management: Rec. 2100 PQ footage tone mapped into a Rec. 709 sequence", hdr_tone_map), ("mask_blur", "Masks: Gaussian Blur inside a feathered ellipse mask", mask_blur), @@ -620,6 +671,8 @@ goldens!( text_burnin, graphic_title, graphic_shapes, + graphic_template, + graphic_rich_text, log_to_rec709, hdr_tone_map, mask_blur, diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 0cdfe3428..668de4321 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -208,6 +208,22 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) Err(e) => return err(format!("`panels`: {e}")), } } + // Essential Graphics ▸ Browse and the template / font dialogs (`panels::graphics_templates`), merged + if let Some(patch) = p.get("gfxTemplates") { + let mut cur = serde_json::to_value(&app.ui.gfx_templates).unwrap_or_default(); + merge(&mut cur, patch); + match serde_json::from_value(cur) { + Ok(v) => app.ui.gfx_templates = v, + Err(e) => return err(format!("`gfxTemplates`: {e}")), + } + } + // the Type tool's text editing state: {"clip", "layer", "caret", "anchor"} (byte offsets) or null + if let Some(v) = p.get("gfxEdit") { + match serde_json::from_value(v.clone()) { + Ok(e) => app.ui.gfx_edit = e, + Err(e) => return err(format!("`gfxEdit`: {e}")), + } + } // fields of the open Edit / Clip / File dialog (`panels::clip_dialogs`) if let Some(m) = p.get("clipDialog").and_then(Value::as_object) { let Some(d) = app.ui.clip_dialog.as_mut() else { return err("no clip dialog is open") }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3c05e27c2..405c9e341 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -684,14 +684,18 @@ impl FilmcraftApp { self.session.execute("graphics.newFromFile", json!({"path": path})).map_err(|e| e.to_string()) } "captions.import" => { - let paths = self.hooks.pick_files.as_mut().map(|f| f(&["srt", "vtt", "scc"])).unwrap_or_default(); + let paths = self.hooks.pick_files.as_mut().map(|f| f(&["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp", "xml"])).unwrap_or_default(); let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; self.session.execute("captions.import", json!({"path": path})).map_err(|e| e.to_string()) } "captions.export" => { let name = self.session.state.active_sequence.and_then(|s| self.session.project.item(s)).map(|i| i.name.clone()).unwrap_or_default(); let suggested = format!("{}.srt", name.replace(' ', "_")); - let Some(path) = self.hooks.pick_save_as.as_mut().and_then(|f| f("Captions (SRT, WebVTT, SCC)", &["srt", "vtt", "scc"], &suggested)) else { + let Some(path) = + self.hooks.pick_save_as.as_mut().and_then(|f| { + f("Captions (SRT, WebVTT, SCC, MCC, EBU STL, TTML, DFXP)", &["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp"], &suggested) + }) + else { return Ok(Value::Null); }; let mut p = params.clone(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cc3ccb2a9..619f9f50b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -179,6 +179,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val if let Some(r) = crate::panels::settings::route(app, id) { return r; } + if let Some(r) = crate::panels::graphics_templates::route(app, ctx, id, ¶ms) { + if let Err(e) = &r { + app.ui.status = e.clone(); + } + return r; + } if let Some(r) = crate::panels::menu_dialogs::route(app, ctx, id, ¶ms) { if let Err(e) = &r { app.ui.status = e.clone(); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index bac1ade8f..8a54c2a8f 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -8,6 +8,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { crate::panels::color_dialogs::show(app, ctx); crate::panels::clip_dialogs::show(app, ctx); crate::panels::menu_dialogs::show(app, ctx); + crate::panels::graphics_templates::show(app, ctx); crate::panels::presets::save_dialog(app, ctx); crate::panels::audio_fx_editor::show(app, ctx); crate::panels::multicam::show_dialog(app, ctx); diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 464a9914b..d2e7f4e3b 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -739,10 +739,33 @@ struct Ctx<'a> { t: Tokens, actions: Vec<(String, Value)>, autos: Vec<(String, Rect, String)>, + /// Characters selected with the Type tool in this layer: character properties apply to them. + range: Option<(usize, usize)>, +} + +/// Character properties a Type-tool selection can carry, with their `graphics.setCharStyle` names. +fn char_style_key(id: &str) -> Option<&'static str> { + Some(match id { + "font" => "font", + "font_style" => "fontStyle", + "size" => "size", + "fill_color" => "color", + "faux_bold" => "bold", + "faux_italic" => "italic", + "underline" => "underline", + "tracking" => "tracking", + "baseline_shift" => "baselineShift", + "caps" => "caps", + _ => return None, + }) } impl Ctx<'_> { fn set(&mut self, id: &str, v: Value) { + if let (Some((a, b)), Some(k)) = (self.range, char_style_key(id)) { + self.actions.push(("graphics.setCharStyle".into(), json!({"clip": self.clip.0, "layer": self.layer, "start": a, "end": b, "style": {k: v}}))); + return; + } self.actions.push(("graphics.set".into(), json!({"clip": self.clip.0, "layer": self.layer, "props": {id: v}}))); } fn auto(&mut self, id: &str, r: Rect, label: &str) { @@ -1051,13 +1074,32 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + // ---- template properties (a graphic made from a graphics template) + if it.graphic.as_ref().is_some_and(|m| m.template.is_some()) && section(ui, app, "Template Properties", &t) { + crate::panels::graphics_templates::template_controls(app, ui, clip, &it, mt, &mut autos, &mut actions); + } let Some(l) = sel else { + if section(ui, app, "Responsive Design - Time", &t) { + crate::panels::graphics_templates::responsive_time(app, ui, clip, &it, &mut autos, &mut actions); + } ui.add_space(12.0); ui.label(egui::RichText::new("Select a layer to edit its properties.").color(t.text_faint)); return; }; let e = &it.effects[idx[l]]; - let mut cx = Ctx { clip, layer: l, e, mt, t, actions: Vec::new(), autos: Vec::new() }; + // a Type-tool selection in this layer: character properties apply to the selected characters + let range = app.ui.gfx_edit.as_ref().filter(|ed| ed.clip == clip.0 && ed.layer == l && ed.caret != ed.anchor).and_then(|ed| { + let text = match pv(e, "text", mt) { + ParamValue::Text(s) => s, + _ => return None, + }; + let ch = |b: usize| text.get(..b.min(text.len())).map_or(0, |s| s.chars().count()); + Some((ch(ed.caret.min(ed.anchor)), ch(ed.caret.max(ed.anchor)))) + }); + let mut cx = Ctx { clip, layer: l, e, mt, t, actions: Vec::new(), autos: Vec::new(), range }; + if section(ui, app, "Responsive Design - Position", &t) { + crate::panels::graphics_templates::responsive_position(app, ui, clip, &it, l, &mut cx.autos, &mut cx.actions); + } // ---- align and transform if section(ui, app, "Align and Transform", &t) { ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/graphics_templates.rs b/crates/ui-egui/src/panels/graphics_templates.rs new file mode 100644 index 000000000..eb402ee8c --- /dev/null +++ b/crates/ui-egui/src/panels/graphics_templates.rs @@ -0,0 +1,784 @@ +//! Essential Graphics ▸ Browse (the Graphics Templates library), the template / responsive-design +//! sections of the Edit tab, and the Export As Motion Graphics Template, Install Motion Graphics +//! Template and Replace Fonts in Projects dialogs (M10.7). +//! +//! The library lists the built-in templates (original FilmCraft designs) and the user's +//! `.fcgt` templates with thumbnails rendered by the engine, a search field and a category +//! filter. Click selects, double-click (or Apply) places the template at the playhead, dragging a +//! card onto the timeline places it there. Everything runs engine commands +//! (`graphics.template.*`, `graphics.setRoll`, `graphics.setResponsiveTime`, `graphics.pin`, +//! `file.replaceFonts`). +//! +//! Automation ids: `essentialGraphics.tab.browse|edit`, `gfxTemplates.search`, +//! `gfxTemplates.category`, `gfxTemplates.item.