From 3881dbb32e320a59bc29dfe3ddcd5bef9bd8f1a8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 18:14:17 -0400 Subject: [PATCH] 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).