From b760f1d23af5ff98b408d39de1269696cb4bd489 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 17:37:18 -0400 Subject: [PATCH 1/3] =?UTF-8?q?M13.25:=20project=20templates=20engine=20?= =?UTF-8?q?=E2=80=94=20eight=20original=20built-in=20templates=20with=20Es?= =?UTF-8?q?sential=20Graphics=20controls=20and=20rendered=20thumbnails;=20?= =?UTF-8?q?user=20.ectemplate=20templates=20in=20the=20config=20Templates?= =?UTF-8?q?=20folder;=20templates.list/thumbnail/create/saveAs/delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 3 + crates/engine/src/commands/mod.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/templates.rs | 871 ++++++++++++++++++++++++++++++ 4 files changed, 876 insertions(+) create mode 100644 crates/engine/src/templates.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 71c54885..8bb2d774 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -59,6 +59,9 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall - UI icons drawn in code: `crates/ui-egui/src/icons.rs`. - Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`. +- Built-in project templates (Lower Third, Title Card, Logo Reveal, Kinetic Type, Social Square, Vertical Story, + Slideshow, 3D Text Orbit) built from engine commands, and their thumbnails rendered by our own renderer: + `crates/engine/src/templates.rs`. - Text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter, Drop In Lines): `crates/engine/presets/text_animators.json` (sidecar `.attribution`). - Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`, diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 4768ef99..2d6a40b1 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -199,6 +199,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(content_fill::specs()); v.extend(stubs::specs()); v.extend(crate::learn::specs()); + v.extend(crate::templates::specs()); v.extend(crate::preview::specs()); v }) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4829395a..94409c05 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -35,6 +35,7 @@ pub mod scriptui; mod session_settings; pub mod shortcuts; pub mod sysinfo; +pub mod templates; pub mod tracking; pub mod vector; pub mod viewer; diff --git a/crates/engine/src/templates.rs b/crates/engine/src/templates.rs new file mode 100644 index 00000000..3800b235 --- /dev/null +++ b/crates/engine/src/templates.rs @@ -0,0 +1,871 @@ +//! Project templates: the Home screen's **New from Template** gallery. +//! +//! - **Built-in templates** are original work authored in code (MIT OR Apache-2.0, listed in +//! `ATTRIBUTION.md`): each builds its project by running engine commands on a scratch session, +//! so it is exactly what a user could make by hand. Their thumbnails are rendered by our own +//! renderer the first time they are asked for. +//! - **User templates**: File ▸ Save as Template… writes the open project as an `.ectemplate` +//! (the open template container of `essential.exportTemplate`: a stored ZIP with +//! `manifest.json` (`kind: "project"`), `project.ecproj`, `poster.png` and `thumb.txt`) into +//! the config store's `Templates` folder. +//! - Opening a template (`templates.create`) makes an untitled copy: the template itself is never +//! changed. +//! +//! Commands: `templates.list`, `templates.thumbnail`, `templates.create`, `templates.saveAs`, +//! `templates.delete`. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; + +use effectcraft_keyframe::{Keyframe, Value as KV}; +use effectcraft_project::{ItemId, LayerId, Project}; +use effectcraft_time::Tick; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::commands::essential::{TEMPLATE_FORMAT, TEMPLATE_VERSION, read_template}; +use crate::commands::{CommandSpec, always, bad, str_p}; +use crate::{EngineError, Result, Session, cmd, query}; + +/// The config-store folder user templates live in. +pub const TEMPLATES_DIR: &str = "Templates"; +/// Template file extension. +pub const EXT: &str = "ectemplate"; +/// Gallery thumbnail size (RGB, letterboxed). +pub const THUMB_W: usize = 160; +pub const THUMB_H: usize = 90; + +/// One gallery entry. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplateInfo { + /// Built-in: its id (`lower-third`); user: `user/`. + pub id: String, + pub name: String, + pub description: String, + pub category: String, + pub builtin: bool, + pub width: u32, + pub height: u32, + pub frame_rate: f64, + pub duration: f64, + /// Essential Graphics controls of the main composition (names). + pub controls: Vec, +} + +struct Builtin { + id: &'static str, + name: &'static str, + category: &'static str, + description: &'static str, + build: fn(&mut Session) -> Result<()>, +} + +const BUILTINS: &[Builtin] = &[ + Builtin { + id: "lower-third", + name: "Lower Third", + category: "Titles", + description: "A name and title that slide in on an accent bar. Essential Graphics: Name, Title, Bar Color.", + build: lower_third, + }, + Builtin { + id: "title-card", + name: "Title Card", + category: "Titles", + description: "A centred title that scales up over a gradient, with a subtitle fade. Essential Graphics: Title, Subtitle.", + build: title_card, + }, + Builtin { + id: "logo-reveal", + name: "Logo Reveal", + category: "Brand", + description: "A placeholder logo mark spins and glows into place above the brand name. Essential Graphics: Brand Name, Logo Color.", + build: logo_reveal, + }, + Builtin { + id: "kinetic-type", + name: "Kinetic Type", + category: "Titles", + description: "Three words pop on in rhythm. Essential Graphics: Word 1, Word 2, Word 3.", + build: kinetic_type, + }, + Builtin { + id: "social-square", + name: "Social Square 1080×1080", + category: "Social", + description: "A square post: headline, accent circle and handle. Essential Graphics: Headline, Handle, Accent Color.", + build: social_square, + }, + Builtin { + id: "vertical-story", + name: "Vertical 9:16 Story", + category: "Social", + description: "A 1080×1920 story with a headline and a bobbing call to action. Essential Graphics: Headline, Call to Action.", + build: vertical_story, + }, + Builtin { + id: "slideshow", + name: "Slideshow", + category: "Photo", + description: "Three slides with a slow push-in and cross-fades; replace the colour slides with your photos. Essential Graphics: Caption 1–3.", + build: slideshow, + }, + Builtin { + id: "text-orbit-3d", + name: "3D Text Orbit", + category: "3D", + description: "3D title cards with a camera orbiting around them over a floor. Essential Graphics: Title.", + build: text_orbit, + }, +]; + +// ------------------------------------------------------------------------- authoring helpers + +fn ex(s: &mut Session, c: &str, p: Value) -> Result { + s.execute(c, p) +} + +fn layer_of(v: &Value) -> Result { + v["layer"].as_u64().map(LayerId).ok_or_else(|| EngineError::Other("template: no layer".into())) +} + +fn comp(s: &mut Session, name: &str, w: u32, h: u32, secs: f64, bg: &str) -> Result { + ex(s, "comp.new", json!({"name": name, "width": w, "height": h, "frameRate": 30, "duration": secs, "background": bg}))?; + s.active_comp_id().ok_or(EngineError::NoComp) +} + +fn solid(s: &mut Session, name: &str, color: &str) -> Result { + layer_of(&ex(s, "layer.newSolid", json!({"name": name, "color": color}))?) +} + +fn text(s: &mut Session, txt: &str, pos: [f64; 2], size: f64, fill: &str) -> Result { + layer_of(&ex(s, "layer.newText", json!({"text": txt, "position": pos, "size": size, "fill": fill}))?) +} + +fn shape(s: &mut Session, name: &str, kind: &str, size: [f64; 2], fill: &str, pos: [f64; 2]) -> Result { + layer_of(&ex(s, "layer.newShape", json!({"name": name, "kind": kind, "size": size, "fill": fill, "position": pos}))?) +} + +fn gradient(s: &mut Session, l: LayerId, start: [f64; 2], end: [f64; 2], a: &str, b: &str, radial: bool) -> Result<()> { + ex(s, "effect.apply", json!({"layers": [l.0], "effect": "ec.generate.gradientramp"}))?; + let hex = |h: &str| effectcraft_color::Rgba::from_hex(h).map(|c| [c.r as f64, c.g as f64, c.b as f64, 1.0]).unwrap_or([1.0; 4]); + edit_layer(s, l, |ly| { + let set = |ly: &mut effectcraft_project::Layer, k: &str, v: KV| { + if let Some(p) = ly.props.prop_mut(&format!("effects/#1/{k}")) { + p.value = v; + } + }; + set(ly, "start", KV::Vec2(start)); + set(ly, "end", KV::Vec2(end)); + set(ly, "startColor", KV::Color(hex(a))); + set(ly, "endColor", KV::Color(hex(b))); + set(ly, "shape", KV::Enum(u32::from(radial))); + }); + Ok(()) +} + +fn edit_layer(s: &mut Session, l: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) { + let Some(cid) = s.active_comp_id() else { return }; + if let Some(ly) = Arc::make_mut(&mut s.project).comp_mut(cid).and_then(|c| c.layer_mut(l)) { + f(ly); + } +} + +/// Eased keyframes at seconds (layer time = comp time: template layers start at 0). +fn keys(s: &mut Session, l: LayerId, path: &str, list: &[(f64, KV)]) { + let rate = s.active_comp().map(|c| c.frame_rate).unwrap_or(effectcraft_time::FrameRate::FPS_30); + edit_layer(s, l, |ly| { + if let Some(p) = ly.props.prop_mut(path) { + p.keys = list.iter().map(|(t, v)| Keyframe::new(rate.snap_nearest(Tick::from_seconds_f64(*t)), v.clone()).eased()).collect(); + } + }); +} + +fn set(s: &mut Session, l: LayerId, path: &str, v: KV) { + edit_layer(s, l, |ly| { + if let Some(p) = ly.props.prop_mut(path) { + p.value = v; + } + }); +} + +fn expose(s: &mut Session, l: LayerId, path: &str, name: &str) -> Result<()> { + ex(s, "essential.addProperty", json!({"layer": l.0, "path": path, "name": name}))?; + Ok(()) +} + +fn finish(s: &mut Session, eg_name: &str, poster: f64) -> Result<()> { + ex(s, "essential.setName", json!({"name": eg_name}))?; + if let Some(cid) = s.active_comp_id() { + let rate = s.active_comp().map(|c| c.frame_rate).unwrap_or(effectcraft_time::FrameRate::FPS_30); + if let Some(c) = Arc::make_mut(&mut s.project).comp_mut(cid) { + c.poster_time = rate.snap_nearest(Tick::from_seconds_f64(poster)); + } + } + Ok(()) +} + +fn p3(x: f64, y: f64) -> KV { + KV::Vec3([x, y, 0.0]) +} +fn sc(v: f64) -> KV { + KV::Vec3([v, v, 100.0]) +} +fn op(v: f64) -> KV { + KV::Scalar(v) +} + +// ------------------------------------------------------------------------- built-in templates + +fn lower_third(s: &mut Session) -> Result<()> { + comp(s, "Lower Third", 1920, 1080, 6.0, "#101218")?; + let bg = solid(s, "Preview Background (delete over footage)", "#000000")?; + gradient(s, bg, [960.0, 200.0], [960.0, 1080.0], "#2B3550", "#0B0D14", false)?; + let bar = shape(s, "Accent Bar", "rect", [760.0, 150.0], "#3D8BFF", [0.0, 0.0])?; + keys(s, bar, "transform/position", &[(0.0, p3(-420.0, 860.0)), (0.7, p3(520.0, 860.0)), (5.0, p3(520.0, 860.0)), (5.6, p3(-420.0, 860.0))]); + let name = text(s, "Alex Rivera", [520.0, 845.0], 64.0, "#FFFFFF")?; + keys(s, name, "transform/opacity", &[(0.4, op(0.0)), (0.9, op(100.0)), (4.9, op(100.0)), (5.4, op(0.0))]); + let title = text(s, "Motion Designer", [520.0, 905.0], 34.0, "#DDE6FF")?; + keys(s, title, "transform/opacity", &[(0.6, op(0.0)), (1.1, op(100.0)), (4.8, op(100.0)), (5.3, op(0.0))]); + expose(s, name, "text/sourceText", "Name")?; + expose(s, title, "text/sourceText", "Title")?; + expose(s, bar, "contents/group#1/contents/fill/color", "Bar Color")?; + finish(s, "Lower Third", 2.0) +} + +fn title_card(s: &mut Session) -> Result<()> { + comp(s, "Title Card", 1920, 1080, 5.0, "#000000")?; + let bg = solid(s, "Background", "#000000")?; + gradient(s, bg, [960.0, 400.0], [960.0, 1400.0], "#5B2A86", "#120A1F", true)?; + let title = text(s, "YOUR TITLE HERE", [960.0, 560.0], 120.0, "#FFFFFF")?; + keys(s, title, "transform/scale", &[(0.0, sc(70.0)), (1.2, sc(100.0)), (5.0, sc(106.0))]); + keys(s, title, "transform/opacity", &[(0.0, op(0.0)), (0.6, op(100.0))]); + let sub = text(s, "A subtitle that sets the scene", [960.0, 660.0], 40.0, "#E9D8FF")?; + keys(s, sub, "transform/opacity", &[(0.8, op(0.0)), (1.6, op(100.0))]); + expose(s, title, "text/sourceText", "Title")?; + expose(s, sub, "text/sourceText", "Subtitle")?; + finish(s, "Title Card", 2.5) +} + +fn logo_reveal(s: &mut Session) -> Result<()> { + comp(s, "Logo Reveal", 1920, 1080, 5.0, "#05070C")?; + let bg = solid(s, "Background", "#000000")?; + gradient(s, bg, [960.0, 480.0], [960.0, 1300.0], "#14324A", "#04060A", true)?; + let logo = shape(s, "Logo (replace me)", "star", [300.0, 300.0], "#FFC23D", [960.0, 450.0])?; + keys(s, logo, "transform/scale", &[(0.0, sc(0.0)), (1.0, sc(110.0)), (1.4, sc(100.0))]); + keys(s, logo, "transform/rotation", &[(0.0, op(-180.0)), (1.4, op(0.0))]); + ex(s, "effect.apply", json!({"layers": [logo.0], "effect": "Glow"}))?; + let brand = text(s, "BRAND NAME", [960.0, 720.0], 84.0, "#FFFFFF")?; + keys(s, brand, "transform/opacity", &[(1.2, op(0.0)), (2.0, op(100.0))]); + keys(s, brand, "transform/position", &[(1.2, p3(960.0, 760.0)), (2.0, p3(960.0, 720.0))]); + expose(s, brand, "text/sourceText", "Brand Name")?; + expose(s, logo, "contents/group#1/contents/fill/color", "Logo Color")?; + finish(s, "Logo Reveal", 3.0) +} + +fn kinetic_type(s: &mut Session) -> Result<()> { + comp(s, "Kinetic Type", 1920, 1080, 4.0, "#111111")?; + let bg = solid(s, "Background", "#F2EDE4")?; + let _ = bg; + let words = [("MAKE", 380.0, "#111111", 0.2), ("IT", 560.0, "#E8452C", 0.7), ("MOVE", 740.0, "#111111", 1.2)]; + let mut ids = vec![]; + for (w, y, c, t0) in words { + let l = text(s, w, [960.0, y], 170.0, c)?; + keys(s, l, "transform/scale", &[(t0, sc(0.0)), (t0 + 0.25, sc(118.0)), (t0 + 0.4, sc(100.0))]); + ids.push(l); + } + for (k, l) in ids.into_iter().enumerate() { + expose(s, l, "text/sourceText", &format!("Word {}", k + 1))?; + } + finish(s, "Kinetic Type", 2.0) +} + +fn social_square(s: &mut Session) -> Result<()> { + comp(s, "Social Square", 1080, 1080, 6.0, "#0E1A2B")?; + let bg = solid(s, "Background", "#000000")?; + gradient(s, bg, [0.0, 0.0], [1080.0, 1080.0], "#0F4C5C", "#0B1320", false)?; + let dot = shape(s, "Accent Circle", "ellipse", [400.0, 400.0], "#FF6B6B", [830.0, 250.0])?; + keys(s, dot, "transform/scale", &[(0.0, sc(0.0)), (0.8, sc(100.0)), (6.0, sc(112.0))]); + let head = text(s, "Big news\ndrops today", [540.0, 600.0], 104.0, "#FFFFFF")?; + keys(s, head, "transform/opacity", &[(0.3, op(0.0)), (1.0, op(100.0))]); + let handle = text(s, "@yourhandle", [540.0, 930.0], 44.0, "#BFE9F0")?; + keys(s, handle, "transform/opacity", &[(0.9, op(0.0)), (1.5, op(100.0))]); + expose(s, head, "text/sourceText", "Headline")?; + expose(s, handle, "text/sourceText", "Handle")?; + expose(s, dot, "contents/group#1/contents/fill/color", "Accent Color")?; + finish(s, "Social Square", 2.0) +} + +fn vertical_story(s: &mut Session) -> Result<()> { + comp(s, "Vertical Story", 1080, 1920, 8.0, "#120E24")?; + let bg = solid(s, "Background", "#000000")?; + gradient(s, bg, [540.0, 0.0], [540.0, 1920.0], "#FF8A3D", "#4A1FB8", false)?; + let card = shape(s, "Card", "rounded", [880.0, 820.0], "#FFFFFF", [540.0, 860.0])?; + set(s, card, "transform/opacity", op(18.0)); + keys(s, card, "transform/position", &[(0.0, p3(540.0, 1100.0)), (0.8, p3(540.0, 860.0))]); + let head = text(s, "NEW\nDROP", [540.0, 800.0], 200.0, "#FFFFFF")?; + keys(s, head, "transform/scale", &[(0.3, sc(80.0)), (1.1, sc(100.0))]); + keys(s, head, "transform/opacity", &[(0.3, op(0.0)), (0.9, op(100.0))]); + let cta = text(s, "Swipe up ↑", [540.0, 1640.0], 56.0, "#FFFFFF")?; + keys( + s, + cta, + "transform/position", + &[(1.0, p3(540.0, 1640.0)), (1.6, p3(540.0, 1610.0)), (2.2, p3(540.0, 1640.0)), (2.8, p3(540.0, 1610.0)), (3.4, p3(540.0, 1640.0))], + ); + expose(s, head, "text/sourceText", "Headline")?; + expose(s, cta, "text/sourceText", "Call to Action")?; + finish(s, "Vertical Story", 1.5) +} + +fn slideshow(s: &mut Session) -> Result<()> { + comp(s, "Slideshow", 1920, 1080, 9.0, "#000000")?; + let slides = [ + ("Slide 1 (replace with a photo)", "#3A6EA5", "#0F2238", "First caption"), + ("Slide 2", "#C0603A", "#3A160A", "Second caption"), + ("Slide 3", "#4F8A4B", "#13260F", "Third caption"), + ]; + let mut caps = vec![]; + // Bottom to top: each later slide fades in over the one before. + for (k, (name, a, b, cap)) in slides.into_iter().enumerate() { + let t0 = k as f64 * 3.0; + let l = solid(s, name, "#000000")?; + gradient(s, l, [300.0, 200.0], [1700.0, 1000.0], a, b, false)?; + keys(s, l, "transform/scale", &[(t0, sc(100.0)), (t0 + 3.6, sc(112.0))]); + // A simple placeholder picture: a sun over a hill. + let sun = shape(s, &format!("Sun {}", k + 1), "ellipse", [260.0, 260.0], "#FFE7A8", [1380.0 - 300.0 * k as f64, 330.0])?; + let hill = shape(s, &format!("Hill {}", k + 1), "ellipse", [2600.0, 900.0], b, [700.0 + 400.0 * k as f64, 1150.0])?; + keys(s, sun, "transform/position", &[(t0, p3(1380.0 - 300.0 * k as f64, 330.0)), (t0 + 3.6, p3(1420.0 - 300.0 * k as f64, 300.0))]); + if k > 0 { + for x in [l, sun, hill] { + keys(s, x, "transform/opacity", &[(t0 - 0.6, op(0.0)), (t0, op(100.0))]); + } + } + let c = text(s, cap, [960.0, 960.0], 54.0, "#FFFFFF")?; + let fade_in = if k == 0 { 0.2 } else { t0 }; + keys(s, c, "transform/opacity", &[(fade_in, op(0.0)), (fade_in + 0.5, op(100.0)), (t0 + 2.5, op(100.0)), (t0 + 2.9, op(0.0))]); + caps.push(c); + } + for (k, c) in caps.into_iter().enumerate() { + expose(s, c, "text/sourceText", &format!("Caption {}", k + 1))?; + } + finish(s, "Slideshow", 1.5) +} + +fn text_orbit(s: &mut Session) -> Result<()> { + comp(s, "3D Text Orbit", 1920, 1080, 8.0, "#070910")?; + let floor = shape(s, "Floor", "ellipse", [1000.0, 1000.0], "#1C2740", [960.0, 760.0])?; + ex(s, "layer.setSwitch", json!({"layers": [floor.0], "switch": "threeD", "value": true}))?; + set(s, floor, "transform/position", KV::Vec3([960.0, 760.0, 0.0])); + set(s, floor, "transform/rotationX", op(90.0)); + let title = text(s, "ORBIT", [960.0, 520.0], 220.0, "#FFFFFF")?; + ex(s, "layer.setSwitch", json!({"layers": [title.0], "switch": "threeD", "value": true}))?; + let sub = text(s, "in three dimensions", [960.0, 640.0], 48.0, "#8FB8FF")?; + ex(s, "layer.setSwitch", json!({"layers": [sub.0], "switch": "threeD", "value": true}))?; + set(s, sub, "transform/position", KV::Vec3([960.0, 640.0, -120.0])); + let cam = layer_of(&ex(s, "layer.newCamera", json!({"name": "Orbit Camera", "type": "twoNode", "preset": "35mm", "poi": [960.0, 560.0, 0.0]}))?)?; + // A circle around the title (radius 1500) in eight eased steps. + let n = 8; + let list: Vec<(f64, KV)> = (0..=n) + .map(|i| { + let a = (-50.0 + 100.0 * i as f64 / n as f64).to_radians(); + (8.0 * i as f64 / n as f64, KV::Vec3([960.0 + 1500.0 * a.sin(), 380.0, -1500.0 * a.cos()])) + }) + .collect(); + let rate = s.active_comp().map(|c| c.frame_rate).unwrap_or(effectcraft_time::FrameRate::FPS_30); + edit_layer(s, cam, |ly| { + if let Some(p) = ly.props.prop_mut("transform/position") { + p.keys = list.iter().map(|(t, v)| Keyframe::new(rate.snap_nearest(Tick::from_seconds_f64(*t)), v.clone())).collect(); + } + if let Some(p) = ly.props.prop_mut("transform/poi") { + p.value = KV::Vec3([960.0, 560.0, 0.0]); + } + }); + expose(s, title, "text/sourceText", "Title")?; + finish(s, "3D Text Orbit", 3.0) +} + +// ------------------------------------------------------------------------- built-in access + +fn builtin(id: &str) -> Option<&'static Builtin> { + BUILTINS.iter().find(|b| b.id == id) +} + +/// Build a built-in template's project (the main comp is the one Essential Graphics names). +pub fn build_builtin(id: &str) -> Result { + let b = builtin(id).ok_or_else(|| bad("templates.create", format!("unknown template `{id}` (see templates.list)")))?; + let mut s = Session::default(); + (b.build)(&mut s).map_err(|e| EngineError::Other(format!("template {id}: {e}")))?; + let mut p = (*s.project).clone(); + p.fix_next_id(); + Ok(p) +} + +/// The main composition: the one with Essential Graphics controls, else the first comp. +pub fn main_comp(p: &Project) -> Option { + p.comps().find(|(_, c)| c.essential.as_ref().is_some_and(|e| !e.controls.is_empty())).or_else(|| p.comps().next()).map(|(id, _)| *id) +} + +fn controls_of(p: &Project, cid: Option) -> Vec { + cid.and_then(|c| p.comp(c)).and_then(|c| c.essential.as_ref()).map(|e| e.flat().iter().map(|c| c.name.clone()).collect()).unwrap_or_default() +} + +fn info_from(id: String, name: String, description: String, category: String, builtin: bool, p: &Project) -> TemplateInfo { + let cid = main_comp(p); + let c = cid.and_then(|c| p.comp(c)); + TemplateInfo { + id, + name, + description, + category, + builtin, + width: c.map(|c| c.width).unwrap_or(0), + height: c.map(|c| c.height).unwrap_or(0), + frame_rate: c.map(|c| c.frame_rate.as_f64()).unwrap_or(0.0), + duration: c.map(|c| c.duration.seconds()).unwrap_or(0.0), + controls: controls_of(p, cid), + } +} + +/// Built projects (and thumbnails) are cached for the process: they never change. +type Cache = Mutex)>>; +fn cache() -> &'static Cache { + static C: OnceLock = OnceLock::new(); + C.get_or_init(Default::default) +} + +fn cached_builtin(id: &str) -> Result { + if let Some((p, _)) = cache().lock().ok().and_then(|c| c.get(id).cloned()) { + return Ok(p); + } + let p = build_builtin(id)?; + if let Ok(mut c) = cache().lock() { + c.insert(id.to_string(), (p.clone(), None)); + } + Ok(p) +} + +// ------------------------------------------------------------------------- thumbnails + +/// Letterbox RGBA pixels into a THUMB_W×THUMB_H RGB thumbnail, as hex text (`thumb.txt`). +pub fn encode_thumb(w: u32, h: u32, rgba: &[u8]) -> String { + let (w, h) = (w as usize, h as usize); + let k = (THUMB_W as f32 / w.max(1) as f32).min(THUMB_H as f32 / h.max(1) as f32); + let (dw, dh) = (w as f32 * k, h as f32 * k); + let (ox, oy) = ((THUMB_W as f32 - dw) / 2.0, (THUMB_H as f32 - dh) / 2.0); + let mut out = String::with_capacity(THUMB_W * THUMB_H * 6); + for y in 0..THUMB_H { + for x in 0..THUMB_W { + let (fx, fy) = ((x as f32 + 0.5 - ox) / k, (y as f32 + 0.5 - oy) / k); + let c = if fx >= 0.0 && fy >= 0.0 && (fx as usize) < w && (fy as usize) < h { + let i = (fy as usize * w + fx as usize) * 4; + [rgba[i], rgba[i + 1], rgba[i + 2]] + } else { + [0x16, 0x17, 0x1c] + }; + for v in c { + out.push_str(&format!("{v:02x}")); + } + } + } + out +} + +/// Decode a thumbnail to RGB bytes (THUMB_W×THUMB_H×3). +pub fn decode_thumb(text: &str) -> Option> { + let t = text.trim(); + if t.len() != THUMB_W * THUMB_H * 6 { + return None; + } + (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect() +} + +/// Render a project's main comp at its poster time into a thumbnail. +pub fn render_thumb(p: &Project) -> Option { + let cid = main_comp(p)?; + let t = p.comp(cid)?.poster_time; + let mut s = Session::default(); + s.replace_project(p.clone(), None); + let (w, h, rgba) = s.render_rgba8(cid, t, (THUMB_W * 2) as u32).ok()?; + Some(encode_thumb(w, h, &rgba)) +} + +/// A template's thumbnail (hex RGB, THUMB_W×THUMB_H); built-in ones are rendered on first use. +pub fn thumbnail(s: &Session, id: &str) -> Result { + if let Some(name) = id.strip_prefix("user/") { + let bytes = read_user(s, name)?; + let entries = effectcraft_lottie::zip::read_stored(&bytes); + if let Some((_, t)) = entries.iter().find(|(k, _)| k == "thumb.txt") { + return Ok(String::from_utf8_lossy(t).into_owned()); + } + let (_, p, _) = read_template(&bytes).map_err(|e| bad("templates.thumbnail", e))?; + return render_thumb(&p).ok_or_else(|| bad("templates.thumbnail", "nothing to render")); + } + if let Some(Some(t)) = cache().lock().ok().and_then(|c| c.get(id).map(|e| e.1.clone())) { + return Ok(t); + } + let p = cached_builtin(id)?; + let t = render_thumb(&p).ok_or_else(|| bad("templates.thumbnail", "nothing to render"))?; + if let Ok(mut c) = cache().lock() { + c.insert(id.to_string(), (p, Some(t.clone()))); + } + Ok(t) +} + +// ------------------------------------------------------------------------- user templates + +/// Where user templates are written: `/Templates` (None: in the store itself). +fn user_dir(s: &Session) -> Option { + s.config.as_ref()?.dir().map(|d| d.join(TEMPLATES_DIR)) +} + +/// File names of the user templates (`*.ectemplate`), sorted. +pub fn user_files(s: &Session) -> Vec { + let Some(cfg) = &s.config else { return vec![] }; + let mut v: Vec = match user_dir(s) { + Some(d) => cfg.files().list(&d).into_iter().map(|(n, _)| n).collect(), + None => cfg.list(TEMPLATES_DIR), + }; + v.retain(|n| n.to_ascii_lowercase().ends_with(&format!(".{EXT}"))); + v.sort(); + v +} + +fn check_name(name: &str) -> Result<()> { + if name.is_empty() || name.contains(['/', '\\']) || name.starts_with('.') { + return Err(bad("templates", format!("bad template file name `{name}`"))); + } + Ok(()) +} + +fn read_user(s: &Session, file: &str) -> Result> { + check_name(file)?; + let cfg = s.config.as_ref().ok_or_else(|| bad("templates", "no settings store: user templates are unavailable"))?; + match user_dir(s) { + Some(d) => s.services.read_file(&d.join(file).to_string_lossy()).map_err(|e| EngineError::Other(format!("cannot read template {file}: {e}"))), + None => cfg + .read(&format!("{TEMPLATES_DIR}/{file}")) + .and_then(|t| effectcraft_track::roto::rle::base64_decode(&t)) + .ok_or_else(|| bad("templates", format!("no user template `{file}`"))), + } +} + +fn write_user(s: &Session, file: &str, bytes: &[u8]) -> Result { + check_name(file)?; + let cfg = s.config.as_ref().ok_or_else(|| bad("templates.saveAs", "no settings store: user templates are unavailable"))?; + match user_dir(s) { + Some(d) => { + let path = d.join(file); + cfg.files().write(&path, bytes).map_err(|e| EngineError::Other(format!("cannot write {}: {e}", path.display())))?; + Ok(path.to_string_lossy().into_owned()) + } + None => { + let name = format!("{TEMPLATES_DIR}/{file}"); + cfg.write(&name, &effectcraft_track::roto::rle::base64_encode(bytes)).map_err(|e| EngineError::Other(format!("cannot write {name}: {e}")))?; + Ok(name) + } + } +} + +fn remove_user(s: &Session, file: &str) -> Result<()> { + check_name(file)?; + let cfg = s.config.as_ref().ok_or_else(|| bad("templates.delete", "no settings store"))?; + if !user_files(s).iter().any(|f| f == file) { + return Err(bad("templates.delete", format!("no user template `{file}`"))); + } + match user_dir(s) { + Some(d) => cfg.files().remove(&d.join(file)), + None => cfg.remove(&format!("{TEMPLATES_DIR}/{file}")), + } + .map_err(|e| EngineError::Other(format!("cannot delete {file}: {e}"))) +} + +/// A safe file name for a template name. +pub fn file_name_for(name: &str) -> String { + let clean: String = name.chars().map(|c| if c.is_alphanumeric() || " -_()".contains(c) { c } else { '_' }).collect(); + let clean = clean.trim().trim_start_matches('.').to_string(); + format!("{}.{EXT}", if clean.is_empty() { "Template".into() } else { clean }) +} + +fn str_of(m: &Value, k: &str) -> String { + m.get(k).and_then(Value::as_str).unwrap_or_default().to_string() +} + +/// Every template: built-in ones first, then the user's. +pub fn list(s: &Session) -> Vec { + let mut v = vec![]; + for b in BUILTINS { + if let Ok(p) = cached_builtin(b.id) { + v.push(info_from(b.id.into(), b.name.into(), b.description.into(), b.category.into(), true, &p)); + } + } + for f in user_files(s) { + let Ok(bytes) = read_user(s, &f) else { continue }; + let Ok((m, p, _)) = read_template(&bytes) else { continue }; + let stem = f.strip_suffix(&format!(".{EXT}")).unwrap_or(&f).to_string(); + let name = Some(str_of(&m, "name")).filter(|n| !n.is_empty()).unwrap_or(stem); + let cat = Some(str_of(&m, "category")).filter(|n| !n.is_empty()).unwrap_or_else(|| "My Templates".into()); + v.push(info_from(format!("user/{f}"), name, str_of(&m, "description"), cat, false, &p)); + } + v +} + +/// The project of a template id (or of an `.ectemplate` file). +fn project_of(s: &Session, id: Option<&str>, path: Option<&str>) -> Result<(Project, String)> { + if let Some(path) = path { + let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; + let (m, p, _) = read_template(&bytes).map_err(|e| bad("templates.create", e))?; + return Ok((p, str_of(&m, "name"))); + } + let id = id.ok_or_else(|| bad("templates.create", "missing `id` (see templates.list) or `path`"))?; + if let Some(f) = id.strip_prefix("user/") { + let (m, p, _) = read_template(&read_user(s, f)?).map_err(|e| bad("templates.create", e))?; + return Ok((p, str_of(&m, "name"))); + } + let b = builtin(id).ok_or_else(|| bad("templates.create", format!("unknown template `{id}` (see templates.list)")))?; + Ok((cached_builtin(id)?, b.name.to_string())) +} + +// ------------------------------------------------------------------------- commands + +fn list_cmd(s: &mut Session, p: &Value) -> Result { + let with_thumbs = p.get("thumbnails").and_then(Value::as_bool).unwrap_or(false); + let mut out = vec![]; + for t in list(s) { + let mut j = serde_json::to_value(&t).unwrap_or_default(); + if with_thumbs { + j["thumbnail"] = json!(thumbnail(s, &t.id).ok()); + } + out.push(j); + } + Ok(json!(out)) +} + +fn thumb_cmd(s: &mut Session, p: &Value) -> Result { + let id = str_p(p, "id").ok_or_else(|| bad("templates.thumbnail", "missing `id`"))?; + Ok(json!({"id": id, "width": THUMB_W, "height": THUMB_H, "rgb": thumbnail(s, id)?})) +} + +fn create(s: &mut Session, p: &Value) -> Result { + let (proj, name) = project_of(s, str_p(p, "id"), str_p(p, "path"))?; + let cid = main_comp(&proj); + let poster = cid.and_then(|c| proj.comp(c)).map(|c| c.poster_time).unwrap_or(Tick::ZERO); + s.replace_project(proj, None); + s.update_sentinel(); + if let Some(c) = cid { + s.open_comp(c); + s.set_time(poster); + } + s.toast(format!("New project from template “{name}”")); + Ok(json!({"template": name, "comp": cid.map(|c| c.0), "untitled": true})) +} + +fn save_as(s: &mut Session, p: &Value) -> Result { + const C: &str = "templates.saveAs"; + let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string(); + if s.project.comps().next().is_none() { + return Err(bad(C, "the project has no compositions")); + } + let file = file_name_for(&name); + let overwrite = p.get("overwrite").and_then(Value::as_bool).unwrap_or(true); + if !overwrite && user_files(s).contains(&file) { + return Err(bad(C, format!("a template named “{name}” already exists"))); + } + let cid = s.active_comp_id().or_else(|| main_comp(&s.project)).ok_or(EngineError::NoComp)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + // The active comp becomes the template's main comp: give it the poster time it shows now. + let mut proj = (*s.project).clone(); + if let Some(c) = proj.comp_mut(cid) + && c.poster_time == Tick::ZERO + { + c.poster_time = s.time_of(cid); + } + let comp_name = proj.item(cid).map(|i| i.name.clone()).unwrap_or_default(); + let manifest = json!({ + "format": TEMPLATE_FORMAT, + "version": TEMPLATE_VERSION, + "kind": "project", + "generator": format!("EffectCraft {}", env!("CARGO_PKG_VERSION")), + "name": name, + "description": str_p(p, "description").unwrap_or_default(), + "category": str_p(p, "category").filter(|c| !c.is_empty()).unwrap_or("My Templates"), + "comp": cid.0, + "compName": comp_name, + "width": comp.width, + "height": comp.height, + "frameRate": comp.frame_rate.as_f64(), + "duration": comp.duration.seconds(), + "controls": controls_of(&proj, Some(cid)), + "poster": "poster.png", + "thumbnail": "thumb.txt", + }); + let t = s.time_of(cid); + let mut entries: Vec<(String, Vec)> = + vec![("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default()), ("project.ecproj".into(), proj.to_json().into_bytes())]; + if let Ok((w, h, rgba)) = s.render_rgba8(cid, t, 320) { + if let Ok(png) = crate::commands::comp_more::encode_png(&rgba, w, h) { + entries.push(("poster.png".into(), png)); + } + entries.push(("thumb.txt".into(), encode_thumb(w, h, &rgba).into_bytes())); + } + let bytes = effectcraft_lottie::zip::store(&entries); + let at = write_user(s, &file, &bytes)?; + s.toast(format!("Saved template “{name}”")); + Ok(json!({"id": format!("user/{file}"), "name": name, "path": at, "bytes": bytes.len()})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + let id = str_p(p, "id").ok_or_else(|| bad("templates.delete", "missing `id`"))?; + let file = id.strip_prefix("user/").ok_or_else(|| bad("templates.delete", "only user templates (`user/…`) can be deleted"))?; + remove_user(s, file)?; + Ok(json!({"deleted": id})) +} + +fn has_comps(s: &Session) -> std::result::Result<(), String> { + if s.project.comps().next().is_some() { Ok(()) } else { Err("the project has no compositions".into()) } +} + +pub fn specs() -> Vec { + vec![ + query!( + "templates.list", + "Project Templates", + "{thumbnails?: bool} → [{id, name, description, category, builtin, width, height, frameRate, duration, controls, thumbnail? (hex RGB 160×90)}]", + list_cmd + ), + query!("templates.thumbnail", "Template Thumbnail", "{id} → {id, width, height, rgb (hex)}", thumb_cmd), + cmd!("templates.create", "New Project from Template", [], None, "{id (templates.list) | path (.ectemplate)} → an untitled copy", always, create), + cmd!( + "templates.saveAs", + "Save as Template...", + ["File"], + None, + "{name, description?, category?, overwrite?: bool (default true)} → {id, path}", + has_comps, + save_as + ), + cmd!("templates.delete", "Delete Template", [], None, "{id: user/}", always, delete), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{DirConfig, MemoryConfig}; + + #[test] + fn every_builtin_builds_with_controls_and_renders() { + let s = Session::default(); + let l = list(&s); + assert_eq!(l.len(), BUILTINS.len()); + assert!(l.len() >= 8); + for t in &l { + assert!(t.builtin && !t.controls.is_empty(), "{t:?}"); + assert!(t.width > 0 && t.duration > 0.0, "{t:?}"); + let th = decode_thumb(&thumbnail(&s, &t.id).unwrap()).unwrap(); + // Not a blank frame: some variety in the pixels. + let distinct: std::collections::BTreeSet<[u8; 3]> = th.chunks_exact(3).map(|c| [c[0] / 16, c[1] / 16, c[2] / 16]).collect(); + assert!(distinct.len() > 3, "{}: thumbnail looks blank", t.id); + } + let sq = l.iter().find(|t| t.id == "social-square").unwrap(); + assert_eq!((sq.width, sq.height), (1080, 1080)); + let v = l.iter().find(|t| t.id == "vertical-story").unwrap(); + assert_eq!((v.width, v.height), (1080, 1920)); + assert_eq!(l.iter().find(|t| t.id == "lower-third").unwrap().controls, ["Name", "Title", "Bar Color"]); + } + + #[test] + fn create_opens_an_untitled_copy() { + let mut s = Session::default(); + let r = s.execute("templates.create", json!({"id": "title-card"})).unwrap(); + assert_eq!(r["template"], "Title Card"); + assert!(s.path.is_none()); + let cid = s.active_comp_id().unwrap(); + assert_eq!(Some(cid.0), r["comp"].as_u64()); + assert!(s.time() > Tick::ZERO); + // The Essential Graphics controls came along and drive the title. + let eg = s.execute("essential.list", json!({})).unwrap(); + assert_eq!(eg["controls"][0]["name"], "Title"); + assert!(s.execute("templates.create", json!({"id": "nope"})).is_err()); + assert!(s.execute("templates.create", json!({})).is_err()); + } + + fn user_roundtrip(mut s: Session) { + s.execute("templates.create", json!({"id": "kinetic-type"})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Mine", "color": "#ff00ff"})).unwrap(); + let r = s.execute("templates.saveAs", json!({"name": "My Promo", "description": "Mine", "category": "Work"})).unwrap(); + assert_eq!(r["id"], "user/My Promo.ectemplate"); + let l = list(&s); + let mine = l.iter().find(|t| !t.builtin).unwrap(); + assert_eq!((mine.name.as_str(), mine.category.as_str(), mine.description.as_str()), ("My Promo", "Work", "Mine")); + assert_eq!(mine.controls, ["Word 1", "Word 2", "Word 3"]); + assert!(decode_thumb(&thumbnail(&s, &mine.id).unwrap()).is_some()); + let j = s.execute("templates.list", json!({"thumbnails": true})).unwrap(); + assert!(j.as_array().unwrap().iter().all(|t| t["thumbnail"].is_string())); + // Overwrite protection. + assert!(s.execute("templates.saveAs", json!({"name": "My Promo", "overwrite": false})).is_err()); + // Open: an untitled copy with the added layer. + s.execute("file.newProject", json!({})).unwrap(); + s.execute("templates.create", json!({"id": mine.id})).unwrap(); + assert!(s.path.is_none()); + assert!(s.active_comp().unwrap().layers.iter().any(|l| l.name == "Mine")); + // Built-ins can't be deleted; user ones can. + assert!(s.execute("templates.delete", json!({"id": "title-card"})).is_err()); + s.execute("templates.delete", json!({"id": mine.id})).unwrap(); + assert!(list(&s).iter().all(|t| t.builtin)); + assert!(s.execute("templates.delete", json!({"id": "user/My Promo.ectemplate"})).is_err()); + assert!(s.execute("templates.create", json!({"id": "user/../x.ectemplate"})).is_err()); + } + + #[test] + fn user_templates_in_a_memory_store() { + let s = Session { config: Some(Arc::new(MemoryConfig::default())), ..Default::default() }; + user_roundtrip(s); + } + + #[test] + fn user_templates_in_the_config_folder() { + let dir = std::env::temp_dir().join(format!("ec-templates-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session { config: Some(Arc::new(DirConfig::new(&dir))), ..Default::default() }; + s.execute("templates.create", json!({"id": "lower-third"})).unwrap(); + s.execute("templates.saveAs", json!({"name": "On Disk"})).unwrap(); + let f = dir.join(TEMPLATES_DIR).join("On Disk.ectemplate"); + // A real template file: the Essential Graphics reader understands it too. + let info = s.execute("essential.templateInfo", json!({"path": f.to_string_lossy()})).unwrap(); + assert_eq!((info["kind"].as_str(), info["name"].as_str()), (Some("project"), Some("On Disk"))); + s.execute("templates.create", json!({"path": f.to_string_lossy()})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + let dir2 = std::env::temp_dir().join(format!("ec-templates-b-{}", std::process::id())); + let s = Session { config: Some(Arc::new(DirConfig::new(&dir2))), ..Default::default() }; + user_roundtrip(s); + let _ = std::fs::remove_dir_all(&dir2); + } + + /// Writes every built-in template's poster frame to target/test-out/templates for review. + #[test] + #[ignore] + fn dump_posters() { + let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/templates"); + std::fs::create_dir_all(&d).unwrap(); + for b in BUILTINS { + let p = build_builtin(b.id).unwrap(); + let cid = main_comp(&p).unwrap(); + let t = p.comp(cid).unwrap().poster_time; + let mut s = Session::default(); + s.replace_project(p, None); + let (w, h, rgba) = s.render_rgba8(cid, t, 640).unwrap(); + let png = crate::commands::comp_more::encode_png(&rgba, w, h).unwrap(); + std::fs::write(d.join(format!("{}.png", b.id)), png).unwrap(); + } + } + + #[test] + fn file_names_are_safe() { + assert_eq!(file_name_for("A/B: C"), "A_B_ C.ectemplate"); + assert_eq!(file_name_for(""), "Template.ectemplate"); + assert!(!file_name_for("...").starts_with('.')); + assert!(check_name("../x").is_err()); + } +} From 9e70cde72572c123ebe898822c4b75fb95ac8a7b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 17:46:41 -0400 Subject: [PATCH 2/3] M13.25: View > Simulate Output > My Custom RGB (primaries, white, gamma or an RGB ICC profile; kept in Settings; view.customRgb) and File menu entries for templates Co-Authored-By: Claude Opus 5.5 --- crates/color/src/icc.rs | 337 ++++++++++++++++++++++ crates/color/src/lib.rs | 1 + crates/engine/src/commands/viewer_cmds.rs | 89 +++++- crates/engine/src/menus.rs | 5 + crates/engine/src/prefs.rs | 3 + crates/engine/src/templates.rs | 3 + crates/engine/src/tests_color_view.rs | 65 +++++ crates/engine/src/viewer.rs | 128 +++++++- 8 files changed, 621 insertions(+), 10 deletions(-) create mode 100644 crates/color/src/icc.rs diff --git a/crates/color/src/icc.rs b/crates/color/src/icc.rs new file mode 100644 index 00000000..b740c123 --- /dev/null +++ b/crates/color/src/icc.rs @@ -0,0 +1,337 @@ +//! A reader for matrix/TRC RGB ICC profiles (ICC.1:2001-04 v2 and ICC.1:2010 v4, the public +//! International Color Consortium specification): the red/green/blue colorants (`rXYZ`, `gXYZ`, +//! `bXYZ`), the media white point (`wtpt`), the chromatic adaptation (`chad`), the tone curves +//! (`rTRC`, `gTRC`, `bTRC` as `curv` or `para`) and the description (`desc` / `mluc`). +//! +//! Used by View ▸ Simulate Output ▸ My Custom RGB: the profile becomes primaries, a white point +//! and a curve. LUT-based (`A2B0`) and non-RGB profiles are rejected. + +use crate::space::{bradford, invert, mul_vec}; + +/// CIE D50 (the ICC profile connection space white), xy. +pub const D50: [f64; 2] = [0.3457, 0.3585]; + +/// A tone curve of a profile. +#[derive(Clone, Debug, PartialEq)] +pub enum Trc { + /// `curv` with one entry, or `para` function type 0: y = x^g. + Gamma(f64), + /// `curv` table (0..=65535 samples over 0..1). + Table(Vec), + /// `para` function types 1–4: (type, parameters g a b c d e f). + Parametric(u16, [f64; 7]), +} + +impl Trc { + /// Encoded value (0..1) → linear. + pub fn eval(&self, x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + match self { + Trc::Gamma(g) => x.powf(*g), + Trc::Table(t) if t.is_empty() => x, + Trc::Table(t) => { + let f = x * (t.len() - 1) as f64; + let i = (f.floor() as usize).min(t.len() - 1); + let j = (i + 1).min(t.len() - 1); + let k = f - i as f64; + (t[i] as f64 * (1.0 - k) + t[j] as f64 * k) / 65535.0 + } + Trc::Parametric(ty, p) => { + let [g, a, b, c, d, e, f] = *p; + let pw = |v: f64| if v > 0.0 { v.powf(g) } else { 0.0 }; + match ty { + 1 => { + if x >= -b / a { + pw(a * x + b) + } else { + 0.0 + } + } + 2 => { + if x >= -b / a { + pw(a * x + b) + c + } else { + c + } + } + 3 => { + if x >= d { + pw(a * x + b) + } else { + c * x + } + } + _ => { + if x >= d { + pw(a * x + b) + e + } else { + c * x + f + } + } + } + } + } + } + + /// The single gamma that best matches the curve (least squares in log space over mid-tones). + pub fn fit_gamma(&self) -> f64 { + if let Trc::Gamma(g) = self { + return *g; + } + let (mut num, mut den) = (0.0, 0.0); + for i in 4..20 { + let x = i as f64 / 20.0; + let y = self.eval(x); + if y > 1e-6 && y < 1.0 { + num += x.ln() * y.ln(); + den += x.ln() * x.ln(); + } + } + if den > 0.0 { (num / den).clamp(0.1, 10.0) } else { 1.0 } + } + + /// Whether the curve is the sRGB (IEC 61966-2-1) piecewise curve, within 8-bit precision. + pub fn is_srgb(&self) -> bool { + let srgb = |x: f64| if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) }; + !matches!(self, Trc::Gamma(_)) && (0..=32).all(|i| (self.eval(i as f64 / 32.0) - srgb(i as f64 / 32.0)).abs() < 0.004) + } +} + +/// What a matrix/TRC RGB profile says. +#[derive(Clone, Debug, PartialEq)] +pub struct RgbProfile { + pub description: String, + /// Red, green, blue chromaticities (CIE xy) under the profile's own white. + pub primaries: [[f64; 2]; 3], + /// The white point (CIE xy) of the device. + pub white: [f64; 2], + /// Tone curves (red, green, blue). + pub trc: [Trc; 3], + /// ICC version major number. + pub version: u8, +} + +fn be32(b: &[u8], o: usize) -> Option { + Some(u32::from_be_bytes(b.get(o..o + 4)?.try_into().ok()?)) +} +fn be16(b: &[u8], o: usize) -> Option { + Some(u16::from_be_bytes(b.get(o..o + 2)?.try_into().ok()?)) +} +fn s15f16(b: &[u8], o: usize) -> Option { + Some(be32(b, o)? as i32 as f64 / 65536.0) +} + +fn xy(v: [f64; 3]) -> [f64; 2] { + let s = v[0] + v[1] + v[2]; + if s.abs() < 1e-12 { D50 } else { [v[0] / s, v[1] / s] } +} + +fn xyz_of(c: [f64; 2]) -> [f64; 3] { + [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]] +} + +/// Parse an RGB matrix/TRC ICC profile. +pub fn parse(b: &[u8]) -> Result { + if b.len() < 132 || b.get(36..40) != Some(b"acsp") { + return Err("not an ICC profile".into()); + } + if b.get(16..20) != Some(b"RGB ") { + return Err("not an RGB profile (only RGB display profiles can be simulated)".into()); + } + let version = b[8]; + let n = be32(b, 128).ok_or("truncated tag table")? as usize; + let mut tags = std::collections::HashMap::new(); + for i in 0..n.min(200) { + let o = 132 + i * 12; + let sig = b.get(o..o + 4).ok_or("truncated tag table")?; + let off = be32(b, o + 4).ok_or("truncated tag table")? as usize; + let size = be32(b, o + 8).ok_or("truncated tag table")? as usize; + if off.checked_add(size).is_some_and(|e| e <= b.len()) { + tags.insert(String::from_utf8_lossy(sig).into_owned(), &b[off..off + size]); + } + } + let xyz_tag = |name: &str| -> Result<[f64; 3], String> { + let t = tags.get(name).ok_or_else(|| format!("the profile has no {name} tag (only matrix/TRC profiles are supported)"))?; + if t.get(0..4) != Some(b"XYZ ") { + return Err(format!("{name}: not an XYZ tag")); + } + Ok([s15f16(t, 8).ok_or("bad XYZ")?, s15f16(t, 12).ok_or("bad XYZ")?, s15f16(t, 16).ok_or("bad XYZ")?]) + }; + let trc_tag = |name: &str| -> Result { + let t = tags.get(name).ok_or_else(|| format!("the profile has no {name} tag"))?; + match t.get(0..4) { + Some(b"curv") => { + let count = be32(t, 8).ok_or("bad curv")? as usize; + match count { + 0 => Ok(Trc::Gamma(1.0)), + 1 => Ok(Trc::Gamma(be16(t, 12).ok_or("bad curv")? as f64 / 256.0)), + _ => Ok(Trc::Table((0..count).map(|i| be16(t, 12 + i * 2)).collect::>>().ok_or("truncated curv")?)), + } + } + Some(b"para") => { + let ty = be16(t, 8).ok_or("bad para")?; + let np = [1, 3, 4, 5, 7].get(ty as usize).copied().ok_or("unknown para function")?; + let mut p = [0.0; 7]; + for (k, v) in p.iter_mut().enumerate().take(np) { + *v = s15f16(t, 12 + k * 4).ok_or("truncated para")?; + } + Ok(if ty == 0 { Trc::Gamma(p[0]) } else { Trc::Parametric(ty, p) }) + } + _ => Err(format!("{name}: unsupported curve type")), + } + }; + let col = [xyz_tag("rXYZ")?, xyz_tag("gXYZ")?, xyz_tag("bXYZ")?]; + // The device white: from the chromatic adaptation (v4, and many v2 profiles), else the media + // white point (v2 profiles that keep it un-adapted), else D50. + let d50 = xyz_of(D50); + let chad = tags.get("chad").filter(|t| t.get(0..4) == Some(b"sf32") && t.len() >= 44).map(|t| { + let v: Vec = (0..9).map(|i| s15f16(t, 8 + i * 4).unwrap_or(0.0)).collect(); + [[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]] + }); + let white = match (chad, xyz_tag("wtpt").ok()) { + (Some(m), _) => xy(mul_vec(&invert(&m), d50)), + (None, Some(w)) => xy(w), + _ => D50, + }; + // Colorants are adapted to D50 in the profile: undo that to get the device primaries. + let undo = match chad { + Some(m) => invert(&m), + None => bradford(D50, white), + }; + let primaries = col.map(|c| xy(mul_vec(&undo, c))); + let trc = [trc_tag("rTRC")?, trc_tag("gTRC")?, trc_tag("bTRC")?]; + Ok(RgbProfile { description: description(tags.get("desc").copied()).unwrap_or_default(), primaries, white, trc, version }) +} + +fn description(t: Option<&[u8]>) -> Option { + let t = t?; + match t.get(0..4)? { + b"desc" => { + let n = be32(t, 8)? as usize; + let s = t.get(12..12 + n)?; + Some(String::from_utf8_lossy(s).trim_end_matches('\0').to_string()) + } + b"mluc" => { + // The first record: UTF-16BE. + let len = be32(t, 20)? as usize; + let off = be32(t, 24)? as usize; + let u: Vec = t.get(off..off + len)?.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + Some(String::from_utf16_lossy(&u).trim_end_matches('\0').to_string()) + } + _ => None, + } +} + +/// Build a minimal v2 matrix/TRC RGB profile (tests, and saving a custom simulation): colorants +/// Bradford-adapted to D50, `wtpt` the device white, gamma curves. +pub fn write_matrix_profile(desc: &str, primaries: [[f64; 2]; 3], white: [f64; 2], gamma: f64) -> Vec { + let m = crate::space::rgb_to_xyz(primaries, white); + let a = bradford(white, D50); + let col = |j: usize| mul_vec(&a, [m[0][j], m[1][j], m[2][j]]); + let f = |v: f64| ((v * 65536.0).round() as i32).to_be_bytes(); + let xyz = |v: [f64; 3]| { + let mut t = b"XYZ \0\0\0\0".to_vec(); + for c in v { + t.extend(f(c)); + } + t + }; + let curv = { + let mut t = b"curv\0\0\0\0".to_vec(); + t.extend(1u32.to_be_bytes()); + t.extend(((gamma * 256.0).round() as u16).to_be_bytes()); + t.extend([0, 0]); + t + }; + let dsc = { + let mut t = b"desc\0\0\0\0".to_vec(); + t.extend((desc.len() as u32 + 1).to_be_bytes()); + t.extend(desc.as_bytes()); + t.push(0); + t.extend([0u8; 12 + 67]); + while !t.len().is_multiple_of(4) { + t.push(0); + } + t + }; + let w = xyz_of(white); + let tags: Vec<(&[u8; 4], Vec)> = vec![ + (b"desc", dsc), + (b"wtpt", xyz(w)), + (b"rXYZ", xyz(col(0))), + (b"gXYZ", xyz(col(1))), + (b"bXYZ", xyz(col(2))), + (b"rTRC", curv.clone()), + (b"gTRC", curv.clone()), + (b"bTRC", curv), + ]; + let mut table = vec![]; + let mut data = vec![]; + let base = 128 + 4 + tags.len() * 12; + for (sig, t) in &tags { + table.extend(*sig); + table.extend(((base + data.len()) as u32).to_be_bytes()); + table.extend((t.len() as u32).to_be_bytes()); + data.extend(t); + while !data.len().is_multiple_of(4) { + data.push(0); + } + } + let total = base + data.len(); + let mut h = vec![0u8; 128]; + h[0..4].copy_from_slice(&(total as u32).to_be_bytes()); + h[8] = 2; + h[9] = 0x10; + h[12..16].copy_from_slice(b"mntr"); + h[16..20].copy_from_slice(b"RGB "); + h[20..24].copy_from_slice(b"XYZ "); + h[36..40].copy_from_slice(b"acsp"); + for (k, v) in xyz_of(D50).into_iter().enumerate() { + h[68 + k * 4..72 + k * 4].copy_from_slice(&f(v)); + } + let mut out = h; + out.extend((tags.len() as u32).to_be_bytes()); + out.extend(table); + out.extend(data); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const P709: [[f64; 2]; 3] = [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]]; + const D65: [f64; 2] = [0.3127, 0.3290]; + + #[test] + fn matrix_profile_round_trips() { + let b = write_matrix_profile("Test RGB", P709, D65, 2.2); + let p = parse(&b).unwrap(); + assert_eq!(p.description, "Test RGB"); + assert_eq!(p.version, 2); + for (a, e) in p.primaries.iter().zip(P709) { + assert!((a[0] - e[0]).abs() < 2e-4 && (a[1] - e[1]).abs() < 2e-4, "{a:?} vs {e:?}"); + } + assert!((p.white[0] - D65[0]).abs() < 2e-4 && (p.white[1] - D65[1]).abs() < 2e-4, "{:?}", p.white); + assert!((p.trc[0].fit_gamma() - 2.2).abs() < 0.01); + assert!(!p.trc[0].is_srgb()); + } + + #[test] + fn curves() { + let srgb = Trc::Parametric(3, [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0]); + assert!(srgb.is_srgb()); + assert!((srgb.fit_gamma() - 2.2).abs() < 0.15, "{}", srgb.fit_gamma()); + let table = Trc::Table((0..256).map(|i| ((i as f64 / 255.0).powf(1.8) * 65535.0).round() as u16).collect()); + assert!((table.fit_gamma() - 1.8).abs() < 0.02); + assert!((Trc::Gamma(2.0).eval(0.5) - 0.25).abs() < 1e-12); + } + + #[test] + fn rejects_other_files() { + assert!(parse(b"hello").is_err()); + let mut b = write_matrix_profile("x", P709, D65, 2.2); + b[16..20].copy_from_slice(b"CMYK"); + assert!(parse(&b).unwrap_err().contains("RGB")); + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 9212b020..fd94e5a8 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -6,6 +6,7 @@ //! colour and composited with Porter-Duff source-over on premultiplied pixels. pub mod blend; +pub mod icc; pub mod space; pub use blend::{BlendMode, blend_pixel}; diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index b8b36f15..30dc6e4a 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p}; -use crate::viewer::{Channel, SimProfile, Simulation, Snapshot}; +use crate::viewer::{Channel, CustomRgb, SimProfile, Simulation, Snapshot}; use crate::{EngineError, Result, Session, VertexRef, cmd, query}; /// Composition ▸ Preview ▸ Fast Previews modes. @@ -345,6 +345,81 @@ fn simulate_output(s: &mut Session, p: &Value) -> Result { Ok(json!({"profile": sim.profile.id(), "label": sim.profile.label(), "preserveRgb": sim.preserve_rgb})) } +fn custom_rgb_json(c: &CustomRgb) -> Value { + serde_json::to_value(c).unwrap_or_default() +} + +/// View ▸ Simulate Output ▸ My Custom RGB…: define the custom output device (kept in Settings) +/// and, unless `apply` is false, simulate it. An `icc` path that differs from the one the +/// definition came from is read (its primaries, white and curve replace the numbers); `from` +/// starts from a built-in profile; `reset` goes back to the default. +fn custom_rgb(s: &mut Session, p: &Value) -> Result { + let cmd = "view.customRgb"; + let mut c = if b_p(p, "reset") == Some(true) { CustomRgb::default() } else { s.prefs.custom_rgb.clone() }; + if let Some(from) = str_p(p, "from") { + let prof = SimProfile::parse(from).ok_or_else(|| bad(cmd, format!("from: unknown profile `{from}`")))?; + if let Some((prims, curve)) = prof.space() { + [c.red, c.green, c.blue] = prims; + c.white = [0.3127, 0.3290]; + (c.gamma, c.srgb_curve) = match curve { + crate::viewer::Curve::Srgb => (2.2, true), + crate::viewer::Curve::Gamma(g) => ((g as f64 * 1000.0).round() / 1000.0, false), + _ => (1.0, false), + }; + c.icc.clear(); + c.name = format!("Custom ({})", prof.label()); + } + } + let pt = |k: &str| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, format!("{k}: [x, y]")))?; + let g = |i: usize| a[i].as_f64().ok_or_else(|| bad(cmd, format!("{k}: [x, y] numbers"))); + Ok(Some([g(0)?, g(1)?])) + } + } + }; + for (k, slot) in [("red", &mut c.red), ("green", &mut c.green), ("blue", &mut c.blue), ("white", &mut c.white)] { + if let Some(v) = pt(k)? { + *slot = v; + } + } + if let Some(g) = f_p(p, "gamma") { + c.gamma = g; + } + if let Some(b) = b_p(p, "srgbCurve") { + c.srgb_curve = b; + } + if let Some(n) = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + c.name = n.to_string(); + } + match str_p(p, "icc").map(str::trim) { + Some("") => c.icc.clear(), + Some(path) if path != c.icc || b_p(p, "reload") == Some(true) => { + let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; + let mut from = CustomRgb::from_icc(&bytes, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?; + if let Some(n) = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty() && *n != s.prefs.custom_rgb.name) { + from.name = n.to_string(); + } + c = from; + } + _ => {} + } + c.validate().map_err(|e| bad(cmd, e))?; + s.prefs.custom_rgb = c.clone(); + s.prefs_revision += 1; + s.save_prefs(); + if b_p(p, "apply") != Some(false) { + let preserve = b_p(p, "preserveRgb").unwrap_or(false); + s.state.viewer.simulation = Simulation { profile: SimProfile::MyCustom, preserve_rgb: preserve }; + } + s.events.push(crate::Event::ProjectChanged { revision: s.revision }); + let mut out = custom_rgb_json(&c); + out["active"] = json!(s.state.viewer.simulation.profile == SimProfile::MyCustom); + Ok(out) +} + /// View ▸ Split with New Locked Viewer: lock a second viewer to the comp (default the active /// one) and its current 3D view (or `view`). fn split_locked(s: &mut Session, p: &Value) -> Result { @@ -374,6 +449,7 @@ fn display_color_state(s: &mut Session, _: &Value) -> Result { Ok(json!({ "displayColorManagement": v.display_color_management, "simulation": {"profile": v.simulation.profile.id(), "preserveRgb": v.simulation.preserve_rgb}, + "customRgb": custom_rgb_json(&s.prefs.custom_rgb), "display": s.prefs.previews.display_profile, "workingSpace": s.project.settings.working_space.map(|w| w.id()), "active": crate::viewer::DisplayColor::of(s).is_some(), @@ -390,10 +466,19 @@ pub fn specs() -> Vec { "Simulate Output", [], None, - "{profile: none|rec709|ntsc|pal|mac18|srgb|rec2020|p3|linear|custom, preserveRgb?, space? (custom)}", + "{profile: none|rec709|ntsc|pal|mac18|srgb|rec2020|p3|linear|myCustom|custom, preserveRgb?, space? (custom)}", always, simulate_output ), + cmd!( + "view.customRgb", + "My Custom RGB...", + [], + None, + "{name?, red?/green?/blue?/white?: [x, y] (CIE xy), gamma?, srgbCurve?: bool, icc?: path (RGB ICC profile; read when it changes, \"\" clears), reload?, from?: a Simulate Output profile to start from, reset?, preserveRgb?, apply?: bool (default true: simulate it)} → the definition (kept in Settings)", + always, + custom_rgb + ), cmd!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], None, "{comp?, view?: 3D view id}", has_comp, split_locked), cmd!("view.closeLockedViewer", "Close Locked Viewer", [], None, "{}", has_locked, close_locked), query!( diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index b991be9b..0cfb9227 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -317,6 +317,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { "view.extendedViewer" => Some(s.prefs.three_d.extended_viewer), "view.snapping" => Some(s.state.snapping), "view.displayColorManagement" => Some(s.state.viewer.display_color_management), + "view.customRgb" => Some(s.state.viewer.simulation.profile == crate::viewer::SimProfile::MyCustom), "view.simulateOutput" => { let want = pstr("profile")?; let sim = s.state.viewer.simulation; @@ -482,6 +483,7 @@ pub const TREE: &str = r#" File New New Project | file.newProject + New Project from Template... | file.newFromTemplate New Folder | project.newFolder Open Project... | file.open Open Recent @@ -496,6 +498,7 @@ File Save As... | file.saveAs Save a Copy... | file.saveCopy Save a Copy As XML... | file.saveCopyAsXml + Save as Template... | templates.saveAs Increment and Save | file.incrementAndSave Revert | file.revert --- @@ -1049,6 +1052,8 @@ View UHDTV (Rec. 2020) | view.simulateOutput {"profile":"rec2020"} Display P3 | view.simulateOutput {"profile":"p3"} Linear (1.0 Gamma) | view.simulateOutput {"profile":"linear"} + --- + My Custom RGB... | view.customRgb Custom... | view.simulateOutput {"profile":"custom"} --- Show Rulers | view.rulers diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 8e73c0ba..9524924a 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -283,6 +283,8 @@ pub struct Prefs { pub recent_fonts: Vec, /// Preview panel: every shortcut's options. pub preview: crate::preview::PreviewSettings, + /// View ▸ Simulate Output ▸ My Custom RGB. + pub custom_rgb: crate::viewer::CustomRgb, /// Top-level keys this version doesn't know (kept for newer versions). #[serde(flatten)] pub extra: Extra, @@ -315,6 +317,7 @@ impl Default for Prefs { recent_presets: vec![], recent_fonts: vec![], preview: crate::preview::PreviewSettings::default(), + custom_rgb: crate::viewer::CustomRgb::default(), extra: Extra::new(), } } diff --git a/crates/engine/src/templates.rs b/crates/engine/src/templates.rs index 3800b235..3829ec39 100644 --- a/crates/engine/src/templates.rs +++ b/crates/engine/src/templates.rs @@ -746,6 +746,9 @@ pub fn specs() -> Vec { save_as ), cmd!("templates.delete", "Delete Template", [], None, "{id: user/}", always, delete), + cmd!("file.newFromTemplate", "New Project from Template...", ["File", "New"], None, "{} → the Home screen's Templates tab", always, |s, p| { + crate::commands::frontend(s, "file.newFromTemplate", p) + }), ] } diff --git a/crates/engine/src/tests_color_view.rs b/crates/engine/src/tests_color_view.rs index c0f83d66..7d42f739 100644 --- a/crates/engine/src/tests_color_view.rs +++ b/crates/engine/src/tests_color_view.rs @@ -221,3 +221,68 @@ fn feet_and_frames_display_and_entry() { assert_eq!(s.project.settings.time_display, TimeDisplayStyle::Frames); assert_eq!(serde_json::to_value(TimeDisplayStyle::Feet16).unwrap(), json!("Feet16")); } + +/// View ▸ Simulate Output ▸ My Custom RGB…: typed-in primaries/white/gamma or an ICC profile, +/// kept in Settings. +#[test] +fn my_custom_rgb_simulation() { + let mut s = session(); + s.config = Some(std::sync::Arc::new(crate::config::MemoryConfig::default())); + s.execute("file.projectSettings", json!({"workingSpace": "srgb"})).unwrap(); + // The default definition: Rec. 709 primaries, D65, gamma 2.2. + let d = crate::viewer::CustomRgb::default(); + assert_eq!((d.white, d.gamma), ([0.3127, 0.3290], 2.2)); + // Rec. 709 / D65 / gamma 1.8 behaves exactly like Legacy Macintosh RGB. + s.execute("view.simulateOutput", json!({"profile": "mac18", "preserveRgb": true})).unwrap(); + let mut a = px(128, 90, 200); + DisplayColor::of(&s).unwrap().apply(&mut a); + let r = s + .execute( + "view.customRgb", + json!({"name": "Old Mac", "red": [0.64, 0.33], "green": [0.30, 0.60], "blue": [0.15, 0.06], "white": [0.3127, 0.3290], "gamma": 1.8, "srgbCurve": false, "preserveRgb": true}), + ) + .unwrap(); + assert_eq!((r["name"].as_str(), r["active"].as_bool()), (Some("Old Mac"), Some(true))); + assert_eq!(s.state.viewer.simulation, Simulation { profile: SimProfile::MyCustom, preserve_rgb: true }); + let mut b = px(128, 90, 200); + DisplayColor::of(&s).unwrap().apply(&mut b); + assert_eq!(a, b); + assert_eq!(crate::menus::checked(&s, "view.customRgb", &json!({})), Some(true)); + assert_eq!(crate::menus::checked(&s, "view.simulateOutput", &json!({"profile": "myCustom"})), Some(true)); + // Persisted in Settings. + let saved = s.config.as_ref().unwrap().read(crate::prefs::PREFS_FILE).unwrap(); + assert!(saved.contains("\"customRgb\"") && saved.contains("Old Mac"), "{saved}"); + // Start from a built-in profile. + s.execute("view.customRgb", json!({"from": "rec2020", "preserveRgb": false})).unwrap(); + assert_eq!(s.prefs.custom_rgb.red, [0.708, 0.292]); + // An ICC profile: the primaries, white and curve come from the file. + let p3 = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]]; + let icc = effectcraft_color::icc::write_matrix_profile("Studio Monitor", p3, [0.3127, 0.3290], 2.4); + let path = std::env::temp_dir().join(format!("ec-custom-{}.icc", std::process::id())); + std::fs::write(&path, icc).unwrap(); + let r = s.execute("view.customRgb", json!({"icc": path.to_string_lossy()})).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(r["name"], "Studio Monitor"); + let c = s.prefs.custom_rgb.clone(); + assert!((c.green[0] - 0.265).abs() < 1e-3 && (c.green[1] - 0.690).abs() < 1e-3, "{c:?}"); + assert!((c.gamma - 2.4).abs() < 0.01 && !c.srgb_curve); + assert!(DisplayColor::of(&s).is_some()); + // The same path again keeps typed-in edits; an empty path forgets the profile. + s.execute("view.customRgb", json!({"icc": c.icc, "gamma": 2.0})).unwrap(); + assert_eq!(s.prefs.custom_rgb.gamma, 2.0); + s.execute("view.customRgb", json!({"icc": ""})).unwrap(); + assert!(s.prefs.custom_rgb.icc.is_empty()); + // Bad definitions are refused and change nothing. + let before = s.prefs.custom_rgb.clone(); + assert!(s.execute("view.customRgb", json!({"red": [0.3, 0.6], "green": [0.3, 0.6]})).is_err()); + assert!(s.execute("view.customRgb", json!({"gamma": 0.0})).is_err()); + assert!(s.execute("view.customRgb", json!({"white": [1.2, 0.3]})).is_err()); + assert!(s.execute("view.customRgb", json!({"icc": "/no/such/profile.icc"})).is_err()); + assert_eq!(s.prefs.custom_rgb, before); + // Define without simulating it. + s.execute("view.simulateOutput", json!({"profile": "none"})).unwrap(); + s.execute("view.customRgb", json!({"reset": true, "apply": false})).unwrap(); + assert_eq!(s.state.viewer.simulation.profile, SimProfile::None); + assert_eq!(s.prefs.custom_rgb, crate::viewer::CustomRgb::default()); + assert_eq!(s.execute("view.displayColor", json!({})).unwrap()["customRgb"]["gamma"], json!(2.2)); +} diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index bba10122..22385995 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -383,6 +383,8 @@ pub enum SimProfile { Pal, /// Legacy Macintosh RGB: Rec. 709 primaries, gamma 1.8. Mac18, + /// My Custom RGB: the user's own device ([`CustomRgb`], in Settings). + MyCustom, } /// A transfer curve of a simulated or display profile. @@ -421,7 +423,7 @@ impl Curve { const P709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]]; impl SimProfile { - pub const ALL: [SimProfile; 9] = [ + pub const ALL: [SimProfile; 10] = [ SimProfile::None, SimProfile::Rec709, SimProfile::Ntsc, @@ -431,6 +433,7 @@ impl SimProfile { SimProfile::Rec2020, SimProfile::P3, SimProfile::Linear, + SimProfile::MyCustom, ]; pub fn id(self) -> &'static str { @@ -444,6 +447,7 @@ impl SimProfile { SimProfile::Ntsc => "ntsc", SimProfile::Pal => "pal", SimProfile::Mac18 => "mac18", + SimProfile::MyCustom => "myCustom", } } @@ -458,6 +462,7 @@ impl SimProfile { SimProfile::Ntsc => "SDTV NTSC", SimProfile::Pal => "SDTV PAL", SimProfile::Mac18 => "Legacy Macintosh RGB (Gamma 1.8)", + SimProfile::MyCustom => "My Custom RGB", } } @@ -465,7 +470,7 @@ impl SimProfile { let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); SimProfile::ALL .into_iter() - .find(|p| p.id() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase() == k) + .find(|p| p.id().to_ascii_lowercase() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase() == k) .or(match k.as_str() { "off" => Some(SimProfile::None), "hdtv" | "bt709" => Some(SimProfile::Rec709), @@ -476,10 +481,10 @@ impl SimProfile { }) } - /// (primaries, curve); `None` for No Output Simulation. + /// (primaries, curve); `None` for No Output Simulation and My Custom RGB (see [`CustomRgb`]). pub fn space(self) -> Option<([[f64; 2]; 3], Curve)> { Some(match self { - SimProfile::None => return None, + SimProfile::None | SimProfile::MyCustom => return None, SimProfile::Rec709 => (P709, Curve::Gamma(2.4)), SimProfile::Rec2020 => (ColorSpace::Rec2020.primaries(), Curve::Gamma(2.4)), SimProfile::P3 => (ColorSpace::DisplayP3.primaries(), Curve::Srgb), @@ -501,6 +506,93 @@ pub struct Simulation { pub preserve_rgb: bool, } +/// View ▸ Simulate Output ▸ My Custom RGB…: a user-defined output device, kept in Settings +/// (`customRgb`). Defined by primaries and white point (CIE xy) and a gamma or the sRGB curve, +/// typed in or read from an RGB ICC profile. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CustomRgb { + pub name: String, + pub red: [f64; 2], + pub green: [f64; 2], + pub blue: [f64; 2], + pub white: [f64; 2], + pub gamma: f64, + /// The sRGB piecewise curve instead of a pure power curve. + pub srgb_curve: bool, + /// The ICC profile the numbers were read from (empty: typed in). + pub icc: String, +} + +impl Default for CustomRgb { + fn default() -> Self { + CustomRgb { name: "My Custom RGB".into(), red: P709[0], green: P709[1], blue: P709[2], white: D65, gamma: 2.2, srgb_curve: false, icc: String::new() } + } +} + +impl CustomRgb { + pub fn primaries(&self) -> [[f64; 2]; 3] { + [self.red, self.green, self.blue] + } + + pub fn curve(&self) -> Curve { + if self.srgb_curve { + Curve::Srgb + } else if (self.gamma - 1.0).abs() < 1e-9 { + Curve::Linear + } else { + Curve::Gamma(self.gamma as f32) + } + } + + /// Chromaticities inside (0, 1) with y > 0, a gamma in 0.1–10, and primaries that span a + /// triangle. + pub fn validate(&self) -> std::result::Result<(), String> { + for (n, c) in [("red", self.red), ("green", self.green), ("blue", self.blue), ("white", self.white)] { + if !(c[0] > 0.0 && c[1] > 0.0 && c[0] < 1.0 && c[1] < 1.0 && c[0] + c[1] <= 1.0) { + return Err(format!("{n}: chromaticity x, y must be in (0, 1) with x + y ≤ 1")); + } + } + if !self.srgb_curve && !(0.1..=10.0).contains(&self.gamma) { + return Err("gamma must be between 0.1 and 10".into()); + } + let [r, g, b] = self.primaries(); + let area = (g[0] - r[0]) * (b[1] - r[1]) - (b[0] - r[0]) * (g[1] - r[1]); + if area.abs() < 1e-6 { + return Err("the primaries must form a triangle".into()); + } + Ok(()) + } + + /// Take primaries, white and curve from an RGB ICC profile. + pub fn from_icc(bytes: &[u8], path: &str) -> std::result::Result { + let p = effectcraft_color::icc::parse(bytes)?; + let name = if p.description.trim().is_empty() { + std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "ICC Profile".into()) + } else { + p.description.trim().to_string() + }; + let gamma = (p.trc.iter().map(|t| t.fit_gamma()).sum::() / 3.0 * 1000.0).round() / 1000.0; + let r = |c: [f64; 2]| c.map(|v| (v * 1e5).round() / 1e5); + Ok(CustomRgb { + name, + red: r(p.primaries[0]), + green: r(p.primaries[1]), + blue: r(p.primaries[2]), + white: r(p.white), + gamma, + srgb_curve: p.trc[0].is_srgb(), + icc: path.to_string(), + }) + } + + fn prof(&self) -> Prof { + let m = space::rgb_to_xyz(self.primaries(), self.white); + let xyz = if self.white == D65 { m } else { space::mul(&space::bradford(self.white, D65), &m) }; + Prof { xyz, curve: self.curve() } + } +} + /// An RGB space for viewer conversions: primaries (D65) and curve. #[derive(Clone, Copy, Debug, PartialEq)] struct Prof { @@ -562,7 +654,16 @@ impl DisplayColor { /// After Effects); `linear`: the working space is linear; `source`: the space rendered /// frames are in (the project's output space, sRGB by default); `dcm`: Use Display Color /// Management; `display`: the monitor's space. - pub fn new(working: Option, linear: bool, source: ColorSpace, dcm: bool, display: ColorSpace, sim: Simulation) -> Option { + #[allow(clippy::too_many_arguments)] + pub fn new( + working: Option, + linear: bool, + source: ColorSpace, + dcm: bool, + display: ColorSpace, + sim: Simulation, + custom: &CustomRgb, + ) -> Option { let ws = working?; let src = Prof::of(source, false); if !dcm { @@ -570,8 +671,11 @@ impl DisplayColor { let raw = Conv::new(src, Prof::of(ws, linear)); return Some(DisplayColor { sim: None, display: Some(raw) }); } - let sim = sim.profile.space().map(|(prims, curve)| { - let out = Prof::new(prims, curve); + let out = match sim.profile { + SimProfile::MyCustom => Some(custom.prof()), + p => p.space().map(|(prims, curve)| Prof::new(prims, curve)), + }; + let sim = out.map(|out| { ( Conv::new(src, out), Some(if sim.preserve_rgb { @@ -591,7 +695,15 @@ impl DisplayColor { let display = ColorSpace::parse(&s.prefs.previews.display_profile).unwrap_or(ColorSpace::Srgb); let v = &s.state.viewer; let linear = st.working_space.is_some_and(|w| st.linearize || w.is_linear()); - DisplayColor::new(st.working_space, linear, st.output_space.unwrap_or(ColorSpace::Srgb), v.display_color_management, display, v.simulation) + DisplayColor::new( + st.working_space, + linear, + st.output_space.unwrap_or(ColorSpace::Srgb), + v.display_color_management, + display, + v.simulation, + &s.prefs.custom_rgb, + ) } /// One straight colour (0..1 floats). From 3881dbb32e320a59bc29dfe3ddcd5bef9bd8f1a8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 18:14:17 -0400 Subject: [PATCH 3/3] M13.25: Home Templates gallery (thumbnails, open, delete, Save Current as Template), Save as Template and My Custom RGB dialogs, form automation ids; parity update Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/templates.rs | 6 +- crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/menus.rs | 8 + crates/ui-egui/src/panels/forms.rs | 119 ++++++++-- crates/ui-egui/src/panels/home.rs | 27 ++- crates/ui-egui/src/panels/learn.rs | 1 + crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/templates.rs | 269 ++++++++++++++++++++++ crates/ui-egui/src/panels/viewer_tools.rs | 3 +- crates/ui-egui/src/state.rs | 4 + docs/parity.md | 27 ++- 12 files changed, 446 insertions(+), 29 deletions(-) create mode 100644 crates/ui-egui/src/panels/templates.rs diff --git a/crates/engine/src/templates.rs b/crates/engine/src/templates.rs index 3829ec39..afa9b146 100644 --- a/crates/engine/src/templates.rs +++ b/crates/engine/src/templates.rs @@ -32,8 +32,8 @@ pub const TEMPLATES_DIR: &str = "Templates"; /// Template file extension. pub const EXT: &str = "ectemplate"; /// Gallery thumbnail size (RGB, letterboxed). -pub const THUMB_W: usize = 160; -pub const THUMB_H: usize = 90; +pub const THUMB_W: usize = 256; +pub const THUMB_H: usize = 144; /// One gallery entry. #[derive(Clone, Debug, PartialEq, Serialize)] @@ -731,7 +731,7 @@ pub fn specs() -> Vec { query!( "templates.list", "Project Templates", - "{thumbnails?: bool} → [{id, name, description, category, builtin, width, height, frameRate, duration, controls, thumbnail? (hex RGB 160×90)}]", + "{thumbnails?: bool} → [{id, name, description, category, builtin, width, height, frameRate, duration, controls, thumbnail? (hex RGB 256×144)}]", list_cmd ), query!("templates.thumbnail", "Template Thumbnail", "{id} → {id, width, height, rgb (hex)}", thumb_cmd), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 98f18e20..b2beac44 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -285,9 +285,10 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques if let Some(b) = p.get("home").and_then(Value::as_bool) { app.ui.start_screen = b; } - // Home screen tab: "home" or "learn". + // Home screen tab: "home", "templates" or "learn". if let Some(tab) = p.get("homeTab").and_then(Value::as_str) { app.ui.home_learn = tab == "learn"; + app.ui.home_templates = tab == "templates"; } ok(Value::Null) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c9fd5707..3060eabe 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -192,6 +192,10 @@ pub struct EffectcraftApp { pub(crate) home_thumbs: std::collections::HashMap>, /// The (project path, saved revision) whose thumbnail was stored last. pub(crate) home_thumb_saved: Option<(String, u64)>, + /// Home ▸ Templates: thumbnail textures by template id (None = not renderable). + pub(crate) template_thumbs: std::collections::HashMap>, + /// Home ▸ Templates: the gallery, and the user template files it was listed from. + pub(crate) template_list: Option<(Vec, Vec)>, } impl EffectcraftApp { @@ -240,6 +244,8 @@ impl EffectcraftApp { dock_rects: vec![], home_thumbs: Default::default(), home_thumb_saved: None, + template_thumbs: Default::default(), + template_list: None, } .with_ui_commands() } @@ -311,6 +317,7 @@ impl EffectcraftApp { if name == "Learn" { self.ui.start_screen = true; self.ui.home_learn = true; + self.ui.home_templates = false; } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5a8f328d..15b05401 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -880,6 +880,14 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu "help.inAppTutorials" => { app.ui.start_screen = true; app.ui.home_learn = true; + app.ui.home_templates = false; + Value::Null + } + // File ▸ New ▸ New Project from Template…: the Home screen's Templates tab. + "file.newFromTemplate" => { + app.ui.start_screen = true; + app.ui.home_learn = false; + app.ui.home_templates = true; Value::Null } "effect.manage" | "anim.browsePresets" => { diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index bc780095..896e6613 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -17,6 +17,11 @@ pub enum FieldKind { speed: f64, }, Text(String), + /// A file path with a Browse… button (the host's open dialog, these extensions). + Path { + value: String, + exts: Vec, + }, Bool(bool), /// Options as (label, value); `sel` is the chosen index. Choice { @@ -40,6 +45,9 @@ impl Field { pub fn text(key: &str, label: &str, value: &str) -> Field { Field { key: key.into(), label: label.into(), kind: FieldKind::Text(value.into()) } } + pub fn path(key: &str, label: &str, value: &str, exts: &[&str]) -> Field { + Field { key: key.into(), label: label.into(), kind: FieldKind::Path { value: value.into(), exts: exts.iter().map(|e| e.to_string()).collect() } } + } pub fn bool(key: &str, label: &str, value: bool) -> Field { Field { key: key.into(), label: label.into(), kind: FieldKind::Bool(value) } } @@ -67,7 +75,7 @@ impl Form { for f in &self.fields { let v = match &f.kind { FieldKind::Number { value, .. } => json!(value), - FieldKind::Text(s) => json!(s), + FieldKind::Text(s) | FieldKind::Path { value: s, .. } => json!(s), FieldKind::Bool(b) => json!(b), FieldKind::Choice { options, sel } => options.get(*sel).map(|o| o.1.clone()).unwrap_or(Value::Null), }; @@ -513,6 +521,46 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], ) } + // File ▸ Save as Template… + "templates.saveAs" if !has(p, &["name"]) => { + let name = s + .path + .as_deref() + .and_then(|p| std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string())) + .or_else(|| s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone())) + .unwrap_or_else(|| "My Template".into()); + ( + "Save as Template".into(), + vec![ + Field::text("name", "Template name", &name), + Field::text("description", "Description", ""), + Field::text("category", "Category", "My Templates"), + ], + ) + } + // View ▸ Simulate Output ▸ My Custom RGB… + "view.customRgb" if p.as_object().is_none_or(|m| m.is_empty()) => { + let c = &s.prefs.custom_rgb; + let num = |k: &str, l: &str, v: f64| Field { key: k.into(), label: l.into(), kind: FieldKind::Number { value: v, speed: 0.001 } }; + ( + "My Custom RGB".into(), + vec![ + Field::text("name", "Name", &c.name), + num("red[0]", "Red x", c.red[0]), + num("red[1]", "Red y", c.red[1]), + num("green[0]", "Green x", c.green[0]), + num("green[1]", "Green y", c.green[1]), + num("blue[0]", "Blue x", c.blue[0]), + num("blue[1]", "Blue y", c.blue[1]), + num("white[0]", "White point x", c.white[0]), + num("white[1]", "White point y", c.white[1]), + num("gamma", "Gamma", c.gamma), + Field::bool("srgbCurve", "Use the sRGB curve (ignore Gamma)", c.srgb_curve), + Field::path("icc", "ICC profile (replaces the numbers)", &c.icc, &["icc", "icm"]), + Field::bool("preserveRgb", "Preserve RGB", s.state.viewer.simulation.preserve_rgb), + ], + ) + } _ => return false, }; form(app, &title, id, base, fields); @@ -523,42 +571,79 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut f = app.dialog_state.form.clone(); let (mut ok, mut close) = (false, false); let h = 120.0 + f.fields.len() as f32 * 30.0; + let mut regs: Vec<(String, egui::Rect, String)> = vec![]; + let mut browse: Option = None; super::dialogs::modal(ctx, &f.title.clone(), vec2(440.0, h), t, |ui| { egui::Grid::new("form-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| { - for fl in &mut f.fields { + for (i, fl) in f.fields.iter_mut().enumerate() { ui.label(&fl.label); - match &mut fl.kind { + let r = match &mut fl.kind { FieldKind::Number { value, speed } => { - ui.add(egui::DragValue::new(value).speed(*speed).max_decimals(3)); + let dec = if *speed < 0.01 { 5 } else { 3 }; + ui.add(egui::DragValue::new(value).speed(*speed).max_decimals(dec)).rect } - FieldKind::Text(s) => { - ui.add(egui::TextEdit::singleline(s).desired_width(220.0)); - } - FieldKind::Bool(b) => { - ui.checkbox(b, ""); + FieldKind::Text(s) => ui.add(egui::TextEdit::singleline(s).desired_width(220.0)).rect, + FieldKind::Path { value, .. } => { + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(value).desired_width(150.0)).rect; + let b = ui.button("Browse…"); + regs.push((format!("form.field.{}.browse", fl.key), b.rect, "Browse…".into())); + if b.clicked() { + browse = Some(i); + } + r + }) + .inner } + FieldKind::Bool(b) => ui.checkbox(b, "").rect, FieldKind::Choice { options, sel } => { let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default(); - egui::ComboBox::from_id_salt(("form", fl.key.as_str())).selected_text(cur).width(240.0).show_ui(ui, |ui| { - for (i, (l, _)) in options.iter().enumerate() { - ui.selectable_value(sel, i, l); - } - }); + egui::ComboBox::from_id_salt(("form", fl.key.as_str())) + .selected_text(cur) + .width(240.0) + .show_ui(ui, |ui| { + for (i, (l, _)) in options.iter().enumerate() { + ui.selectable_value(sel, i, l); + } + }) + .response + .rect } - } + }; + regs.push((format!("form.field.{}", fl.key), r, fl.label.clone())); ui.end_row(); } }); ui.add_space(14.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() { + let b = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)); + regs.push(("form.ok".into(), b.rect, "OK".into())); + if b.clicked() { ok = true; } - if ui.button("Cancel").clicked() { + let c = ui.button("Cancel"); + regs.push(("form.cancel".into(), c.rect, "Cancel".into())); + if c.clicked() { close = true; } }); }); + for (id, r, l) in regs { + app.auto.add(&id, r, &l); + } + if let Some(i) = browse + && let Some(FieldKind::Path { value, exts }) = f.fields.get_mut(i).map(|f| &mut f.kind) + { + let exts: Vec<&str> = exts.iter().map(String::as_str).collect(); + match app.hooks.pick_files.as_ref() { + Some(pick) => { + if let Some(path) = pick(&exts).into_iter().next() { + *value = path; + } + } + None => app.ui.status = "no file dialog available: type the path".into(), + } + } let enter = !ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter)); app.dialog_state.form = f.clone(); if ok || enter { diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index d53a0c08..102a6ed2 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -1,7 +1,8 @@ //! The Home screen (shown in the Composition panel at launch, from the header's Home button, and //! by the Learn workspace): New Project / Open Project / New Composition / Open Demo, the recent -//! projects (File ▸ Open Recent, from Settings) with thumbnails, folders and dates, and where -//! After Effects has its Learn / What's New area, the ArtCraft community links. +//! projects (File ▸ Open Recent, from Settings) with thumbnails, folders and dates, the +//! Templates tab (New from Template gallery, `templates`), the Learn tab, and where After +//! Effects has its Learn / What's New area, the ArtCraft community links. //! //! Thumbnails: when a project is opened or saved and the viewer has its frame, a 96×54 RGB //! thumbnail is stored in the config store (`thumb-.txt`, hex) and shown here. @@ -174,11 +175,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text(pos2(x0 + 52.0, y + 12.0), Align2::LEFT_CENTER, "EffectCraft", Tokens::semibold(22.0), Color32::WHITE); p.text(pos2(x0 + 52.0, y + 32.0), Align2::LEFT_CENTER, format!("Version {}", env!("CARGO_PKG_VERSION")), Tokens::ui(11.0), t.text_faint); y += 56.0; - // Home / Learn tabs. - for (k, (label, learn)) in [("Home", false), ("Learn", true)].into_iter().enumerate() { + // Home / Templates / Learn tabs. + let tab = if app.ui.home_learn { + 2 + } else if app.ui.home_templates { + 1 + } else { + 0 + }; + for (k, label) in ["Home", "Templates", "Learn"].into_iter().enumerate() { let r = Rect::from_min_size(pos2(x0 + k as f32 * 84.0, y), vec2(78.0, 28.0)); let resp = ui.interact(r, egui::Id::new(("home-tab", label)), Sense::click()); - let on = app.ui.home_learn == learn; + let on = tab == k; if on || resp.hovered() { p.rect_filled(r, 6.0, if on { Color32::from_rgb(0x2e, 0x31, 0x3b) } else { t.hover }); } @@ -188,7 +196,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("home.tab.{}", label.to_ascii_lowercase()), r, label); if resp.clicked() { - app.ui.home_learn = learn; + app.ui.home_learn = k == 2; + app.ui.home_templates = k == 1; } } y += 40.0; @@ -269,6 +278,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { run_actions(app, &ctx, actions); return; } + if app.ui.home_templates { + super::templates::home_tab(app, ui, Rect::from_min_max(pos2(rx, ry), pos2(rx + rw, rect.max.y - 8.0))); + run_actions(app, &ctx, actions); + return; + } p.text(pos2(rx, ry + 6.0), Align2::LEFT_CENTER, "Recent", Tokens::semibold(16.0), Color32::WHITE); let entries = recent_entries(&app.session.prefs); if !entries.is_empty() { @@ -410,6 +424,7 @@ mod tests { "home.help.github", "home.recent.0", "home.tab.home", + "home.tab.templates", "home.tab.learn", ] { assert!(app.auto.find(id).is_some(), "{id}"); diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index a184e230..b6423680 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -187,6 +187,7 @@ pub fn coach(app: &mut EffectcraftApp, ctx: &egui::Context) { Some("more") => { let _ = app.session.execute("learn.stop", json!({})); app.ui.home_learn = true; + app.ui.home_templates = false; app.ui.start_screen = true; } Some(a) => { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d43b4e79..cd8b8460 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -42,6 +42,7 @@ pub mod script_console; pub mod scriptui_view; pub mod settings; pub mod shortcut_editor; +pub mod templates; pub mod text_panels; pub mod timeline; pub mod tracker; diff --git a/crates/ui-egui/src/panels/templates.rs b/crates/ui-egui/src/panels/templates.rs new file mode 100644 index 00000000..bae62928 --- /dev/null +++ b/crates/ui-egui/src/panels/templates.rs @@ -0,0 +1,269 @@ +//! Home ▸ Templates: the New from Template gallery (engine model in +//! `effectcraft_engine::templates`). Built-in templates are original projects authored in code; +//! user templates come from File ▸ Save as Template… (the config Templates folder). Thumbnails +//! are rendered by our own renderer, one per frame, the first time the gallery shows them. +//! +//! Automation ids: `home.templates.` (card), `home.templates..open`, +//! `home.templates..delete` (user templates), `home.templates.saveCurrent`. + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::json; + +use effectcraft_engine::templates::{self, THUMB_H, THUMB_W, TemplateInfo}; + +use crate::icons::{self, Icon}; +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +/// The gallery's entries (re-listed when the user template files change). +pub fn entries(app: &mut EffectcraftApp) -> Vec { + let files = templates::user_files(&app.session); + if app.template_list.as_ref().map(|(f, _)| f) != Some(&files) { + let list = templates::list(&app.session); + app.template_thumbs.retain(|id, _| !id.starts_with("user/") || list.iter().any(|t| &t.id == id)); + app.template_list = Some((files, list)); + } + app.template_list.as_ref().map(|(_, l)| l.clone()).unwrap_or_default() +} + +/// A template's thumbnail texture; renders at most one missing thumbnail per frame (`budget`). +fn thumb(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, budget: &mut bool) -> Option { + if let Some(t) = app.template_thumbs.get(id) { + return t.clone(); + } + if !*budget { + ctx.request_repaint(); + return None; + } + *budget = false; + let tex = templates::thumbnail(&app.session, id).ok().and_then(|t| templates::decode_thumb(&t)).map(|rgb| { + let px = rgb.chunks_exact(3).map(|c| Color32::from_rgb(c[0], c[1], c[2])).collect(); + ctx.load_texture(format!("template-thumb-{id}"), egui::ColorImage::new([THUMB_W, THUMB_H], px), egui::TextureOptions::LINEAR) + }); + app.template_thumbs.insert(id.to_string(), tex.clone()); + ctx.request_repaint(); + tex +} + +fn fmt_secs(s: f64) -> String { + if (s - s.round()).abs() < 0.05 { format!("{} s", s.round()) } else { format!("{s:.1} s") } +} + +/// The Templates tab of the Home screen. +pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let p = ui.painter().with_clip_rect(area); + let (x, y, w) = (area.min.x, area.min.y, area.width()); + p.text(pos2(x, y + 6.0), Align2::LEFT_CENTER, "New from Template", Tokens::semibold(16.0), Color32::WHITE); + p.text( + pos2(x, y + 26.0), + Align2::LEFT_CENTER, + "Start from a ready-made project. Opening a template makes an untitled copy.", + Tokens::ui(11.5), + t.text_faint, + ); + // Save the open project as a user template. + let can_save = app.session.project.comps().next().is_some(); + let sr = Rect::from_min_size(pos2(area.max.x - 210.0, y - 4.0), vec2(210.0, 28.0)); + let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::new("home-templates-save")).clicked(); + app.auto.add("home.templates.saveCurrent", sr, "Save Current as Template…"); + if save { + if can_save { + if let Err(e) = crate::menus::invoke(app, &ctx, "templates.saveAs", json!({})) { + app.ui.status = e; + } + } else { + app.ui.status = "Make a composition first: a template is saved from the open project.".into(); + } + } + let list = entries(app); + let top = y + 46.0; + let gap = 16.0; + let card_w = 220.0f32.min(w); + let cols = (((w + gap) / (card_w + gap)).floor() as usize).max(1); + let card_w = (w - gap * (cols - 1) as f32) / cols as f32; + let thumb_h = card_w * THUMB_H as f32 / THUMB_W as f32; + let card_h = thumb_h + 62.0; + let view = Rect::from_min_max(pos2(x, top), area.max); + let mut open: Option = None; + let mut delete: Option = None; + let mut budget = true; + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(view).layout(egui::Layout::top_down(egui::Align::Min))); + egui::ScrollArea::vertical().id_salt("home-templates-scroll").auto_shrink([false, false]).show(&mut child, |ui| { + let rows = list.len().div_ceil(cols); + let (full, _) = ui.allocate_exact_size(vec2(view.width() - 8.0, rows as f32 * (card_h + gap)), Sense::hover()); + let p = ui.painter().with_clip_rect(ui.clip_rect()); + for (i, tpl) in list.iter().enumerate() { + let (cx, cy) = (full.min.x + (i % cols) as f32 * (card_w + gap), full.min.y + (i / cols) as f32 * (card_h + gap)); + let r = Rect::from_min_size(pos2(cx, cy), vec2(card_w, card_h)); + let visible = r.intersects(ui.clip_rect()); + let resp = ui.interact(r, egui::Id::new(("template-card", &tpl.id)), Sense::click()).on_hover_text(&tpl.description); + p.rect_filled(r, 8.0, if resp.hovered() { Color32::from_rgb(0x2c, 0x2e, 0x37) } else { Color32::from_rgb(0x24, 0x26, 0x2e) }); + let tr = Rect::from_min_size(r.min, vec2(card_w, thumb_h)); + let tex = if visible { thumb(app, &ctx, &tpl.id, &mut budget) } else { None }; + match tex { + Some(tex) => { + p.image(tex.id(), tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + p.rect_filled(tr, 6.0, Color32::from_rgb(0x1e, 0x20, 0x27)); + icons::paint(&p, Rect::from_center_size(tr.center(), vec2(22.0, 22.0)), Icon::Comp, t.text_faint); + } + } + p.rect_stroke(tr, 6.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.separator }), StrokeKind::Inside); + // Category chip (built-in) or "Mine". + let chip = if tpl.builtin { tpl.category.clone() } else { format!("{} · Mine", tpl.category) }; + let g = p.layout_no_wrap(chip, Tokens::medium(10.0), Color32::WHITE); + let cr = Rect::from_min_size(tr.min + vec2(8.0, 8.0), g.size() + vec2(12.0, 4.0)); + p.rect_filled(cr, 8.0, Color32::from_black_alpha(150)); + p.galley(cr.min + vec2(6.0, 2.0), g, Color32::WHITE); + let text = p.with_clip_rect(r.shrink(2.0)); + text.text(pos2(r.min.x + 10.0, tr.max.y + 16.0), Align2::LEFT_CENTER, &tpl.name, Tokens::semibold(13.0), Color32::WHITE); + let meta = format!("{}×{} · {} · {:.2} fps", tpl.width, tpl.height, fmt_secs(tpl.duration), tpl.frame_rate); + text.text(pos2(r.min.x + 10.0, tr.max.y + 34.0), Align2::LEFT_CENTER, meta, Tokens::ui(11.0), t.text_dim); + let ctl = match tpl.controls.len() { + 0 => "No Essential Graphics controls".to_string(), + 1 => format!("Essential Graphics: {}", tpl.controls[0]), + n => format!("{n} Essential Graphics controls"), + }; + text.text(pos2(r.min.x + 10.0, tr.max.y + 50.0), Align2::LEFT_CENTER, ctl, Tokens::ui(10.5), t.text_faint); + let base = format!("home.templates.{}", tpl.id); + app.auto.add(&base, r, &tpl.name); + app.auto.add(&format!("{base}.open"), tr, &format!("New project from {}", tpl.name)); + let mut del_hit = false; + if !tpl.builtin { + let dr = Rect::from_min_size(pos2(tr.max.x - 30.0, tr.min.y + 6.0), vec2(24.0, 24.0)); + let dresp = ui.interact(dr, egui::Id::new(("template-del", &tpl.id)), Sense::click()).on_hover_text("Delete this template"); + if resp.hovered() || dresp.hovered() { + p.circle_filled(dr.center(), 12.0, if dresp.hovered() { Color32::from_rgb(0xc0, 0x3a, 0x3a) } else { Color32::from_black_alpha(170) }); + icons::paint(&p, dr.shrink(6.0), Icon::Close, Color32::WHITE); + } + app.auto.add(&format!("{base}.delete"), dr, "Delete Template"); + if dresp.clicked() { + del_hit = true; + delete = Some(tpl.id.clone()); + } + } + if resp.clicked() && !del_hit { + open = Some(tpl.id.clone()); + } + } + }); + if let Some(id) = delete { + match app.session.execute("templates.delete", json!({"id": id})) { + Ok(_) => { + app.template_thumbs.remove(&id); + } + Err(e) => app.ui.status = e.to_string(), + } + } else if let Some(id) = open { + match app.session.execute("templates.create", json!({"id": id})) { + Ok(_) => app.ui.start_screen = false, + Err(e) => app.ui.status = e.to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) { + ctx.run_ui(Default::default(), |ui| { + app.auto.begin_frame(); + if app.ui.start_screen { + crate::panels::home::show(app, ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 860.0))); + } + crate::panels::dialogs::show(app, ui.ctx()); + }) + .textures_delta + .clear(); + } + + fn app_with_store() -> EffectcraftApp { + let s = effectcraft_engine::Session { config: Some(std::sync::Arc::new(effectcraft_engine::config::MemoryConfig::default())), ..Default::default() }; + EffectcraftApp::new(s) + } + + #[test] + fn gallery_lists_renders_and_opens_templates() { + let mut app = app_with_store(); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens); + // File ▸ New ▸ New Project from Template… opens the Templates tab. + let ev = app.session.execute("file.newFromTemplate", json!({})).unwrap(); + assert_eq!(ev["frontend"], "file.newFromTemplate"); + crate::menus::invoke(&mut app, &ctx, "file.newFromTemplate", json!({})).unwrap(); + assert!(app.ui.start_screen && app.ui.home_templates && !app.ui.home_learn); + // One thumbnail per frame until all are rendered. + for _ in 0..12 { + frame(&mut app, &ctx); + } + let list = entries(&mut app); + assert!(list.len() >= 8); + for t in &list { + assert!(app.auto.find(&format!("home.templates.{}", t.id)).is_some(), "{}", t.id); + assert!(app.auto.find(&format!("home.templates.{}.open", t.id)).is_some(), "{}", t.id); + assert!(app.template_thumbs.get(&t.id).is_some_and(Option::is_some), "{}: no thumbnail", t.id); + } + for id in ["home.templates.saveCurrent", "home.tab.templates", "home.tab.learn"] { + assert!(app.auto.find(id).is_some(), "{id}"); + } + assert!(app.auto.find("home.recent.0").is_none()); + // Save as Template… opens its form (once there is a comp). + app.session.execute("templates.create", json!({"id": "logo-reveal"})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "templates.saveAs", json!({})).unwrap(); + assert!(matches!(app.dialog, Some(crate::Dialog::Form))); + assert_eq!(app.dialog_state.form.command, "templates.saveAs"); + app.dialog_state.form.fields[0].kind = crate::panels::forms::FieldKind::Text("Brand Intro".into()); + frame(&mut app, &ctx); + for id in ["form.field.name", "form.field.description", "form.ok", "form.cancel"] { + assert!(app.auto.find(id).is_some(), "{id}"); + } + let params = app.dialog_state.form.params(); + app.session.execute("templates.saveAs", params).unwrap(); + app.dialog = None; + app.ui.start_screen = true; + for _ in 0..3 { + frame(&mut app, &ctx); + } + let mine = "user/Brand Intro.ectemplate"; + assert!(app.auto.find(&format!("home.templates.{mine}.delete")).is_some()); + assert!(app.template_thumbs.get(mine).is_some_and(Option::is_some)); + // Opening makes an untitled copy. + app.session.execute("templates.create", json!({"id": mine})).unwrap(); + assert!(app.session.path.is_none()); + assert!(app.session.active_comp().is_some_and(|c| c.layers.iter().any(|l| l.name == "BRAND NAME"))); + app.session.execute("templates.delete", json!({"id": mine})).unwrap(); + frame(&mut app, &ctx); + frame(&mut app, &ctx); + assert!(app.auto.find(&format!("home.templates.{mine}")).is_none()); + } + + #[test] + fn my_custom_rgb_dialog() { + let mut app = app_with_store(); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens); + // Menu check marks follow the active comp's viewer. + app.session.execute("comp.new", json!({"name": "C", "width": 64, "height": 48})).unwrap(); + let item = crate::menus::menu_items(&app).into_iter().find(|m| m.id == "view.customRgb").expect("menu item"); + assert_eq!(item.label, "My Custom RGB..."); + assert_eq!(item.checked, Some(false)); + crate::menus::invoke(&mut app, &ctx, "view.customRgb", json!({})).unwrap(); + assert!(matches!(app.dialog, Some(crate::Dialog::Form))); + frame(&mut app, &ctx); + for id in ["form.field.red[0]", "form.field.white[1]", "form.field.gamma", "form.field.icc", "form.field.icc.browse", "form.ok"] { + assert!(app.auto.find(id).is_some(), "{id}"); + } + let mut p = app.dialog_state.form.params(); + assert_eq!(p["red"], json!([0.64, 0.33])); + p["gamma"] = json!(1.8); + app.session.execute("view.customRgb", p).unwrap(); + assert_eq!(app.session.prefs.custom_rgb.gamma, 1.8); + assert_eq!(app.session.state.viewer.simulation.profile, effectcraft_engine::viewer::SimProfile::MyCustom); + let item = crate::menus::menu_items(&app).into_iter().find(|m| m.id == "view.customRgb").unwrap(); + assert_eq!(item.checked, Some(true)); + } +} diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 18f67973..e0219dfb 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -348,7 +348,8 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: } let dc = vw::DisplayColor::of(&app.session); // The display conversion's inputs, for the texture caches. - let dc_key = format!("{:?}{:?}{}", dc.is_some(), opts.simulation, app.session.prefs.previews.display_profile) + let custom = if opts.simulation.profile == vw::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() }; + let dc_key = format!("{:?}{:?}{}{custom}", dc.is_some(), opts.simulation, app.session.prefs.previews.display_profile) + &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management); let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0 && dc.is_none(); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9f5f6156..aea71dfe 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -605,6 +605,9 @@ pub struct UiState { /// The Home screen shows its Learn tab (tutorials) instead of the recent projects. #[serde(default)] pub home_learn: bool, + /// The Home screen shows its Templates tab (New from Template gallery). + #[serde(default)] + pub home_templates: bool, /// Align panel: Align Layers to Selection (off: to the composition). #[serde(default)] pub align_to_selection: bool, @@ -677,6 +680,7 @@ impl Default for UiState { tool_creates_shape: true, start_screen: false, home_learn: false, + home_templates: false, align_to_selection: false, cache_when_idle: false, tracker_source: None, diff --git a/docs/parity.md b/docs/parity.md index 8f69f21d..7ec9dd04 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -137,7 +137,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Web | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10) | | 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | | Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (166 run on the GPU since M13.13, EFF-5: Warp, Bezier Warp, Reshape, Smear, CC Bend It, CC Page Turn, Cartoon, Color Emboss, Circle, Ellipse, Iris Wipe, Bevel Alpha / Edges and Gaussian Blur (Legacy) joined the 152 of M13.9; simulations, 3D channel, VR, audio-driven, OCIO, text and the CC light / transition families still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | -| Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | +| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 77% | 2.8 | GPU compositing of Advanced 3D layers with blend modes / track mattes and of environment backgrounds (pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | @@ -329,6 +329,31 @@ Browser in the web app (landed in M13.10: browser storage and File System Access wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script `Socket` validates ports, and ScriptUI resource strings accept trailing array commas. +## Update: M13.25 Home template gallery, Simulate Output ▸ My Custom RGB + +**Home ▸ Templates** (UI-7; File ▸ New ▸ New Project from Template…): a gallery of eight +original built-in project templates authored in code from engine commands — Lower Third, Title +Card, Logo Reveal, Kinetic Type, Social Square 1080×1080, Vertical 9:16 Story, Slideshow and 3D +Text Orbit — each with Essential Graphics controls on its main comp and a thumbnail rendered by +our own renderer the first time the gallery shows it. **File ▸ Save as Template…** writes the +open project as an `.ectemplate` (the open template container: `manifest.json` with +`kind: "project"`, `project.ecproj`, `poster.png`, `thumb.txt`) into the config Templates +folder (in the browser: the settings store); the gallery lists built-in and user templates, +user cards can be deleted, and opening any template makes an untitled copy. Commands: +`templates.list`, `templates.thumbnail`, `templates.create`, `templates.saveAs`, +`templates.delete`, `file.newFromTemplate`; automation ids `home.tab.templates`, +`home.templates.` / `.open` / `.delete`, `home.templates.saveCurrent`. Not yet: footage +used by a saved template is referenced by path, not embedded. + +**View ▸ Simulate Output ▸ My Custom RGB…**: a dialog defines a custom output device by +primaries and white point (CIE xy) and a gamma or the sRGB curve, or reads them from an RGB +matrix/TRC ICC profile (v2/v4: colorants, `wtpt`, `chad`, `curv`/`para` tone curves; table and +parametric curves are fitted to a gamma unless they are the sRGB curve); the definition is kept +in Settings (`customRgb`) and simulated like the built-in profiles, with Preserve RGB +(`view.customRgb`, `view.simulateOutput {profile: "myCustom"}`). Generic dialog forms now register +`form.field.`, `form.ok` and `form.cancel` automation ids. Not yet: LUT-based (`A2B0`) +ICC profiles. + ## Highest-value gaps, in order 1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).