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:
parent
9e70cde725
commit
3881dbb32e
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue