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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 18:14:17 -04:00
parent 9e70cde725
commit 3881dbb32e
12 changed files with 446 additions and 29 deletions

View File

@ -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<CommandSpec> {
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),

View File

@ -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)
}

View File

@ -192,6 +192,10 @@ pub struct EffectcraftApp {
pub(crate) home_thumbs: std::collections::HashMap<String, Option<egui::TextureHandle>>,
/// 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<String, Option<egui::TextureHandle>>,
/// Home ▸ Templates: the gallery, and the user template files it was listed from.
pub(crate) template_list: Option<(Vec<String>, Vec<effectcraft_engine::templates::TemplateInfo>)>,
}
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;
}
}

View File

@ -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" => {

View File

@ -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<String>,
},
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<usize> = 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 {

View File

@ -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-<hash>.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}");

View File

@ -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) => {

View File

@ -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;

View File

@ -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.<id>` (card), `home.templates.<id>.open`,
//! `home.templates.<id>.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<TemplateInfo> {
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<egui::TextureHandle> {
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<String> = None;
let mut delete: Option<String> = 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));
}
}

View File

@ -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));

View File

@ -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,

View File

@ -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.<id>` / `.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.<key>`, `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).