Merge main into M13.23

This commit is contained in:
Brandon Thomas 2026-10-03 18:35:42 -04:00
commit a55b07ef01
23 changed files with 1987 additions and 64 deletions

View File

@ -59,6 +59,9 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`.
- Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`.
- Built-in project templates (Lower Third, Title Card, Logo Reveal, Kinetic Type, Social Square, Vertical Story,
Slideshow, 3D Text Orbit) built from engine commands, and their thumbnails rendered by our own renderer:
`crates/engine/src/templates.rs`.
- Text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter,
Drop In Lines): `crates/engine/presets/text_animators.json` (sidecar `.attribution`).
- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`,

View File

@ -25,14 +25,14 @@ once.
## How far from full parity
Measured feature by feature in [docs/parity.md](docs/parity.md) (3 October 2026):
Measured feature by feature in [docs/parity.md](docs/parity.md) (3 October 2026, evening):
- **≈ 94% of After Effects' features, weighted by importance**, counting partial features as half
done (≈ 98% with per-feature fractions): 80 of 92 features done, 12 partial, none missing; all
298 After Effects effects, every one implemented in full.
- **≈ 19–22 agent-hours of work remain**: about **4–5 hours of wall-clock time** with five Claude
Opus 5.5 agents in parallel, ≈ 3 hours for the essentials and daily-use features.
- Hardest remaining pieces: the web app's threading, wider GPU effect coverage, Advanced 3D
completion, and learned-model quality for Roto Brush and face tracking.
- **≈ 98% of After Effects' features, weighted by importance**, counting partial features as half
done (≈ 99.6% with per-feature fractions): 87 of 92 features done, 5 partial, none missing; all
306 effects implemented in full, 166 of them on the GPU.
- **≈ 9–11 agent-hours of work remain**: about **2–2.5 hours of wall-clock time** with five Claude
Opus 5.5 agents in parallel, ≈ 1.5 hours for the essentials and daily-use features.
- What is left: more GPU effects, GPU effects and a disk cache in the web app, a template gallery
on the Home screen, and learned-model quality for Roto Brush and face tracking.
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).

337
crates/color/src/icc.rs Normal file
View File

@ -0,0 +1,337 @@
//! A reader for matrix/TRC RGB ICC profiles (ICC.1:2001-04 v2 and ICC.1:2010 v4, the public
//! International Color Consortium specification): the red/green/blue colorants (`rXYZ`, `gXYZ`,
//! `bXYZ`), the media white point (`wtpt`), the chromatic adaptation (`chad`), the tone curves
//! (`rTRC`, `gTRC`, `bTRC` as `curv` or `para`) and the description (`desc` / `mluc`).
//!
//! Used by View ▸ Simulate Output ▸ My Custom RGB: the profile becomes primaries, a white point
//! and a curve. LUT-based (`A2B0`) and non-RGB profiles are rejected.
use crate::space::{bradford, invert, mul_vec};
/// CIE D50 (the ICC profile connection space white), xy.
pub const D50: [f64; 2] = [0.3457, 0.3585];
/// A tone curve of a profile.
#[derive(Clone, Debug, PartialEq)]
pub enum Trc {
/// `curv` with one entry, or `para` function type 0: y = x^g.
Gamma(f64),
/// `curv` table (0..=65535 samples over 0..1).
Table(Vec<u16>),
/// `para` function types 1–4: (type, parameters g a b c d e f).
Parametric(u16, [f64; 7]),
}
impl Trc {
/// Encoded value (0..1) → linear.
pub fn eval(&self, x: f64) -> f64 {
let x = x.clamp(0.0, 1.0);
match self {
Trc::Gamma(g) => x.powf(*g),
Trc::Table(t) if t.is_empty() => x,
Trc::Table(t) => {
let f = x * (t.len() - 1) as f64;
let i = (f.floor() as usize).min(t.len() - 1);
let j = (i + 1).min(t.len() - 1);
let k = f - i as f64;
(t[i] as f64 * (1.0 - k) + t[j] as f64 * k) / 65535.0
}
Trc::Parametric(ty, p) => {
let [g, a, b, c, d, e, f] = *p;
let pw = |v: f64| if v > 0.0 { v.powf(g) } else { 0.0 };
match ty {
1 => {
if x >= -b / a {
pw(a * x + b)
} else {
0.0
}
}
2 => {
if x >= -b / a {
pw(a * x + b) + c
} else {
c
}
}
3 => {
if x >= d {
pw(a * x + b)
} else {
c * x
}
}
_ => {
if x >= d {
pw(a * x + b) + e
} else {
c * x + f
}
}
}
}
}
}
/// The single gamma that best matches the curve (least squares in log space over mid-tones).
pub fn fit_gamma(&self) -> f64 {
if let Trc::Gamma(g) = self {
return *g;
}
let (mut num, mut den) = (0.0, 0.0);
for i in 4..20 {
let x = i as f64 / 20.0;
let y = self.eval(x);
if y > 1e-6 && y < 1.0 {
num += x.ln() * y.ln();
den += x.ln() * x.ln();
}
}
if den > 0.0 { (num / den).clamp(0.1, 10.0) } else { 1.0 }
}
/// Whether the curve is the sRGB (IEC 61966-2-1) piecewise curve, within 8-bit precision.
pub fn is_srgb(&self) -> bool {
let srgb = |x: f64| if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) };
!matches!(self, Trc::Gamma(_)) && (0..=32).all(|i| (self.eval(i as f64 / 32.0) - srgb(i as f64 / 32.0)).abs() < 0.004)
}
}
/// What a matrix/TRC RGB profile says.
#[derive(Clone, Debug, PartialEq)]
pub struct RgbProfile {
pub description: String,
/// Red, green, blue chromaticities (CIE xy) under the profile's own white.
pub primaries: [[f64; 2]; 3],
/// The white point (CIE xy) of the device.
pub white: [f64; 2],
/// Tone curves (red, green, blue).
pub trc: [Trc; 3],
/// ICC version major number.
pub version: u8,
}
fn be32(b: &[u8], o: usize) -> Option<u32> {
Some(u32::from_be_bytes(b.get(o..o + 4)?.try_into().ok()?))
}
fn be16(b: &[u8], o: usize) -> Option<u16> {
Some(u16::from_be_bytes(b.get(o..o + 2)?.try_into().ok()?))
}
fn s15f16(b: &[u8], o: usize) -> Option<f64> {
Some(be32(b, o)? as i32 as f64 / 65536.0)
}
fn xy(v: [f64; 3]) -> [f64; 2] {
let s = v[0] + v[1] + v[2];
if s.abs() < 1e-12 { D50 } else { [v[0] / s, v[1] / s] }
}
fn xyz_of(c: [f64; 2]) -> [f64; 3] {
[c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]]
}
/// Parse an RGB matrix/TRC ICC profile.
pub fn parse(b: &[u8]) -> Result<RgbProfile, String> {
if b.len() < 132 || b.get(36..40) != Some(b"acsp") {
return Err("not an ICC profile".into());
}
if b.get(16..20) != Some(b"RGB ") {
return Err("not an RGB profile (only RGB display profiles can be simulated)".into());
}
let version = b[8];
let n = be32(b, 128).ok_or("truncated tag table")? as usize;
let mut tags = std::collections::HashMap::new();
for i in 0..n.min(200) {
let o = 132 + i * 12;
let sig = b.get(o..o + 4).ok_or("truncated tag table")?;
let off = be32(b, o + 4).ok_or("truncated tag table")? as usize;
let size = be32(b, o + 8).ok_or("truncated tag table")? as usize;
if off.checked_add(size).is_some_and(|e| e <= b.len()) {
tags.insert(String::from_utf8_lossy(sig).into_owned(), &b[off..off + size]);
}
}
let xyz_tag = |name: &str| -> Result<[f64; 3], String> {
let t = tags.get(name).ok_or_else(|| format!("the profile has no {name} tag (only matrix/TRC profiles are supported)"))?;
if t.get(0..4) != Some(b"XYZ ") {
return Err(format!("{name}: not an XYZ tag"));
}
Ok([s15f16(t, 8).ok_or("bad XYZ")?, s15f16(t, 12).ok_or("bad XYZ")?, s15f16(t, 16).ok_or("bad XYZ")?])
};
let trc_tag = |name: &str| -> Result<Trc, String> {
let t = tags.get(name).ok_or_else(|| format!("the profile has no {name} tag"))?;
match t.get(0..4) {
Some(b"curv") => {
let count = be32(t, 8).ok_or("bad curv")? as usize;
match count {
0 => Ok(Trc::Gamma(1.0)),
1 => Ok(Trc::Gamma(be16(t, 12).ok_or("bad curv")? as f64 / 256.0)),
_ => Ok(Trc::Table((0..count).map(|i| be16(t, 12 + i * 2)).collect::<Option<Vec<_>>>().ok_or("truncated curv")?)),
}
}
Some(b"para") => {
let ty = be16(t, 8).ok_or("bad para")?;
let np = [1, 3, 4, 5, 7].get(ty as usize).copied().ok_or("unknown para function")?;
let mut p = [0.0; 7];
for (k, v) in p.iter_mut().enumerate().take(np) {
*v = s15f16(t, 12 + k * 4).ok_or("truncated para")?;
}
Ok(if ty == 0 { Trc::Gamma(p[0]) } else { Trc::Parametric(ty, p) })
}
_ => Err(format!("{name}: unsupported curve type")),
}
};
let col = [xyz_tag("rXYZ")?, xyz_tag("gXYZ")?, xyz_tag("bXYZ")?];
// The device white: from the chromatic adaptation (v4, and many v2 profiles), else the media
// white point (v2 profiles that keep it un-adapted), else D50.
let d50 = xyz_of(D50);
let chad = tags.get("chad").filter(|t| t.get(0..4) == Some(b"sf32") && t.len() >= 44).map(|t| {
let v: Vec<f64> = (0..9).map(|i| s15f16(t, 8 + i * 4).unwrap_or(0.0)).collect();
[[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]]
});
let white = match (chad, xyz_tag("wtpt").ok()) {
(Some(m), _) => xy(mul_vec(&invert(&m), d50)),
(None, Some(w)) => xy(w),
_ => D50,
};
// Colorants are adapted to D50 in the profile: undo that to get the device primaries.
let undo = match chad {
Some(m) => invert(&m),
None => bradford(D50, white),
};
let primaries = col.map(|c| xy(mul_vec(&undo, c)));
let trc = [trc_tag("rTRC")?, trc_tag("gTRC")?, trc_tag("bTRC")?];
Ok(RgbProfile { description: description(tags.get("desc").copied()).unwrap_or_default(), primaries, white, trc, version })
}
fn description(t: Option<&[u8]>) -> Option<String> {
let t = t?;
match t.get(0..4)? {
b"desc" => {
let n = be32(t, 8)? as usize;
let s = t.get(12..12 + n)?;
Some(String::from_utf8_lossy(s).trim_end_matches('\0').to_string())
}
b"mluc" => {
// The first record: UTF-16BE.
let len = be32(t, 20)? as usize;
let off = be32(t, 24)? as usize;
let u: Vec<u16> = t.get(off..off + len)?.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
Some(String::from_utf16_lossy(&u).trim_end_matches('\0').to_string())
}
_ => None,
}
}
/// Build a minimal v2 matrix/TRC RGB profile (tests, and saving a custom simulation): colorants
/// Bradford-adapted to D50, `wtpt` the device white, gamma curves.
pub fn write_matrix_profile(desc: &str, primaries: [[f64; 2]; 3], white: [f64; 2], gamma: f64) -> Vec<u8> {
let m = crate::space::rgb_to_xyz(primaries, white);
let a = bradford(white, D50);
let col = |j: usize| mul_vec(&a, [m[0][j], m[1][j], m[2][j]]);
let f = |v: f64| ((v * 65536.0).round() as i32).to_be_bytes();
let xyz = |v: [f64; 3]| {
let mut t = b"XYZ \0\0\0\0".to_vec();
for c in v {
t.extend(f(c));
}
t
};
let curv = {
let mut t = b"curv\0\0\0\0".to_vec();
t.extend(1u32.to_be_bytes());
t.extend(((gamma * 256.0).round() as u16).to_be_bytes());
t.extend([0, 0]);
t
};
let dsc = {
let mut t = b"desc\0\0\0\0".to_vec();
t.extend((desc.len() as u32 + 1).to_be_bytes());
t.extend(desc.as_bytes());
t.push(0);
t.extend([0u8; 12 + 67]);
while !t.len().is_multiple_of(4) {
t.push(0);
}
t
};
let w = xyz_of(white);
let tags: Vec<(&[u8; 4], Vec<u8>)> = vec![
(b"desc", dsc),
(b"wtpt", xyz(w)),
(b"rXYZ", xyz(col(0))),
(b"gXYZ", xyz(col(1))),
(b"bXYZ", xyz(col(2))),
(b"rTRC", curv.clone()),
(b"gTRC", curv.clone()),
(b"bTRC", curv),
];
let mut table = vec![];
let mut data = vec![];
let base = 128 + 4 + tags.len() * 12;
for (sig, t) in &tags {
table.extend(*sig);
table.extend(((base + data.len()) as u32).to_be_bytes());
table.extend((t.len() as u32).to_be_bytes());
data.extend(t);
while !data.len().is_multiple_of(4) {
data.push(0);
}
}
let total = base + data.len();
let mut h = vec![0u8; 128];
h[0..4].copy_from_slice(&(total as u32).to_be_bytes());
h[8] = 2;
h[9] = 0x10;
h[12..16].copy_from_slice(b"mntr");
h[16..20].copy_from_slice(b"RGB ");
h[20..24].copy_from_slice(b"XYZ ");
h[36..40].copy_from_slice(b"acsp");
for (k, v) in xyz_of(D50).into_iter().enumerate() {
h[68 + k * 4..72 + k * 4].copy_from_slice(&f(v));
}
let mut out = h;
out.extend((tags.len() as u32).to_be_bytes());
out.extend(table);
out.extend(data);
out
}
#[cfg(test)]
mod tests {
use super::*;
const P709: [[f64; 2]; 3] = [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]];
const D65: [f64; 2] = [0.3127, 0.3290];
#[test]
fn matrix_profile_round_trips() {
let b = write_matrix_profile("Test RGB", P709, D65, 2.2);
let p = parse(&b).unwrap();
assert_eq!(p.description, "Test RGB");
assert_eq!(p.version, 2);
for (a, e) in p.primaries.iter().zip(P709) {
assert!((a[0] - e[0]).abs() < 2e-4 && (a[1] - e[1]).abs() < 2e-4, "{a:?} vs {e:?}");
}
assert!((p.white[0] - D65[0]).abs() < 2e-4 && (p.white[1] - D65[1]).abs() < 2e-4, "{:?}", p.white);
assert!((p.trc[0].fit_gamma() - 2.2).abs() < 0.01);
assert!(!p.trc[0].is_srgb());
}
#[test]
fn curves() {
let srgb = Trc::Parametric(3, [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0]);
assert!(srgb.is_srgb());
assert!((srgb.fit_gamma() - 2.2).abs() < 0.15, "{}", srgb.fit_gamma());
let table = Trc::Table((0..256).map(|i| ((i as f64 / 255.0).powf(1.8) * 65535.0).round() as u16).collect());
assert!((table.fit_gamma() - 1.8).abs() < 0.02);
assert!((Trc::Gamma(2.0).eval(0.5) - 0.25).abs() < 1e-12);
}
#[test]
fn rejects_other_files() {
assert!(parse(b"hello").is_err());
let mut b = write_matrix_profile("x", P709, D65, 2.2);
b[16..20].copy_from_slice(b"CMYK");
assert!(parse(&b).unwrap_err().contains("RGB"));
}
}

View File

@ -6,6 +6,7 @@
//! colour and composited with Porter-Duff source-over on premultiplied pixels.
pub mod blend;
pub mod icc;
pub mod space;
pub use blend::{BlendMode, blend_pixel};

View File

@ -199,6 +199,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(content_fill::specs());
v.extend(stubs::specs());
v.extend(crate::learn::specs());
v.extend(crate::templates::specs());
v.extend(crate::preview::specs());
v
})

View File

@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p};
use crate::viewer::{Channel, SimProfile, Simulation, Snapshot};
use crate::viewer::{Channel, CustomRgb, SimProfile, Simulation, Snapshot};
use crate::{EngineError, Result, Session, VertexRef, cmd, query};
/// Composition ▸ Preview ▸ Fast Previews modes.
@ -345,6 +345,81 @@ fn simulate_output(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"profile": sim.profile.id(), "label": sim.profile.label(), "preserveRgb": sim.preserve_rgb}))
}
fn custom_rgb_json(c: &CustomRgb) -> Value {
serde_json::to_value(c).unwrap_or_default()
}
/// View ▸ Simulate Output ▸ My Custom RGB…: define the custom output device (kept in Settings)
/// and, unless `apply` is false, simulate it. An `icc` path that differs from the one the
/// definition came from is read (its primaries, white and curve replace the numbers); `from`
/// starts from a built-in profile; `reset` goes back to the default.
fn custom_rgb(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "view.customRgb";
let mut c = if b_p(p, "reset") == Some(true) { CustomRgb::default() } else { s.prefs.custom_rgb.clone() };
if let Some(from) = str_p(p, "from") {
let prof = SimProfile::parse(from).ok_or_else(|| bad(cmd, format!("from: unknown profile `{from}`")))?;
if let Some((prims, curve)) = prof.space() {
[c.red, c.green, c.blue] = prims;
c.white = [0.3127, 0.3290];
(c.gamma, c.srgb_curve) = match curve {
crate::viewer::Curve::Srgb => (2.2, true),
crate::viewer::Curve::Gamma(g) => ((g as f64 * 1000.0).round() / 1000.0, false),
_ => (1.0, false),
};
c.icc.clear();
c.name = format!("Custom ({})", prof.label());
}
}
let pt = |k: &str| -> Result<Option<[f64; 2]>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, format!("{k}: [x, y]")))?;
let g = |i: usize| a[i].as_f64().ok_or_else(|| bad(cmd, format!("{k}: [x, y] numbers")));
Ok(Some([g(0)?, g(1)?]))
}
}
};
for (k, slot) in [("red", &mut c.red), ("green", &mut c.green), ("blue", &mut c.blue), ("white", &mut c.white)] {
if let Some(v) = pt(k)? {
*slot = v;
}
}
if let Some(g) = f_p(p, "gamma") {
c.gamma = g;
}
if let Some(b) = b_p(p, "srgbCurve") {
c.srgb_curve = b;
}
if let Some(n) = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
c.name = n.to_string();
}
match str_p(p, "icc").map(str::trim) {
Some("") => c.icc.clear(),
Some(path) if path != c.icc || b_p(p, "reload") == Some(true) => {
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let mut from = CustomRgb::from_icc(&bytes, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
if let Some(n) = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty() && *n != s.prefs.custom_rgb.name) {
from.name = n.to_string();
}
c = from;
}
_ => {}
}
c.validate().map_err(|e| bad(cmd, e))?;
s.prefs.custom_rgb = c.clone();
s.prefs_revision += 1;
s.save_prefs();
if b_p(p, "apply") != Some(false) {
let preserve = b_p(p, "preserveRgb").unwrap_or(false);
s.state.viewer.simulation = Simulation { profile: SimProfile::MyCustom, preserve_rgb: preserve };
}
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
let mut out = custom_rgb_json(&c);
out["active"] = json!(s.state.viewer.simulation.profile == SimProfile::MyCustom);
Ok(out)
}
/// View ▸ Split with New Locked Viewer: lock a second viewer to the comp (default the active
/// one) and its current 3D view (or `view`).
fn split_locked(s: &mut Session, p: &Value) -> Result<Value> {
@ -374,6 +449,7 @@ fn display_color_state(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({
"displayColorManagement": v.display_color_management,
"simulation": {"profile": v.simulation.profile.id(), "preserveRgb": v.simulation.preserve_rgb},
"customRgb": custom_rgb_json(&s.prefs.custom_rgb),
"display": s.prefs.previews.display_profile,
"workingSpace": s.project.settings.working_space.map(|w| w.id()),
"active": crate::viewer::DisplayColor::of(s).is_some(),
@ -390,10 +466,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Simulate Output",
[],
None,
"{profile: none|rec709|ntsc|pal|mac18|srgb|rec2020|p3|linear|custom, preserveRgb?, space? (custom)}",
"{profile: none|rec709|ntsc|pal|mac18|srgb|rec2020|p3|linear|myCustom|custom, preserveRgb?, space? (custom)}",
always,
simulate_output
),
cmd!(
"view.customRgb",
"My Custom RGB...",
[],
None,
"{name?, red?/green?/blue?/white?: [x, y] (CIE xy), gamma?, srgbCurve?: bool, icc?: path (RGB ICC profile; read when it changes, \"\" clears), reload?, from?: a Simulate Output profile to start from, reset?, preserveRgb?, apply?: bool (default true: simulate it)} → the definition (kept in Settings)",
always,
custom_rgb
),
cmd!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], None, "{comp?, view?: 3D view id}", has_comp, split_locked),
cmd!("view.closeLockedViewer", "Close Locked Viewer", [], None, "{}", has_locked, close_locked),
query!(

View File

@ -35,6 +35,7 @@ pub mod scriptui;
mod session_settings;
pub mod shortcuts;
pub mod sysinfo;
pub mod templates;
pub mod tracking;
pub mod vector;
pub mod viewer;

View File

@ -317,6 +317,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
"view.extendedViewer" => Some(s.prefs.three_d.extended_viewer),
"view.snapping" => Some(s.state.snapping),
"view.displayColorManagement" => Some(s.state.viewer.display_color_management),
"view.customRgb" => Some(s.state.viewer.simulation.profile == crate::viewer::SimProfile::MyCustom),
"view.simulateOutput" => {
let want = pstr("profile")?;
let sim = s.state.viewer.simulation;
@ -482,6 +483,7 @@ pub const TREE: &str = r#"
File
New
New Project | file.newProject
New Project from Template... | file.newFromTemplate
New Folder | project.newFolder
Open Project... | file.open
Open Recent
@ -496,6 +498,7 @@ File
Save As... | file.saveAs
Save a Copy... | file.saveCopy
Save a Copy As XML... | file.saveCopyAsXml
Save as Template... | templates.saveAs
Increment and Save | file.incrementAndSave
Revert | file.revert
---
@ -1049,6 +1052,8 @@ View
UHDTV (Rec. 2020) | view.simulateOutput {"profile":"rec2020"}
Display P3 | view.simulateOutput {"profile":"p3"}
Linear (1.0 Gamma) | view.simulateOutput {"profile":"linear"}
---
My Custom RGB... | view.customRgb
Custom... | view.simulateOutput {"profile":"custom"}
---
Show Rulers | view.rulers

View File

@ -283,6 +283,8 @@ pub struct Prefs {
pub recent_fonts: Vec<String>,
/// Preview panel: every shortcut's options.
pub preview: crate::preview::PreviewSettings,
/// View ▸ Simulate Output ▸ My Custom RGB.
pub custom_rgb: crate::viewer::CustomRgb,
/// Top-level keys this version doesn't know (kept for newer versions).
#[serde(flatten)]
pub extra: Extra,
@ -315,6 +317,7 @@ impl Default for Prefs {
recent_presets: vec![],
recent_fonts: vec![],
preview: crate::preview::PreviewSettings::default(),
custom_rgb: crate::viewer::CustomRgb::default(),
extra: Extra::new(),
}
}

View File

@ -0,0 +1,874 @@
//! Project templates: the Home screen's **New from Template** gallery.
//!
//! - **Built-in templates** are original work authored in code (MIT OR Apache-2.0, listed in
//! `ATTRIBUTION.md`): each builds its project by running engine commands on a scratch session,
//! so it is exactly what a user could make by hand. Their thumbnails are rendered by our own
//! renderer the first time they are asked for.
//! - **User templates**: File ▸ Save as Template… writes the open project as an `.ectemplate`
//! (the open template container of `essential.exportTemplate`: a stored ZIP with
//! `manifest.json` (`kind: "project"`), `project.ecproj`, `poster.png` and `thumb.txt`) into
//! the config store's `Templates` folder.
//! - Opening a template (`templates.create`) makes an untitled copy: the template itself is never
//! changed.
//!
//! Commands: `templates.list`, `templates.thumbnail`, `templates.create`, `templates.saveAs`,
//! `templates.delete`.
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_keyframe::{Keyframe, Value as KV};
use effectcraft_project::{ItemId, LayerId, Project};
use effectcraft_time::Tick;
use serde::Serialize;
use serde_json::{Value, json};
use crate::commands::essential::{TEMPLATE_FORMAT, TEMPLATE_VERSION, read_template};
use crate::commands::{CommandSpec, always, bad, str_p};
use crate::{EngineError, Result, Session, cmd, query};
/// The config-store folder user templates live in.
pub const TEMPLATES_DIR: &str = "Templates";
/// Template file extension.
pub const EXT: &str = "ectemplate";
/// Gallery thumbnail size (RGB, letterboxed).
pub const THUMB_W: usize = 256;
pub const THUMB_H: usize = 144;
/// One gallery entry.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TemplateInfo {
/// Built-in: its id (`lower-third`); user: `user/<file name>`.
pub id: String,
pub name: String,
pub description: String,
pub category: String,
pub builtin: bool,
pub width: u32,
pub height: u32,
pub frame_rate: f64,
pub duration: f64,
/// Essential Graphics controls of the main composition (names).
pub controls: Vec<String>,
}
struct Builtin {
id: &'static str,
name: &'static str,
category: &'static str,
description: &'static str,
build: fn(&mut Session) -> Result<()>,
}
const BUILTINS: &[Builtin] = &[
Builtin {
id: "lower-third",
name: "Lower Third",
category: "Titles",
description: "A name and title that slide in on an accent bar. Essential Graphics: Name, Title, Bar Color.",
build: lower_third,
},
Builtin {
id: "title-card",
name: "Title Card",
category: "Titles",
description: "A centred title that scales up over a gradient, with a subtitle fade. Essential Graphics: Title, Subtitle.",
build: title_card,
},
Builtin {
id: "logo-reveal",
name: "Logo Reveal",
category: "Brand",
description: "A placeholder logo mark spins and glows into place above the brand name. Essential Graphics: Brand Name, Logo Color.",
build: logo_reveal,
},
Builtin {
id: "kinetic-type",
name: "Kinetic Type",
category: "Titles",
description: "Three words pop on in rhythm. Essential Graphics: Word 1, Word 2, Word 3.",
build: kinetic_type,
},
Builtin {
id: "social-square",
name: "Social Square 1080×1080",
category: "Social",
description: "A square post: headline, accent circle and handle. Essential Graphics: Headline, Handle, Accent Color.",
build: social_square,
},
Builtin {
id: "vertical-story",
name: "Vertical 9:16 Story",
category: "Social",
description: "A 1080×1920 story with a headline and a bobbing call to action. Essential Graphics: Headline, Call to Action.",
build: vertical_story,
},
Builtin {
id: "slideshow",
name: "Slideshow",
category: "Photo",
description: "Three slides with a slow push-in and cross-fades; replace the colour slides with your photos. Essential Graphics: Caption 1–3.",
build: slideshow,
},
Builtin {
id: "text-orbit-3d",
name: "3D Text Orbit",
category: "3D",
description: "3D title cards with a camera orbiting around them over a floor. Essential Graphics: Title.",
build: text_orbit,
},
];
// ------------------------------------------------------------------------- authoring helpers
fn ex(s: &mut Session, c: &str, p: Value) -> Result<Value> {
s.execute(c, p)
}
fn layer_of(v: &Value) -> Result<LayerId> {
v["layer"].as_u64().map(LayerId).ok_or_else(|| EngineError::Other("template: no layer".into()))
}
fn comp(s: &mut Session, name: &str, w: u32, h: u32, secs: f64, bg: &str) -> Result<ItemId> {
ex(s, "comp.new", json!({"name": name, "width": w, "height": h, "frameRate": 30, "duration": secs, "background": bg}))?;
s.active_comp_id().ok_or(EngineError::NoComp)
}
fn solid(s: &mut Session, name: &str, color: &str) -> Result<LayerId> {
layer_of(&ex(s, "layer.newSolid", json!({"name": name, "color": color}))?)
}
fn text(s: &mut Session, txt: &str, pos: [f64; 2], size: f64, fill: &str) -> Result<LayerId> {
layer_of(&ex(s, "layer.newText", json!({"text": txt, "position": pos, "size": size, "fill": fill}))?)
}
fn shape(s: &mut Session, name: &str, kind: &str, size: [f64; 2], fill: &str, pos: [f64; 2]) -> Result<LayerId> {
layer_of(&ex(s, "layer.newShape", json!({"name": name, "kind": kind, "size": size, "fill": fill, "position": pos}))?)
}
fn gradient(s: &mut Session, l: LayerId, start: [f64; 2], end: [f64; 2], a: &str, b: &str, radial: bool) -> Result<()> {
ex(s, "effect.apply", json!({"layers": [l.0], "effect": "ec.generate.gradientramp"}))?;
let hex = |h: &str| effectcraft_color::Rgba::from_hex(h).map(|c| [c.r as f64, c.g as f64, c.b as f64, 1.0]).unwrap_or([1.0; 4]);
edit_layer(s, l, |ly| {
let set = |ly: &mut effectcraft_project::Layer, k: &str, v: KV| {
if let Some(p) = ly.props.prop_mut(&format!("effects/#1/{k}")) {
p.value = v;
}
};
set(ly, "start", KV::Vec2(start));
set(ly, "end", KV::Vec2(end));
set(ly, "startColor", KV::Color(hex(a)));
set(ly, "endColor", KV::Color(hex(b)));
set(ly, "shape", KV::Enum(u32::from(radial)));
});
Ok(())
}
fn edit_layer(s: &mut Session, l: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) {
let Some(cid) = s.active_comp_id() else { return };
if let Some(ly) = Arc::make_mut(&mut s.project).comp_mut(cid).and_then(|c| c.layer_mut(l)) {
f(ly);
}
}
/// Eased keyframes at seconds (layer time = comp time: template layers start at 0).
fn keys(s: &mut Session, l: LayerId, path: &str, list: &[(f64, KV)]) {
let rate = s.active_comp().map(|c| c.frame_rate).unwrap_or(effectcraft_time::FrameRate::FPS_30);
edit_layer(s, l, |ly| {
if let Some(p) = ly.props.prop_mut(path) {
p.keys = list.iter().map(|(t, v)| Keyframe::new(rate.snap_nearest(Tick::from_seconds_f64(*t)), v.clone()).eased()).collect();
}
});
}
fn set(s: &mut Session, l: LayerId, path: &str, v: KV) {
edit_layer(s, l, |ly| {
if let Some(p) = ly.props.prop_mut(path) {
p.value = v;
}
});
}
fn expose(s: &mut Session, l: LayerId, path: &str, name: &str) -> Result<()> {
ex(s, "essential.addProperty", json!({"layer": l.0, "path": path, "name": name}))?;
Ok(())
}
fn finish(s: &mut Session, eg_name: &str, poster: f64) -> Result<()> {
ex(s, "essential.setName", json!({"name": eg_name}))?;
if let Some(cid) = s.active_comp_id() {
let rate = s.active_comp().map(|c| c.frame_rate).unwrap_or(effectcraft_time::FrameRate::FPS_30);
if let Some(c) = Arc::make_mut(&mut s.project).comp_mut(cid) {
c.poster_time = rate.snap_nearest(Tick::from_seconds_f64(poster));
}
}
Ok(())
}
fn p3(x: f64, y: f64) -> KV {
KV::Vec3([x, y, 0.0])
}
fn sc(v: f64) -> KV {
KV::Vec3([v, v, 100.0])
}
fn op(v: f64) -> KV {
KV::Scalar(v)
}
// ------------------------------------------------------------------------- built-in templates
fn lower_third(s: &mut Session) -> Result<()> {
comp(s, "Lower Third", 1920, 1080, 6.0, "#101218")?;
let bg = solid(s, "Preview Background (delete over footage)", "#000000")?;
gradient(s, bg, [960.0, 200.0], [960.0, 1080.0], "#2B3550", "#0B0D14", false)?;
let bar = shape(s, "Accent Bar", "rect", [760.0, 150.0], "#3D8BFF", [0.0, 0.0])?;
keys(s, bar, "transform/position", &[(0.0, p3(-420.0, 860.0)), (0.7, p3(520.0, 860.0)), (5.0, p3(520.0, 860.0)), (5.6, p3(-420.0, 860.0))]);
let name = text(s, "Alex Rivera", [520.0, 845.0], 64.0, "#FFFFFF")?;
keys(s, name, "transform/opacity", &[(0.4, op(0.0)), (0.9, op(100.0)), (4.9, op(100.0)), (5.4, op(0.0))]);
let title = text(s, "Motion Designer", [520.0, 905.0], 34.0, "#DDE6FF")?;
keys(s, title, "transform/opacity", &[(0.6, op(0.0)), (1.1, op(100.0)), (4.8, op(100.0)), (5.3, op(0.0))]);
expose(s, name, "text/sourceText", "Name")?;
expose(s, title, "text/sourceText", "Title")?;
expose(s, bar, "contents/group#1/contents/fill/color", "Bar Color")?;
finish(s, "Lower Third", 2.0)
}
fn title_card(s: &mut Session) -> Result<()> {
comp(s, "Title Card", 1920, 1080, 5.0, "#000000")?;
let bg = solid(s, "Background", "#000000")?;
gradient(s, bg, [960.0, 400.0], [960.0, 1400.0], "#5B2A86", "#120A1F", true)?;
let title = text(s, "YOUR TITLE HERE", [960.0, 560.0], 120.0, "#FFFFFF")?;
keys(s, title, "transform/scale", &[(0.0, sc(70.0)), (1.2, sc(100.0)), (5.0, sc(106.0))]);
keys(s, title, "transform/opacity", &[(0.0, op(0.0)), (0.6, op(100.0))]);
let sub = text(s, "A subtitle that sets the scene", [960.0, 660.0], 40.0, "#E9D8FF")?;
keys(s, sub, "transform/opacity", &[(0.8, op(0.0)), (1.6, op(100.0))]);
expose(s, title, "text/sourceText", "Title")?;
expose(s, sub, "text/sourceText", "Subtitle")?;
finish(s, "Title Card", 2.5)
}
fn logo_reveal(s: &mut Session) -> Result<()> {
comp(s, "Logo Reveal", 1920, 1080, 5.0, "#05070C")?;
let bg = solid(s, "Background", "#000000")?;
gradient(s, bg, [960.0, 480.0], [960.0, 1300.0], "#14324A", "#04060A", true)?;
let logo = shape(s, "Logo (replace me)", "star", [300.0, 300.0], "#FFC23D", [960.0, 450.0])?;
keys(s, logo, "transform/scale", &[(0.0, sc(0.0)), (1.0, sc(110.0)), (1.4, sc(100.0))]);
keys(s, logo, "transform/rotation", &[(0.0, op(-180.0)), (1.4, op(0.0))]);
ex(s, "effect.apply", json!({"layers": [logo.0], "effect": "Glow"}))?;
let brand = text(s, "BRAND NAME", [960.0, 720.0], 84.0, "#FFFFFF")?;
keys(s, brand, "transform/opacity", &[(1.2, op(0.0)), (2.0, op(100.0))]);
keys(s, brand, "transform/position", &[(1.2, p3(960.0, 760.0)), (2.0, p3(960.0, 720.0))]);
expose(s, brand, "text/sourceText", "Brand Name")?;
expose(s, logo, "contents/group#1/contents/fill/color", "Logo Color")?;
finish(s, "Logo Reveal", 3.0)
}
fn kinetic_type(s: &mut Session) -> Result<()> {
comp(s, "Kinetic Type", 1920, 1080, 4.0, "#111111")?;
let bg = solid(s, "Background", "#F2EDE4")?;
let _ = bg;
let words = [("MAKE", 380.0, "#111111", 0.2), ("IT", 560.0, "#E8452C", 0.7), ("MOVE", 740.0, "#111111", 1.2)];
let mut ids = vec![];
for (w, y, c, t0) in words {
let l = text(s, w, [960.0, y], 170.0, c)?;
keys(s, l, "transform/scale", &[(t0, sc(0.0)), (t0 + 0.25, sc(118.0)), (t0 + 0.4, sc(100.0))]);
ids.push(l);
}
for (k, l) in ids.into_iter().enumerate() {
expose(s, l, "text/sourceText", &format!("Word {}", k + 1))?;
}
finish(s, "Kinetic Type", 2.0)
}
fn social_square(s: &mut Session) -> Result<()> {
comp(s, "Social Square", 1080, 1080, 6.0, "#0E1A2B")?;
let bg = solid(s, "Background", "#000000")?;
gradient(s, bg, [0.0, 0.0], [1080.0, 1080.0], "#0F4C5C", "#0B1320", false)?;
let dot = shape(s, "Accent Circle", "ellipse", [400.0, 400.0], "#FF6B6B", [830.0, 250.0])?;
keys(s, dot, "transform/scale", &[(0.0, sc(0.0)), (0.8, sc(100.0)), (6.0, sc(112.0))]);
let head = text(s, "Big news\ndrops today", [540.0, 600.0], 104.0, "#FFFFFF")?;
keys(s, head, "transform/opacity", &[(0.3, op(0.0)), (1.0, op(100.0))]);
let handle = text(s, "@yourhandle", [540.0, 930.0], 44.0, "#BFE9F0")?;
keys(s, handle, "transform/opacity", &[(0.9, op(0.0)), (1.5, op(100.0))]);
expose(s, head, "text/sourceText", "Headline")?;
expose(s, handle, "text/sourceText", "Handle")?;
expose(s, dot, "contents/group#1/contents/fill/color", "Accent Color")?;
finish(s, "Social Square", 2.0)
}
fn vertical_story(s: &mut Session) -> Result<()> {
comp(s, "Vertical Story", 1080, 1920, 8.0, "#120E24")?;
let bg = solid(s, "Background", "#000000")?;
gradient(s, bg, [540.0, 0.0], [540.0, 1920.0], "#FF8A3D", "#4A1FB8", false)?;
let card = shape(s, "Card", "rounded", [880.0, 820.0], "#FFFFFF", [540.0, 860.0])?;
set(s, card, "transform/opacity", op(18.0));
keys(s, card, "transform/position", &[(0.0, p3(540.0, 1100.0)), (0.8, p3(540.0, 860.0))]);
let head = text(s, "NEW\nDROP", [540.0, 800.0], 200.0, "#FFFFFF")?;
keys(s, head, "transform/scale", &[(0.3, sc(80.0)), (1.1, sc(100.0))]);
keys(s, head, "transform/opacity", &[(0.3, op(0.0)), (0.9, op(100.0))]);
let cta = text(s, "Swipe up ↑", [540.0, 1640.0], 56.0, "#FFFFFF")?;
keys(
s,
cta,
"transform/position",
&[(1.0, p3(540.0, 1640.0)), (1.6, p3(540.0, 1610.0)), (2.2, p3(540.0, 1640.0)), (2.8, p3(540.0, 1610.0)), (3.4, p3(540.0, 1640.0))],
);
expose(s, head, "text/sourceText", "Headline")?;
expose(s, cta, "text/sourceText", "Call to Action")?;
finish(s, "Vertical Story", 1.5)
}
fn slideshow(s: &mut Session) -> Result<()> {
comp(s, "Slideshow", 1920, 1080, 9.0, "#000000")?;
let slides = [
("Slide 1 (replace with a photo)", "#3A6EA5", "#0F2238", "First caption"),
("Slide 2", "#C0603A", "#3A160A", "Second caption"),
("Slide 3", "#4F8A4B", "#13260F", "Third caption"),
];
let mut caps = vec![];
// Bottom to top: each later slide fades in over the one before.
for (k, (name, a, b, cap)) in slides.into_iter().enumerate() {
let t0 = k as f64 * 3.0;
let l = solid(s, name, "#000000")?;
gradient(s, l, [300.0, 200.0], [1700.0, 1000.0], a, b, false)?;
keys(s, l, "transform/scale", &[(t0, sc(100.0)), (t0 + 3.6, sc(112.0))]);
// A simple placeholder picture: a sun over a hill.
let sun = shape(s, &format!("Sun {}", k + 1), "ellipse", [260.0, 260.0], "#FFE7A8", [1380.0 - 300.0 * k as f64, 330.0])?;
let hill = shape(s, &format!("Hill {}", k + 1), "ellipse", [2600.0, 900.0], b, [700.0 + 400.0 * k as f64, 1150.0])?;
keys(s, sun, "transform/position", &[(t0, p3(1380.0 - 300.0 * k as f64, 330.0)), (t0 + 3.6, p3(1420.0 - 300.0 * k as f64, 300.0))]);
if k > 0 {
for x in [l, sun, hill] {
keys(s, x, "transform/opacity", &[(t0 - 0.6, op(0.0)), (t0, op(100.0))]);
}
}
let c = text(s, cap, [960.0, 960.0], 54.0, "#FFFFFF")?;
let fade_in = if k == 0 { 0.2 } else { t0 };
keys(s, c, "transform/opacity", &[(fade_in, op(0.0)), (fade_in + 0.5, op(100.0)), (t0 + 2.5, op(100.0)), (t0 + 2.9, op(0.0))]);
caps.push(c);
}
for (k, c) in caps.into_iter().enumerate() {
expose(s, c, "text/sourceText", &format!("Caption {}", k + 1))?;
}
finish(s, "Slideshow", 1.5)
}
fn text_orbit(s: &mut Session) -> Result<()> {
comp(s, "3D Text Orbit", 1920, 1080, 8.0, "#070910")?;
let floor = shape(s, "Floor", "ellipse", [1000.0, 1000.0], "#1C2740", [960.0, 760.0])?;
ex(s, "layer.setSwitch", json!({"layers": [floor.0], "switch": "threeD", "value": true}))?;
set(s, floor, "transform/position", KV::Vec3([960.0, 760.0, 0.0]));
set(s, floor, "transform/rotationX", op(90.0));
let title = text(s, "ORBIT", [960.0, 520.0], 220.0, "#FFFFFF")?;
ex(s, "layer.setSwitch", json!({"layers": [title.0], "switch": "threeD", "value": true}))?;
let sub = text(s, "in three dimensions", [960.0, 640.0], 48.0, "#8FB8FF")?;
ex(s, "layer.setSwitch", json!({"layers": [sub.0], "switch": "threeD", "value": true}))?;
set(s, sub, "transform/position", KV::Vec3([960.0, 640.0, -120.0]));
let cam = layer_of(&ex(s, "layer.newCamera", json!({"name": "Orbit Camera", "type": "twoNode", "preset": "35mm", "poi": [960.0, 560.0, 0.0]}))?)?;
// A circle around the title (radius 1500) in eight eased steps.
let n = 8;
let list: Vec<(f64, KV)> = (0..=n)
.map(|i| {
let a = (-50.0 + 100.0 * i as f64 / n as f64).to_radians();
(8.0 * i as f64 / n as f64, KV::Vec3([960.0 + 1500.0 * a.sin(), 380.0, -1500.0 * a.cos()]))
})
.collect();
let rate = s.active_comp().map(|c| c.frame_rate).unwrap_or(effectcraft_time::FrameRate::FPS_30);
edit_layer(s, cam, |ly| {
if let Some(p) = ly.props.prop_mut("transform/position") {
p.keys = list.iter().map(|(t, v)| Keyframe::new(rate.snap_nearest(Tick::from_seconds_f64(*t)), v.clone())).collect();
}
if let Some(p) = ly.props.prop_mut("transform/poi") {
p.value = KV::Vec3([960.0, 560.0, 0.0]);
}
});
expose(s, title, "text/sourceText", "Title")?;
finish(s, "3D Text Orbit", 3.0)
}
// ------------------------------------------------------------------------- built-in access
fn builtin(id: &str) -> Option<&'static Builtin> {
BUILTINS.iter().find(|b| b.id == id)
}
/// Build a built-in template's project (the main comp is the one Essential Graphics names).
pub fn build_builtin(id: &str) -> Result<Project> {
let b = builtin(id).ok_or_else(|| bad("templates.create", format!("unknown template `{id}` (see templates.list)")))?;
let mut s = Session::default();
(b.build)(&mut s).map_err(|e| EngineError::Other(format!("template {id}: {e}")))?;
let mut p = (*s.project).clone();
p.fix_next_id();
Ok(p)
}
/// The main composition: the one with Essential Graphics controls, else the first comp.
pub fn main_comp(p: &Project) -> Option<ItemId> {
p.comps().find(|(_, c)| c.essential.as_ref().is_some_and(|e| !e.controls.is_empty())).or_else(|| p.comps().next()).map(|(id, _)| *id)
}
fn controls_of(p: &Project, cid: Option<ItemId>) -> Vec<String> {
cid.and_then(|c| p.comp(c)).and_then(|c| c.essential.as_ref()).map(|e| e.flat().iter().map(|c| c.name.clone()).collect()).unwrap_or_default()
}
fn info_from(id: String, name: String, description: String, category: String, builtin: bool, p: &Project) -> TemplateInfo {
let cid = main_comp(p);
let c = cid.and_then(|c| p.comp(c));
TemplateInfo {
id,
name,
description,
category,
builtin,
width: c.map(|c| c.width).unwrap_or(0),
height: c.map(|c| c.height).unwrap_or(0),
frame_rate: c.map(|c| c.frame_rate.as_f64()).unwrap_or(0.0),
duration: c.map(|c| c.duration.seconds()).unwrap_or(0.0),
controls: controls_of(p, cid),
}
}
/// Built projects (and thumbnails) are cached for the process: they never change.
type Cache = Mutex<std::collections::HashMap<String, (Project, Option<String>)>>;
fn cache() -> &'static Cache {
static C: OnceLock<Cache> = OnceLock::new();
C.get_or_init(Default::default)
}
fn cached_builtin(id: &str) -> Result<Project> {
if let Some((p, _)) = cache().lock().ok().and_then(|c| c.get(id).cloned()) {
return Ok(p);
}
let p = build_builtin(id)?;
if let Ok(mut c) = cache().lock() {
c.insert(id.to_string(), (p.clone(), None));
}
Ok(p)
}
// ------------------------------------------------------------------------- thumbnails
/// Letterbox RGBA pixels into a THUMB_W×THUMB_H RGB thumbnail, as hex text (`thumb.txt`).
pub fn encode_thumb(w: u32, h: u32, rgba: &[u8]) -> String {
let (w, h) = (w as usize, h as usize);
let k = (THUMB_W as f32 / w.max(1) as f32).min(THUMB_H as f32 / h.max(1) as f32);
let (dw, dh) = (w as f32 * k, h as f32 * k);
let (ox, oy) = ((THUMB_W as f32 - dw) / 2.0, (THUMB_H as f32 - dh) / 2.0);
let mut out = String::with_capacity(THUMB_W * THUMB_H * 6);
for y in 0..THUMB_H {
for x in 0..THUMB_W {
let (fx, fy) = ((x as f32 + 0.5 - ox) / k, (y as f32 + 0.5 - oy) / k);
let c = if fx >= 0.0 && fy >= 0.0 && (fx as usize) < w && (fy as usize) < h {
let i = (fy as usize * w + fx as usize) * 4;
[rgba[i], rgba[i + 1], rgba[i + 2]]
} else {
[0x16, 0x17, 0x1c]
};
for v in c {
out.push_str(&format!("{v:02x}"));
}
}
}
out
}
/// Decode a thumbnail to RGB bytes (THUMB_W×THUMB_H×3).
pub fn decode_thumb(text: &str) -> Option<Vec<u8>> {
let t = text.trim();
if t.len() != THUMB_W * THUMB_H * 6 {
return None;
}
(0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect()
}
/// Render a project's main comp at its poster time into a thumbnail.
pub fn render_thumb(p: &Project) -> Option<String> {
let cid = main_comp(p)?;
let t = p.comp(cid)?.poster_time;
let mut s = Session::default();
s.replace_project(p.clone(), None);
let (w, h, rgba) = s.render_rgba8(cid, t, (THUMB_W * 2) as u32).ok()?;
Some(encode_thumb(w, h, &rgba))
}
/// A template's thumbnail (hex RGB, THUMB_W×THUMB_H); built-in ones are rendered on first use.
pub fn thumbnail(s: &Session, id: &str) -> Result<String> {
if let Some(name) = id.strip_prefix("user/") {
let bytes = read_user(s, name)?;
let entries = effectcraft_lottie::zip::read_stored(&bytes);
if let Some((_, t)) = entries.iter().find(|(k, _)| k == "thumb.txt") {
return Ok(String::from_utf8_lossy(t).into_owned());
}
let (_, p, _) = read_template(&bytes).map_err(|e| bad("templates.thumbnail", e))?;
return render_thumb(&p).ok_or_else(|| bad("templates.thumbnail", "nothing to render"));
}
if let Some(Some(t)) = cache().lock().ok().and_then(|c| c.get(id).map(|e| e.1.clone())) {
return Ok(t);
}
let p = cached_builtin(id)?;
let t = render_thumb(&p).ok_or_else(|| bad("templates.thumbnail", "nothing to render"))?;
if let Ok(mut c) = cache().lock() {
c.insert(id.to_string(), (p, Some(t.clone())));
}
Ok(t)
}
// ------------------------------------------------------------------------- user templates
/// Where user templates are written: `<config dir>/Templates` (None: in the store itself).
fn user_dir(s: &Session) -> Option<PathBuf> {
s.config.as_ref()?.dir().map(|d| d.join(TEMPLATES_DIR))
}
/// File names of the user templates (`*.ectemplate`), sorted.
pub fn user_files(s: &Session) -> Vec<String> {
let Some(cfg) = &s.config else { return vec![] };
let mut v: Vec<String> = match user_dir(s) {
Some(d) => cfg.files().list(&d).into_iter().map(|(n, _)| n).collect(),
None => cfg.list(TEMPLATES_DIR),
};
v.retain(|n| n.to_ascii_lowercase().ends_with(&format!(".{EXT}")));
v.sort();
v
}
fn check_name(name: &str) -> Result<()> {
if name.is_empty() || name.contains(['/', '\\']) || name.starts_with('.') {
return Err(bad("templates", format!("bad template file name `{name}`")));
}
Ok(())
}
fn read_user(s: &Session, file: &str) -> Result<Vec<u8>> {
check_name(file)?;
let cfg = s.config.as_ref().ok_or_else(|| bad("templates", "no settings store: user templates are unavailable"))?;
match user_dir(s) {
Some(d) => s.services.read_file(&d.join(file).to_string_lossy()).map_err(|e| EngineError::Other(format!("cannot read template {file}: {e}"))),
None => cfg
.read(&format!("{TEMPLATES_DIR}/{file}"))
.and_then(|t| effectcraft_track::roto::rle::base64_decode(&t))
.ok_or_else(|| bad("templates", format!("no user template `{file}`"))),
}
}
fn write_user(s: &Session, file: &str, bytes: &[u8]) -> Result<String> {
check_name(file)?;
let cfg = s.config.as_ref().ok_or_else(|| bad("templates.saveAs", "no settings store: user templates are unavailable"))?;
match user_dir(s) {
Some(d) => {
let path = d.join(file);
cfg.files().write(&path, bytes).map_err(|e| EngineError::Other(format!("cannot write {}: {e}", path.display())))?;
Ok(path.to_string_lossy().into_owned())
}
None => {
let name = format!("{TEMPLATES_DIR}/{file}");
cfg.write(&name, &effectcraft_track::roto::rle::base64_encode(bytes)).map_err(|e| EngineError::Other(format!("cannot write {name}: {e}")))?;
Ok(name)
}
}
}
fn remove_user(s: &Session, file: &str) -> Result<()> {
check_name(file)?;
let cfg = s.config.as_ref().ok_or_else(|| bad("templates.delete", "no settings store"))?;
if !user_files(s).iter().any(|f| f == file) {
return Err(bad("templates.delete", format!("no user template `{file}`")));
}
match user_dir(s) {
Some(d) => cfg.files().remove(&d.join(file)),
None => cfg.remove(&format!("{TEMPLATES_DIR}/{file}")),
}
.map_err(|e| EngineError::Other(format!("cannot delete {file}: {e}")))
}
/// A safe file name for a template name.
pub fn file_name_for(name: &str) -> String {
let clean: String = name.chars().map(|c| if c.is_alphanumeric() || " -_()".contains(c) { c } else { '_' }).collect();
let clean = clean.trim().trim_start_matches('.').to_string();
format!("{}.{EXT}", if clean.is_empty() { "Template".into() } else { clean })
}
fn str_of(m: &Value, k: &str) -> String {
m.get(k).and_then(Value::as_str).unwrap_or_default().to_string()
}
/// Every template: built-in ones first, then the user's.
pub fn list(s: &Session) -> Vec<TemplateInfo> {
let mut v = vec![];
for b in BUILTINS {
if let Ok(p) = cached_builtin(b.id) {
v.push(info_from(b.id.into(), b.name.into(), b.description.into(), b.category.into(), true, &p));
}
}
for f in user_files(s) {
let Ok(bytes) = read_user(s, &f) else { continue };
let Ok((m, p, _)) = read_template(&bytes) else { continue };
let stem = f.strip_suffix(&format!(".{EXT}")).unwrap_or(&f).to_string();
let name = Some(str_of(&m, "name")).filter(|n| !n.is_empty()).unwrap_or(stem);
let cat = Some(str_of(&m, "category")).filter(|n| !n.is_empty()).unwrap_or_else(|| "My Templates".into());
v.push(info_from(format!("user/{f}"), name, str_of(&m, "description"), cat, false, &p));
}
v
}
/// The project of a template id (or of an `.ectemplate` file).
fn project_of(s: &Session, id: Option<&str>, path: Option<&str>) -> Result<(Project, String)> {
if let Some(path) = path {
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let (m, p, _) = read_template(&bytes).map_err(|e| bad("templates.create", e))?;
return Ok((p, str_of(&m, "name")));
}
let id = id.ok_or_else(|| bad("templates.create", "missing `id` (see templates.list) or `path`"))?;
if let Some(f) = id.strip_prefix("user/") {
let (m, p, _) = read_template(&read_user(s, f)?).map_err(|e| bad("templates.create", e))?;
return Ok((p, str_of(&m, "name")));
}
let b = builtin(id).ok_or_else(|| bad("templates.create", format!("unknown template `{id}` (see templates.list)")))?;
Ok((cached_builtin(id)?, b.name.to_string()))
}
// ------------------------------------------------------------------------- commands
fn list_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let with_thumbs = p.get("thumbnails").and_then(Value::as_bool).unwrap_or(false);
let mut out = vec![];
for t in list(s) {
let mut j = serde_json::to_value(&t).unwrap_or_default();
if with_thumbs {
j["thumbnail"] = json!(thumbnail(s, &t.id).ok());
}
out.push(j);
}
Ok(json!(out))
}
fn thumb_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_p(p, "id").ok_or_else(|| bad("templates.thumbnail", "missing `id`"))?;
Ok(json!({"id": id, "width": THUMB_W, "height": THUMB_H, "rgb": thumbnail(s, id)?}))
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let (proj, name) = project_of(s, str_p(p, "id"), str_p(p, "path"))?;
let cid = main_comp(&proj);
let poster = cid.and_then(|c| proj.comp(c)).map(|c| c.poster_time).unwrap_or(Tick::ZERO);
s.replace_project(proj, None);
s.update_sentinel();
if let Some(c) = cid {
s.open_comp(c);
s.set_time(poster);
}
s.toast(format!("New project from template “{name}”"));
Ok(json!({"template": name, "comp": cid.map(|c| c.0), "untitled": true}))
}
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "templates.saveAs";
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
if s.project.comps().next().is_none() {
return Err(bad(C, "the project has no compositions"));
}
let file = file_name_for(&name);
let overwrite = p.get("overwrite").and_then(Value::as_bool).unwrap_or(true);
if !overwrite && user_files(s).contains(&file) {
return Err(bad(C, format!("a template named “{name}” already exists")));
}
let cid = s.active_comp_id().or_else(|| main_comp(&s.project)).ok_or(EngineError::NoComp)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
// The active comp becomes the template's main comp: give it the poster time it shows now.
let mut proj = (*s.project).clone();
if let Some(c) = proj.comp_mut(cid)
&& c.poster_time == Tick::ZERO
{
c.poster_time = s.time_of(cid);
}
let comp_name = proj.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let manifest = json!({
"format": TEMPLATE_FORMAT,
"version": TEMPLATE_VERSION,
"kind": "project",
"generator": format!("EffectCraft {}", env!("CARGO_PKG_VERSION")),
"name": name,
"description": str_p(p, "description").unwrap_or_default(),
"category": str_p(p, "category").filter(|c| !c.is_empty()).unwrap_or("My Templates"),
"comp": cid.0,
"compName": comp_name,
"width": comp.width,
"height": comp.height,
"frameRate": comp.frame_rate.as_f64(),
"duration": comp.duration.seconds(),
"controls": controls_of(&proj, Some(cid)),
"poster": "poster.png",
"thumbnail": "thumb.txt",
});
let t = s.time_of(cid);
let mut entries: Vec<(String, Vec<u8>)> =
vec![("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default()), ("project.ecproj".into(), proj.to_json().into_bytes())];
if let Ok((w, h, rgba)) = s.render_rgba8(cid, t, 320) {
if let Ok(png) = crate::commands::comp_more::encode_png(&rgba, w, h) {
entries.push(("poster.png".into(), png));
}
entries.push(("thumb.txt".into(), encode_thumb(w, h, &rgba).into_bytes()));
}
let bytes = effectcraft_lottie::zip::store(&entries);
let at = write_user(s, &file, &bytes)?;
s.toast(format!("Saved template “{name}”"));
Ok(json!({"id": format!("user/{file}"), "name": name, "path": at, "bytes": bytes.len()}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_p(p, "id").ok_or_else(|| bad("templates.delete", "missing `id`"))?;
let file = id.strip_prefix("user/").ok_or_else(|| bad("templates.delete", "only user templates (`user/…`) can be deleted"))?;
remove_user(s, file)?;
Ok(json!({"deleted": id}))
}
fn has_comps(s: &Session) -> std::result::Result<(), String> {
if s.project.comps().next().is_some() { Ok(()) } else { Err("the project has no compositions".into()) }
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!(
"templates.list",
"Project Templates",
"{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),
cmd!("templates.create", "New Project from Template", [], None, "{id (templates.list) | path (.ectemplate)} → an untitled copy", always, create),
cmd!(
"templates.saveAs",
"Save as Template...",
["File"],
None,
"{name, description?, category?, overwrite?: bool (default true)} → {id, path}",
has_comps,
save_as
),
cmd!("templates.delete", "Delete Template", [], None, "{id: user/<file>}", always, delete),
cmd!("file.newFromTemplate", "New Project from Template...", ["File", "New"], None, "{} → the Home screen's Templates tab", always, |s, p| {
crate::commands::frontend(s, "file.newFromTemplate", p)
}),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{DirConfig, MemoryConfig};
#[test]
fn every_builtin_builds_with_controls_and_renders() {
let s = Session::default();
let l = list(&s);
assert_eq!(l.len(), BUILTINS.len());
assert!(l.len() >= 8);
for t in &l {
assert!(t.builtin && !t.controls.is_empty(), "{t:?}");
assert!(t.width > 0 && t.duration > 0.0, "{t:?}");
let th = decode_thumb(&thumbnail(&s, &t.id).unwrap()).unwrap();
// Not a blank frame: some variety in the pixels.
let distinct: std::collections::BTreeSet<[u8; 3]> = th.chunks_exact(3).map(|c| [c[0] / 16, c[1] / 16, c[2] / 16]).collect();
assert!(distinct.len() > 3, "{}: thumbnail looks blank", t.id);
}
let sq = l.iter().find(|t| t.id == "social-square").unwrap();
assert_eq!((sq.width, sq.height), (1080, 1080));
let v = l.iter().find(|t| t.id == "vertical-story").unwrap();
assert_eq!((v.width, v.height), (1080, 1920));
assert_eq!(l.iter().find(|t| t.id == "lower-third").unwrap().controls, ["Name", "Title", "Bar Color"]);
}
#[test]
fn create_opens_an_untitled_copy() {
let mut s = Session::default();
let r = s.execute("templates.create", json!({"id": "title-card"})).unwrap();
assert_eq!(r["template"], "Title Card");
assert!(s.path.is_none());
let cid = s.active_comp_id().unwrap();
assert_eq!(Some(cid.0), r["comp"].as_u64());
assert!(s.time() > Tick::ZERO);
// The Essential Graphics controls came along and drive the title.
let eg = s.execute("essential.list", json!({})).unwrap();
assert_eq!(eg["controls"][0]["name"], "Title");
assert!(s.execute("templates.create", json!({"id": "nope"})).is_err());
assert!(s.execute("templates.create", json!({})).is_err());
}
fn user_roundtrip(mut s: Session) {
s.execute("templates.create", json!({"id": "kinetic-type"})).unwrap();
s.execute("layer.newSolid", json!({"name": "Mine", "color": "#ff00ff"})).unwrap();
let r = s.execute("templates.saveAs", json!({"name": "My Promo", "description": "Mine", "category": "Work"})).unwrap();
assert_eq!(r["id"], "user/My Promo.ectemplate");
let l = list(&s);
let mine = l.iter().find(|t| !t.builtin).unwrap();
assert_eq!((mine.name.as_str(), mine.category.as_str(), mine.description.as_str()), ("My Promo", "Work", "Mine"));
assert_eq!(mine.controls, ["Word 1", "Word 2", "Word 3"]);
assert!(decode_thumb(&thumbnail(&s, &mine.id).unwrap()).is_some());
let j = s.execute("templates.list", json!({"thumbnails": true})).unwrap();
assert!(j.as_array().unwrap().iter().all(|t| t["thumbnail"].is_string()));
// Overwrite protection.
assert!(s.execute("templates.saveAs", json!({"name": "My Promo", "overwrite": false})).is_err());
// Open: an untitled copy with the added layer.
s.execute("file.newProject", json!({})).unwrap();
s.execute("templates.create", json!({"id": mine.id})).unwrap();
assert!(s.path.is_none());
assert!(s.active_comp().unwrap().layers.iter().any(|l| l.name == "Mine"));
// Built-ins can't be deleted; user ones can.
assert!(s.execute("templates.delete", json!({"id": "title-card"})).is_err());
s.execute("templates.delete", json!({"id": mine.id})).unwrap();
assert!(list(&s).iter().all(|t| t.builtin));
assert!(s.execute("templates.delete", json!({"id": "user/My Promo.ectemplate"})).is_err());
assert!(s.execute("templates.create", json!({"id": "user/../x.ectemplate"})).is_err());
}
#[test]
fn user_templates_in_a_memory_store() {
let s = Session { config: Some(Arc::new(MemoryConfig::default())), ..Default::default() };
user_roundtrip(s);
}
#[test]
fn user_templates_in_the_config_folder() {
let dir = std::env::temp_dir().join(format!("ec-templates-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session { config: Some(Arc::new(DirConfig::new(&dir))), ..Default::default() };
s.execute("templates.create", json!({"id": "lower-third"})).unwrap();
s.execute("templates.saveAs", json!({"name": "On Disk"})).unwrap();
let f = dir.join(TEMPLATES_DIR).join("On Disk.ectemplate");
// A real template file: the Essential Graphics reader understands it too.
let info = s.execute("essential.templateInfo", json!({"path": f.to_string_lossy()})).unwrap();
assert_eq!((info["kind"].as_str(), info["name"].as_str()), (Some("project"), Some("On Disk")));
s.execute("templates.create", json!({"path": f.to_string_lossy()})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let dir2 = std::env::temp_dir().join(format!("ec-templates-b-{}", std::process::id()));
let s = Session { config: Some(Arc::new(DirConfig::new(&dir2))), ..Default::default() };
user_roundtrip(s);
let _ = std::fs::remove_dir_all(&dir2);
}
/// Writes every built-in template's poster frame to target/test-out/templates for review.
#[test]
#[ignore]
fn dump_posters() {
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/templates");
std::fs::create_dir_all(&d).unwrap();
for b in BUILTINS {
let p = build_builtin(b.id).unwrap();
let cid = main_comp(&p).unwrap();
let t = p.comp(cid).unwrap().poster_time;
let mut s = Session::default();
s.replace_project(p, None);
let (w, h, rgba) = s.render_rgba8(cid, t, 640).unwrap();
let png = crate::commands::comp_more::encode_png(&rgba, w, h).unwrap();
std::fs::write(d.join(format!("{}.png", b.id)), png).unwrap();
}
}
#[test]
fn file_names_are_safe() {
assert_eq!(file_name_for("A/B: C"), "A_B_ C.ectemplate");
assert_eq!(file_name_for(""), "Template.ectemplate");
assert!(!file_name_for("...").starts_with('.'));
assert!(check_name("../x").is_err());
}
}

View File

@ -221,3 +221,68 @@ fn feet_and_frames_display_and_entry() {
assert_eq!(s.project.settings.time_display, TimeDisplayStyle::Frames);
assert_eq!(serde_json::to_value(TimeDisplayStyle::Feet16).unwrap(), json!("Feet16"));
}
/// View ▸ Simulate Output ▸ My Custom RGB…: typed-in primaries/white/gamma or an ICC profile,
/// kept in Settings.
#[test]
fn my_custom_rgb_simulation() {
let mut s = session();
s.config = Some(std::sync::Arc::new(crate::config::MemoryConfig::default()));
s.execute("file.projectSettings", json!({"workingSpace": "srgb"})).unwrap();
// The default definition: Rec. 709 primaries, D65, gamma 2.2.
let d = crate::viewer::CustomRgb::default();
assert_eq!((d.white, d.gamma), ([0.3127, 0.3290], 2.2));
// Rec. 709 / D65 / gamma 1.8 behaves exactly like Legacy Macintosh RGB.
s.execute("view.simulateOutput", json!({"profile": "mac18", "preserveRgb": true})).unwrap();
let mut a = px(128, 90, 200);
DisplayColor::of(&s).unwrap().apply(&mut a);
let r = s
.execute(
"view.customRgb",
json!({"name": "Old Mac", "red": [0.64, 0.33], "green": [0.30, 0.60], "blue": [0.15, 0.06], "white": [0.3127, 0.3290], "gamma": 1.8, "srgbCurve": false, "preserveRgb": true}),
)
.unwrap();
assert_eq!((r["name"].as_str(), r["active"].as_bool()), (Some("Old Mac"), Some(true)));
assert_eq!(s.state.viewer.simulation, Simulation { profile: SimProfile::MyCustom, preserve_rgb: true });
let mut b = px(128, 90, 200);
DisplayColor::of(&s).unwrap().apply(&mut b);
assert_eq!(a, b);
assert_eq!(crate::menus::checked(&s, "view.customRgb", &json!({})), Some(true));
assert_eq!(crate::menus::checked(&s, "view.simulateOutput", &json!({"profile": "myCustom"})), Some(true));
// Persisted in Settings.
let saved = s.config.as_ref().unwrap().read(crate::prefs::PREFS_FILE).unwrap();
assert!(saved.contains("\"customRgb\"") && saved.contains("Old Mac"), "{saved}");
// Start from a built-in profile.
s.execute("view.customRgb", json!({"from": "rec2020", "preserveRgb": false})).unwrap();
assert_eq!(s.prefs.custom_rgb.red, [0.708, 0.292]);
// An ICC profile: the primaries, white and curve come from the file.
let p3 = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
let icc = effectcraft_color::icc::write_matrix_profile("Studio Monitor", p3, [0.3127, 0.3290], 2.4);
let path = std::env::temp_dir().join(format!("ec-custom-{}.icc", std::process::id()));
std::fs::write(&path, icc).unwrap();
let r = s.execute("view.customRgb", json!({"icc": path.to_string_lossy()})).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(r["name"], "Studio Monitor");
let c = s.prefs.custom_rgb.clone();
assert!((c.green[0] - 0.265).abs() < 1e-3 && (c.green[1] - 0.690).abs() < 1e-3, "{c:?}");
assert!((c.gamma - 2.4).abs() < 0.01 && !c.srgb_curve);
assert!(DisplayColor::of(&s).is_some());
// The same path again keeps typed-in edits; an empty path forgets the profile.
s.execute("view.customRgb", json!({"icc": c.icc, "gamma": 2.0})).unwrap();
assert_eq!(s.prefs.custom_rgb.gamma, 2.0);
s.execute("view.customRgb", json!({"icc": ""})).unwrap();
assert!(s.prefs.custom_rgb.icc.is_empty());
// Bad definitions are refused and change nothing.
let before = s.prefs.custom_rgb.clone();
assert!(s.execute("view.customRgb", json!({"red": [0.3, 0.6], "green": [0.3, 0.6]})).is_err());
assert!(s.execute("view.customRgb", json!({"gamma": 0.0})).is_err());
assert!(s.execute("view.customRgb", json!({"white": [1.2, 0.3]})).is_err());
assert!(s.execute("view.customRgb", json!({"icc": "/no/such/profile.icc"})).is_err());
assert_eq!(s.prefs.custom_rgb, before);
// Define without simulating it.
s.execute("view.simulateOutput", json!({"profile": "none"})).unwrap();
s.execute("view.customRgb", json!({"reset": true, "apply": false})).unwrap();
assert_eq!(s.state.viewer.simulation.profile, SimProfile::None);
assert_eq!(s.prefs.custom_rgb, crate::viewer::CustomRgb::default());
assert_eq!(s.execute("view.displayColor", json!({})).unwrap()["customRgb"]["gamma"], json!(2.2));
}

View File

@ -383,6 +383,8 @@ pub enum SimProfile {
Pal,
/// Legacy Macintosh RGB: Rec. 709 primaries, gamma 1.8.
Mac18,
/// My Custom RGB: the user's own device ([`CustomRgb`], in Settings).
MyCustom,
}
/// A transfer curve of a simulated or display profile.
@ -421,7 +423,7 @@ impl Curve {
const P709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
impl SimProfile {
pub const ALL: [SimProfile; 9] = [
pub const ALL: [SimProfile; 10] = [
SimProfile::None,
SimProfile::Rec709,
SimProfile::Ntsc,
@ -431,6 +433,7 @@ impl SimProfile {
SimProfile::Rec2020,
SimProfile::P3,
SimProfile::Linear,
SimProfile::MyCustom,
];
pub fn id(self) -> &'static str {
@ -444,6 +447,7 @@ impl SimProfile {
SimProfile::Ntsc => "ntsc",
SimProfile::Pal => "pal",
SimProfile::Mac18 => "mac18",
SimProfile::MyCustom => "myCustom",
}
}
@ -458,6 +462,7 @@ impl SimProfile {
SimProfile::Ntsc => "SDTV NTSC",
SimProfile::Pal => "SDTV PAL",
SimProfile::Mac18 => "Legacy Macintosh RGB (Gamma 1.8)",
SimProfile::MyCustom => "My Custom RGB",
}
}
@ -465,7 +470,7 @@ impl SimProfile {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
SimProfile::ALL
.into_iter()
.find(|p| p.id() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == k)
.find(|p| p.id().to_ascii_lowercase() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == k)
.or(match k.as_str() {
"off" => Some(SimProfile::None),
"hdtv" | "bt709" => Some(SimProfile::Rec709),
@ -476,10 +481,10 @@ impl SimProfile {
})
}
/// (primaries, curve); `None` for No Output Simulation.
/// (primaries, curve); `None` for No Output Simulation and My Custom RGB (see [`CustomRgb`]).
pub fn space(self) -> Option<([[f64; 2]; 3], Curve)> {
Some(match self {
SimProfile::None => return None,
SimProfile::None | SimProfile::MyCustom => return None,
SimProfile::Rec709 => (P709, Curve::Gamma(2.4)),
SimProfile::Rec2020 => (ColorSpace::Rec2020.primaries(), Curve::Gamma(2.4)),
SimProfile::P3 => (ColorSpace::DisplayP3.primaries(), Curve::Srgb),
@ -501,6 +506,93 @@ pub struct Simulation {
pub preserve_rgb: bool,
}
/// View ▸ Simulate Output ▸ My Custom RGB…: a user-defined output device, kept in Settings
/// (`customRgb`). Defined by primaries and white point (CIE xy) and a gamma or the sRGB curve,
/// typed in or read from an RGB ICC profile.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CustomRgb {
pub name: String,
pub red: [f64; 2],
pub green: [f64; 2],
pub blue: [f64; 2],
pub white: [f64; 2],
pub gamma: f64,
/// The sRGB piecewise curve instead of a pure power curve.
pub srgb_curve: bool,
/// The ICC profile the numbers were read from (empty: typed in).
pub icc: String,
}
impl Default for CustomRgb {
fn default() -> Self {
CustomRgb { name: "My Custom RGB".into(), red: P709[0], green: P709[1], blue: P709[2], white: D65, gamma: 2.2, srgb_curve: false, icc: String::new() }
}
}
impl CustomRgb {
pub fn primaries(&self) -> [[f64; 2]; 3] {
[self.red, self.green, self.blue]
}
pub fn curve(&self) -> Curve {
if self.srgb_curve {
Curve::Srgb
} else if (self.gamma - 1.0).abs() < 1e-9 {
Curve::Linear
} else {
Curve::Gamma(self.gamma as f32)
}
}
/// Chromaticities inside (0, 1) with y > 0, a gamma in 0.1–10, and primaries that span a
/// triangle.
pub fn validate(&self) -> std::result::Result<(), String> {
for (n, c) in [("red", self.red), ("green", self.green), ("blue", self.blue), ("white", self.white)] {
if !(c[0] > 0.0 && c[1] > 0.0 && c[0] < 1.0 && c[1] < 1.0 && c[0] + c[1] <= 1.0) {
return Err(format!("{n}: chromaticity x, y must be in (0, 1) with x + y ≤ 1"));
}
}
if !self.srgb_curve && !(0.1..=10.0).contains(&self.gamma) {
return Err("gamma must be between 0.1 and 10".into());
}
let [r, g, b] = self.primaries();
let area = (g[0] - r[0]) * (b[1] - r[1]) - (b[0] - r[0]) * (g[1] - r[1]);
if area.abs() < 1e-6 {
return Err("the primaries must form a triangle".into());
}
Ok(())
}
/// Take primaries, white and curve from an RGB ICC profile.
pub fn from_icc(bytes: &[u8], path: &str) -> std::result::Result<CustomRgb, String> {
let p = effectcraft_color::icc::parse(bytes)?;
let name = if p.description.trim().is_empty() {
std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "ICC Profile".into())
} else {
p.description.trim().to_string()
};
let gamma = (p.trc.iter().map(|t| t.fit_gamma()).sum::<f64>() / 3.0 * 1000.0).round() / 1000.0;
let r = |c: [f64; 2]| c.map(|v| (v * 1e5).round() / 1e5);
Ok(CustomRgb {
name,
red: r(p.primaries[0]),
green: r(p.primaries[1]),
blue: r(p.primaries[2]),
white: r(p.white),
gamma,
srgb_curve: p.trc[0].is_srgb(),
icc: path.to_string(),
})
}
fn prof(&self) -> Prof {
let m = space::rgb_to_xyz(self.primaries(), self.white);
let xyz = if self.white == D65 { m } else { space::mul(&space::bradford(self.white, D65), &m) };
Prof { xyz, curve: self.curve() }
}
}
/// An RGB space for viewer conversions: primaries (D65) and curve.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Prof {
@ -562,7 +654,16 @@ impl DisplayColor {
/// After Effects); `linear`: the working space is linear; `source`: the space rendered
/// frames are in (the project's output space, sRGB by default); `dcm`: Use Display Color
/// Management; `display`: the monitor's space.
pub fn new(working: Option<ColorSpace>, linear: bool, source: ColorSpace, dcm: bool, display: ColorSpace, sim: Simulation) -> Option<DisplayColor> {
#[allow(clippy::too_many_arguments)]
pub fn new(
working: Option<ColorSpace>,
linear: bool,
source: ColorSpace,
dcm: bool,
display: ColorSpace,
sim: Simulation,
custom: &CustomRgb,
) -> Option<DisplayColor> {
let ws = working?;
let src = Prof::of(source, false);
if !dcm {
@ -570,8 +671,11 @@ impl DisplayColor {
let raw = Conv::new(src, Prof::of(ws, linear));
return Some(DisplayColor { sim: None, display: Some(raw) });
}
let sim = sim.profile.space().map(|(prims, curve)| {
let out = Prof::new(prims, curve);
let out = match sim.profile {
SimProfile::MyCustom => Some(custom.prof()),
p => p.space().map(|(prims, curve)| Prof::new(prims, curve)),
};
let sim = out.map(|out| {
(
Conv::new(src, out),
Some(if sim.preserve_rgb {
@ -591,7 +695,15 @@ impl DisplayColor {
let display = ColorSpace::parse(&s.prefs.previews.display_profile).unwrap_or(ColorSpace::Srgb);
let v = &s.state.viewer;
let linear = st.working_space.is_some_and(|w| st.linearize || w.is_linear());
DisplayColor::new(st.working_space, linear, st.output_space.unwrap_or(ColorSpace::Srgb), v.display_color_management, display, v.simulation)
DisplayColor::new(
st.working_space,
linear,
st.output_space.unwrap_or(ColorSpace::Srgb),
v.display_color_management,
display,
v.simulation,
&s.prefs.custom_rgb,
)
}
/// One straight colour (0..1 floats).

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

@ -2,30 +2,49 @@
How close EffectCraft is to After Effects 2026, feature by feature, and how much work is left.
## Current status (audit at commit `978e8d7`, 3 October 2026)
## Current status (audit at commit `58163a2`, 3 October 2026, evening)
| Measure | Value |
|---|---|
| **Feature parity, weighted by tier** (P0 ×3, P1 ×2, P2 ×1) | **≈ 94%** counting every partial feature as half done; ≈ 98% using per-feature fractions |
| Unweighted | ≈ 94% (half-credit) / 98% (fractions) |
| P0 / P1 / P2 | ≈ 96% / 92% / 85% (half-credit); 99% / 97% / 96% (fractions) |
| Features done / partial / missing | 80 / 12 / 0 of 92 |
| **Effects** | **298 of 298** After Effects 2026 effects exist, all implemented in full ([effects.md](effects.md)); 166 run on the GPU |
| Disabled menu entries left | 0 (the stub list is empty) |
| Remaining work | ≈ 19–22 agent-hours at the pace measured so far (≈ 58 on the conservative audit scale) |
| **Wall-clock estimate** | **≈ 4–5 hours** with five agents in parallel; ≈ 3 hours for 100% of P0 + P1 |
| **Feature parity, weighted by tier** (P0 ×3, P1 ×2, P2 ×1) | **≈ 98%** counting every partial feature as half done; ≈ 99.6% using per-feature fractions |
| Unweighted | ≈ 97% (half-credit) / 99% (fractions) |
| P0 / P1 / P2 | ≈ 99% / 96% / 90% (half-credit); 99.8% / 99.2% / 97.5% (fractions) |
| Features done / partial / missing | 87 / 5 / 0 of 92 |
| **Effects** | **306** effects (all After Effects 2026 effects), every one implemented in full ([effects.md](effects.md)); 166 run on the GPU |
| Disabled menu entries left | 1, on purpose: Import ▸ Vanishing Point (.vpe), whose format has no public specification |
| Remaining work | ≈ 9–11 agent-hours at the pace measured so far (≈ 29 on the conservative audit scale); ≈ 6–7 without the learned-model items |
| **Wall-clock estimate** | **≈ 2–2.5 hours** with five agents in parallel; ≈ 1.5 hours for 100% of P0 + P1 |
Partial features: GPU coverage (Advanced 3D, more GPU effects), the web app (GPU effects and
the disk cache in the browser; M13.10 moved viewer frames and Roto Brush off the page's thread), Essential Graphics mirrored properties, and the learned-model quality
of Roto Brush and face tracking (both are classical). Since this audit, M9.13 completed all 42
simplified effects, M13.5 added the Learn panel, and M13.8 the Preview panel settings, Align to
Selection / Distribute and Advanced 3D iris DOF, collapsed precomps and extruded strokes. M13.7 added ScriptUI resource strings,
`onDraw` (ScriptUIGraphics draw lists), live `onChanging`, `Socket`, the Essential Graphics
scripting hooks and Font / uniform Scale controls, and the last missing menu items: Save Frame As ▸
Photoshop Layers / ProEXR, Composition ▸ Open in Essential Graphics, File ▸ Watch Folder,
Window ▸ Create Nulls From Paths / VR Comp Editor (built-in panels) and Help ▸ In-App / Online
Tutorials. File ▸ Import ▸ Vanishing Point (.vpe) is present but permanently disabled: the format
has no public specification, so a clean-room implementation is not possible (its tooltip says so).
Partial features:
| id | Tier | Done | What is missing |
|---|---|---|---|
| EFF-5 GPU effects | P1 | 0.85 | GPU kernels for the CC light / transition families, Numbers and Timecode, 3D Channel, the VR family, OCIO / LUT, Time effects and the simulations; Advanced 3D runs with blend modes, track mattes or environment backgrounds composite on the CPU |
| WEB-1 Web app | P0 | 0.9 | GPU effects in the browser (needs WebGPU in the render workers or async readback), a disk cache in browser storage (OPFS), a storage manager, threads inside one engine instance |
| UI-7 Home screen | P1 | 0.95 | A "start from a template" gallery |
| MSK-4 Roto Brush | P2 | 0.85 | Segmentation is classical (graph cut + optical flow), not a learned model |
| TRK-3 Face tracking | P2 | 0.9 | Classical fitter: weak on profile and occluded faces; Rolling Shutter Ripple is approximated |
Since the previous audit, M9.13 completed all 42 simplified effects; M13.4 added HEVC and AV1
export and Opus SILK / hybrid; M13.5 the Learn tab and Extended Viewer; M13.6 and M13.12 text,
images, shadings and transparency groups in PDF/AI/EPS import, variable fonts and PSD warps;
M13.7 ScriptUI resource strings, `onDraw` and `Socket`, Essential Graphics scripting hooks and
the last menu items; M13.8 the Preview panel, Align to Selection / Distribute and Advanced 3D
iris depth of field, collapsed precomps and extruded strokes; M13.9 and M13.13 107 more GPU
effects, a texture pool and per-comp CPU/GPU choice; M13.10 render workers in the browser and
visual editors for curves and palettes; M13.11 Advanced 3D on the GPU and Essential Graphics
mirrors; M13.14–M13.20 puppet pin editing and rigging; M13.15 end-to-end tests through MCP and
the CLI; M13.21 lazy project open, virtualised panels, background auto-save and an audit that
every command is reachable over MCP, the control channel and the CLI.
Out of clean-room scope (no public specification or no permissively licensed data): `.jsxbin`,
Vanishing Point `.vpe`, the predefined CJK CMaps in PDF import, Kodak film emulations. Adobe
service integrations (Team Projects, Libraries, Media Encoder, Dynamic Link, Frame.io, Exchange)
and third-party plug-ins (Cinema 4D, Mocha) are intentionally absent.
### Previous audit (commit `978e8d7`, 3 October 2026): ≈ 94%
80 done / 12 partial / 0 missing.
### Previous audit (commit `fa26ad9`, 2 October 2026, late): ≈ 93%
@ -137,7 +156,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 +348,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).