Merge branch 'worktree-agent-aab5c21557d20742c'
This commit is contained in:
commit
bbe29e7c5b
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -285,9 +285,10 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||
if let Some(b) = p.get("home").and_then(Value::as_bool) {
|
||||
app.ui.start_screen = b;
|
||||
}
|
||||
// Home screen tab: "home" or "learn".
|
||||
// Home screen tab: "home", "templates" or "learn".
|
||||
if let Some(tab) = p.get("homeTab").and_then(Value::as_str) {
|
||||
app.ui.home_learn = tab == "learn";
|
||||
app.ui.home_templates = tab == "templates";
|
||||
}
|
||||
ok(Value::Null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,10 @@ pub struct EffectcraftApp {
|
|||
pub(crate) home_thumbs: std::collections::HashMap<String, Option<egui::TextureHandle>>,
|
||||
/// The (project path, saved revision) whose thumbnail was stored last.
|
||||
pub(crate) home_thumb_saved: Option<(String, u64)>,
|
||||
/// Home ▸ Templates: thumbnail textures by template id (None = not renderable).
|
||||
pub(crate) template_thumbs: std::collections::HashMap<String, Option<egui::TextureHandle>>,
|
||||
/// Home ▸ Templates: the gallery, and the user template files it was listed from.
|
||||
pub(crate) template_list: Option<(Vec<String>, Vec<effectcraft_engine::templates::TemplateInfo>)>,
|
||||
}
|
||||
|
||||
impl EffectcraftApp {
|
||||
|
|
@ -240,6 +244,8 @@ impl EffectcraftApp {
|
|||
dock_rects: vec![],
|
||||
home_thumbs: Default::default(),
|
||||
home_thumb_saved: None,
|
||||
template_thumbs: Default::default(),
|
||||
template_list: None,
|
||||
}
|
||||
.with_ui_commands()
|
||||
}
|
||||
|
|
@ -311,6 +317,7 @@ impl EffectcraftApp {
|
|||
if name == "Learn" {
|
||||
self.ui.start_screen = true;
|
||||
self.ui.home_learn = true;
|
||||
self.ui.home_templates = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -880,6 +880,14 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
|||
"help.inAppTutorials" => {
|
||||
app.ui.start_screen = true;
|
||||
app.ui.home_learn = true;
|
||||
app.ui.home_templates = false;
|
||||
Value::Null
|
||||
}
|
||||
// File ▸ New ▸ New Project from Template…: the Home screen's Templates tab.
|
||||
"file.newFromTemplate" => {
|
||||
app.ui.start_screen = true;
|
||||
app.ui.home_learn = false;
|
||||
app.ui.home_templates = true;
|
||||
Value::Null
|
||||
}
|
||||
"effect.manage" | "anim.browsePresets" => {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ pub enum FieldKind {
|
|||
speed: f64,
|
||||
},
|
||||
Text(String),
|
||||
/// A file path with a Browse… button (the host's open dialog, these extensions).
|
||||
Path {
|
||||
value: String,
|
||||
exts: Vec<String>,
|
||||
},
|
||||
Bool(bool),
|
||||
/// Options as (label, value); `sel` is the chosen index.
|
||||
Choice {
|
||||
|
|
@ -40,6 +45,9 @@ impl Field {
|
|||
pub fn text(key: &str, label: &str, value: &str) -> Field {
|
||||
Field { key: key.into(), label: label.into(), kind: FieldKind::Text(value.into()) }
|
||||
}
|
||||
pub fn path(key: &str, label: &str, value: &str, exts: &[&str]) -> Field {
|
||||
Field { key: key.into(), label: label.into(), kind: FieldKind::Path { value: value.into(), exts: exts.iter().map(|e| e.to_string()).collect() } }
|
||||
}
|
||||
pub fn bool(key: &str, label: &str, value: bool) -> Field {
|
||||
Field { key: key.into(), label: label.into(), kind: FieldKind::Bool(value) }
|
||||
}
|
||||
|
|
@ -67,7 +75,7 @@ impl Form {
|
|||
for f in &self.fields {
|
||||
let v = match &f.kind {
|
||||
FieldKind::Number { value, .. } => json!(value),
|
||||
FieldKind::Text(s) => json!(s),
|
||||
FieldKind::Text(s) | FieldKind::Path { value: s, .. } => json!(s),
|
||||
FieldKind::Bool(b) => json!(b),
|
||||
FieldKind::Choice { options, sel } => options.get(*sel).map(|o| o.1.clone()).unwrap_or(Value::Null),
|
||||
};
|
||||
|
|
@ -513,6 +521,46 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
|||
],
|
||||
)
|
||||
}
|
||||
// File ▸ Save as Template…
|
||||
"templates.saveAs" if !has(p, &["name"]) => {
|
||||
let name = s
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|p| std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()))
|
||||
.or_else(|| s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()))
|
||||
.unwrap_or_else(|| "My Template".into());
|
||||
(
|
||||
"Save as Template".into(),
|
||||
vec![
|
||||
Field::text("name", "Template name", &name),
|
||||
Field::text("description", "Description", ""),
|
||||
Field::text("category", "Category", "My Templates"),
|
||||
],
|
||||
)
|
||||
}
|
||||
// View ▸ Simulate Output ▸ My Custom RGB…
|
||||
"view.customRgb" if p.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
let c = &s.prefs.custom_rgb;
|
||||
let num = |k: &str, l: &str, v: f64| Field { key: k.into(), label: l.into(), kind: FieldKind::Number { value: v, speed: 0.001 } };
|
||||
(
|
||||
"My Custom RGB".into(),
|
||||
vec![
|
||||
Field::text("name", "Name", &c.name),
|
||||
num("red[0]", "Red x", c.red[0]),
|
||||
num("red[1]", "Red y", c.red[1]),
|
||||
num("green[0]", "Green x", c.green[0]),
|
||||
num("green[1]", "Green y", c.green[1]),
|
||||
num("blue[0]", "Blue x", c.blue[0]),
|
||||
num("blue[1]", "Blue y", c.blue[1]),
|
||||
num("white[0]", "White point x", c.white[0]),
|
||||
num("white[1]", "White point y", c.white[1]),
|
||||
num("gamma", "Gamma", c.gamma),
|
||||
Field::bool("srgbCurve", "Use the sRGB curve (ignore Gamma)", c.srgb_curve),
|
||||
Field::path("icc", "ICC profile (replaces the numbers)", &c.icc, &["icc", "icm"]),
|
||||
Field::bool("preserveRgb", "Preserve RGB", s.state.viewer.simulation.preserve_rgb),
|
||||
],
|
||||
)
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
form(app, &title, id, base, fields);
|
||||
|
|
@ -523,42 +571,79 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
|||
let mut f = app.dialog_state.form.clone();
|
||||
let (mut ok, mut close) = (false, false);
|
||||
let h = 120.0 + f.fields.len() as f32 * 30.0;
|
||||
let mut regs: Vec<(String, egui::Rect, String)> = vec![];
|
||||
let mut browse: Option<usize> = None;
|
||||
super::dialogs::modal(ctx, &f.title.clone(), vec2(440.0, h), t, |ui| {
|
||||
egui::Grid::new("form-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| {
|
||||
for fl in &mut f.fields {
|
||||
for (i, fl) in f.fields.iter_mut().enumerate() {
|
||||
ui.label(&fl.label);
|
||||
match &mut fl.kind {
|
||||
let r = match &mut fl.kind {
|
||||
FieldKind::Number { value, speed } => {
|
||||
ui.add(egui::DragValue::new(value).speed(*speed).max_decimals(3));
|
||||
let dec = if *speed < 0.01 { 5 } else { 3 };
|
||||
ui.add(egui::DragValue::new(value).speed(*speed).max_decimals(dec)).rect
|
||||
}
|
||||
FieldKind::Text(s) => {
|
||||
ui.add(egui::TextEdit::singleline(s).desired_width(220.0));
|
||||
}
|
||||
FieldKind::Bool(b) => {
|
||||
ui.checkbox(b, "");
|
||||
FieldKind::Text(s) => ui.add(egui::TextEdit::singleline(s).desired_width(220.0)).rect,
|
||||
FieldKind::Path { value, .. } => {
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(value).desired_width(150.0)).rect;
|
||||
let b = ui.button("Browse…");
|
||||
regs.push((format!("form.field.{}.browse", fl.key), b.rect, "Browse…".into()));
|
||||
if b.clicked() {
|
||||
browse = Some(i);
|
||||
}
|
||||
r
|
||||
})
|
||||
.inner
|
||||
}
|
||||
FieldKind::Bool(b) => ui.checkbox(b, "").rect,
|
||||
FieldKind::Choice { options, sel } => {
|
||||
let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default();
|
||||
egui::ComboBox::from_id_salt(("form", fl.key.as_str())).selected_text(cur).width(240.0).show_ui(ui, |ui| {
|
||||
for (i, (l, _)) in options.iter().enumerate() {
|
||||
ui.selectable_value(sel, i, l);
|
||||
}
|
||||
});
|
||||
egui::ComboBox::from_id_salt(("form", fl.key.as_str()))
|
||||
.selected_text(cur)
|
||||
.width(240.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, (l, _)) in options.iter().enumerate() {
|
||||
ui.selectable_value(sel, i, l);
|
||||
}
|
||||
})
|
||||
.response
|
||||
.rect
|
||||
}
|
||||
}
|
||||
};
|
||||
regs.push((format!("form.field.{}", fl.key), r, fl.label.clone()));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
let b = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
|
||||
regs.push(("form.ok".into(), b.rect, "OK".into()));
|
||||
if b.clicked() {
|
||||
ok = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
let c = ui.button("Cancel");
|
||||
regs.push(("form.cancel".into(), c.rect, "Cancel".into()));
|
||||
if c.clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
for (id, r, l) in regs {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
if let Some(i) = browse
|
||||
&& let Some(FieldKind::Path { value, exts }) = f.fields.get_mut(i).map(|f| &mut f.kind)
|
||||
{
|
||||
let exts: Vec<&str> = exts.iter().map(String::as_str).collect();
|
||||
match app.hooks.pick_files.as_ref() {
|
||||
Some(pick) => {
|
||||
if let Some(path) = pick(&exts).into_iter().next() {
|
||||
*value = path;
|
||||
}
|
||||
}
|
||||
None => app.ui.status = "no file dialog available: type the path".into(),
|
||||
}
|
||||
}
|
||||
let enter = !ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
app.dialog_state.form = f.clone();
|
||||
if ok || enter {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
//! The Home screen (shown in the Composition panel at launch, from the header's Home button, and
|
||||
//! by the Learn workspace): New Project / Open Project / New Composition / Open Demo, the recent
|
||||
//! projects (File ▸ Open Recent, from Settings) with thumbnails, folders and dates, and where
|
||||
//! After Effects has its Learn / What's New area, the ArtCraft community links.
|
||||
//! projects (File ▸ Open Recent, from Settings) with thumbnails, folders and dates, the
|
||||
//! Templates tab (New from Template gallery, `templates`), the Learn tab, and where After
|
||||
//! Effects has its Learn / What's New area, the ArtCraft community links.
|
||||
//!
|
||||
//! Thumbnails: when a project is opened or saved and the viewer has its frame, a 96×54 RGB
|
||||
//! thumbnail is stored in the config store (`thumb-<hash>.txt`, hex) and shown here.
|
||||
|
|
@ -174,11 +175,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
p.text(pos2(x0 + 52.0, y + 12.0), Align2::LEFT_CENTER, "EffectCraft", Tokens::semibold(22.0), Color32::WHITE);
|
||||
p.text(pos2(x0 + 52.0, y + 32.0), Align2::LEFT_CENTER, format!("Version {}", env!("CARGO_PKG_VERSION")), Tokens::ui(11.0), t.text_faint);
|
||||
y += 56.0;
|
||||
// Home / Learn tabs.
|
||||
for (k, (label, learn)) in [("Home", false), ("Learn", true)].into_iter().enumerate() {
|
||||
// Home / Templates / Learn tabs.
|
||||
let tab = if app.ui.home_learn {
|
||||
2
|
||||
} else if app.ui.home_templates {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
for (k, label) in ["Home", "Templates", "Learn"].into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + k as f32 * 84.0, y), vec2(78.0, 28.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("home-tab", label)), Sense::click());
|
||||
let on = app.ui.home_learn == learn;
|
||||
let on = tab == k;
|
||||
if on || resp.hovered() {
|
||||
p.rect_filled(r, 6.0, if on { Color32::from_rgb(0x2e, 0x31, 0x3b) } else { t.hover });
|
||||
}
|
||||
|
|
@ -188,7 +196,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
app.auto.add(&format!("home.tab.{}", label.to_ascii_lowercase()), r, label);
|
||||
if resp.clicked() {
|
||||
app.ui.home_learn = learn;
|
||||
app.ui.home_learn = k == 2;
|
||||
app.ui.home_templates = k == 1;
|
||||
}
|
||||
}
|
||||
y += 40.0;
|
||||
|
|
@ -269,6 +278,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
run_actions(app, &ctx, actions);
|
||||
return;
|
||||
}
|
||||
if app.ui.home_templates {
|
||||
super::templates::home_tab(app, ui, Rect::from_min_max(pos2(rx, ry), pos2(rx + rw, rect.max.y - 8.0)));
|
||||
run_actions(app, &ctx, actions);
|
||||
return;
|
||||
}
|
||||
p.text(pos2(rx, ry + 6.0), Align2::LEFT_CENTER, "Recent", Tokens::semibold(16.0), Color32::WHITE);
|
||||
let entries = recent_entries(&app.session.prefs);
|
||||
if !entries.is_empty() {
|
||||
|
|
@ -410,6 +424,7 @@ mod tests {
|
|||
"home.help.github",
|
||||
"home.recent.0",
|
||||
"home.tab.home",
|
||||
"home.tab.templates",
|
||||
"home.tab.learn",
|
||||
] {
|
||||
assert!(app.auto.find(id).is_some(), "{id}");
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ pub fn coach(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
Some("more") => {
|
||||
let _ = app.session.execute("learn.stop", json!({}));
|
||||
app.ui.home_learn = true;
|
||||
app.ui.home_templates = false;
|
||||
app.ui.start_screen = true;
|
||||
}
|
||||
Some(a) => {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ pub mod script_console;
|
|||
pub mod scriptui_view;
|
||||
pub mod settings;
|
||||
pub mod shortcut_editor;
|
||||
pub mod templates;
|
||||
pub mod text_panels;
|
||||
pub mod timeline;
|
||||
pub mod tracker;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
//! Home ▸ Templates: the New from Template gallery (engine model in
|
||||
//! `effectcraft_engine::templates`). Built-in templates are original projects authored in code;
|
||||
//! user templates come from File ▸ Save as Template… (the config Templates folder). Thumbnails
|
||||
//! are rendered by our own renderer, one per frame, the first time the gallery shows them.
|
||||
//!
|
||||
//! Automation ids: `home.templates.<id>` (card), `home.templates.<id>.open`,
|
||||
//! `home.templates.<id>.delete` (user templates), `home.templates.saveCurrent`.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use effectcraft_engine::templates::{self, THUMB_H, THUMB_W, TemplateInfo};
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
/// The gallery's entries (re-listed when the user template files change).
|
||||
pub fn entries(app: &mut EffectcraftApp) -> Vec<TemplateInfo> {
|
||||
let files = templates::user_files(&app.session);
|
||||
if app.template_list.as_ref().map(|(f, _)| f) != Some(&files) {
|
||||
let list = templates::list(&app.session);
|
||||
app.template_thumbs.retain(|id, _| !id.starts_with("user/") || list.iter().any(|t| &t.id == id));
|
||||
app.template_list = Some((files, list));
|
||||
}
|
||||
app.template_list.as_ref().map(|(_, l)| l.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A template's thumbnail texture; renders at most one missing thumbnail per frame (`budget`).
|
||||
fn thumb(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, budget: &mut bool) -> Option<egui::TextureHandle> {
|
||||
if let Some(t) = app.template_thumbs.get(id) {
|
||||
return t.clone();
|
||||
}
|
||||
if !*budget {
|
||||
ctx.request_repaint();
|
||||
return None;
|
||||
}
|
||||
*budget = false;
|
||||
let tex = templates::thumbnail(&app.session, id).ok().and_then(|t| templates::decode_thumb(&t)).map(|rgb| {
|
||||
let px = rgb.chunks_exact(3).map(|c| Color32::from_rgb(c[0], c[1], c[2])).collect();
|
||||
ctx.load_texture(format!("template-thumb-{id}"), egui::ColorImage::new([THUMB_W, THUMB_H], px), egui::TextureOptions::LINEAR)
|
||||
});
|
||||
app.template_thumbs.insert(id.to_string(), tex.clone());
|
||||
ctx.request_repaint();
|
||||
tex
|
||||
}
|
||||
|
||||
fn fmt_secs(s: f64) -> String {
|
||||
if (s - s.round()).abs() < 0.05 { format!("{} s", s.round()) } else { format!("{s:.1} s") }
|
||||
}
|
||||
|
||||
/// The Templates tab of the Home screen.
|
||||
pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let p = ui.painter().with_clip_rect(area);
|
||||
let (x, y, w) = (area.min.x, area.min.y, area.width());
|
||||
p.text(pos2(x, y + 6.0), Align2::LEFT_CENTER, "New from Template", Tokens::semibold(16.0), Color32::WHITE);
|
||||
p.text(
|
||||
pos2(x, y + 26.0),
|
||||
Align2::LEFT_CENTER,
|
||||
"Start from a ready-made project. Opening a template makes an untitled copy.",
|
||||
Tokens::ui(11.5),
|
||||
t.text_faint,
|
||||
);
|
||||
// Save the open project as a user template.
|
||||
let can_save = app.session.project.comps().next().is_some();
|
||||
let sr = Rect::from_min_size(pos2(area.max.x - 210.0, y - 4.0), vec2(210.0, 28.0));
|
||||
let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::new("home-templates-save")).clicked();
|
||||
app.auto.add("home.templates.saveCurrent", sr, "Save Current as Template…");
|
||||
if save {
|
||||
if can_save {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "templates.saveAs", json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
} else {
|
||||
app.ui.status = "Make a composition first: a template is saved from the open project.".into();
|
||||
}
|
||||
}
|
||||
let list = entries(app);
|
||||
let top = y + 46.0;
|
||||
let gap = 16.0;
|
||||
let card_w = 220.0f32.min(w);
|
||||
let cols = (((w + gap) / (card_w + gap)).floor() as usize).max(1);
|
||||
let card_w = (w - gap * (cols - 1) as f32) / cols as f32;
|
||||
let thumb_h = card_w * THUMB_H as f32 / THUMB_W as f32;
|
||||
let card_h = thumb_h + 62.0;
|
||||
let view = Rect::from_min_max(pos2(x, top), area.max);
|
||||
let mut open: Option<String> = None;
|
||||
let mut delete: Option<String> = None;
|
||||
let mut budget = true;
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(view).layout(egui::Layout::top_down(egui::Align::Min)));
|
||||
egui::ScrollArea::vertical().id_salt("home-templates-scroll").auto_shrink([false, false]).show(&mut child, |ui| {
|
||||
let rows = list.len().div_ceil(cols);
|
||||
let (full, _) = ui.allocate_exact_size(vec2(view.width() - 8.0, rows as f32 * (card_h + gap)), Sense::hover());
|
||||
let p = ui.painter().with_clip_rect(ui.clip_rect());
|
||||
for (i, tpl) in list.iter().enumerate() {
|
||||
let (cx, cy) = (full.min.x + (i % cols) as f32 * (card_w + gap), full.min.y + (i / cols) as f32 * (card_h + gap));
|
||||
let r = Rect::from_min_size(pos2(cx, cy), vec2(card_w, card_h));
|
||||
let visible = r.intersects(ui.clip_rect());
|
||||
let resp = ui.interact(r, egui::Id::new(("template-card", &tpl.id)), Sense::click()).on_hover_text(&tpl.description);
|
||||
p.rect_filled(r, 8.0, if resp.hovered() { Color32::from_rgb(0x2c, 0x2e, 0x37) } else { Color32::from_rgb(0x24, 0x26, 0x2e) });
|
||||
let tr = Rect::from_min_size(r.min, vec2(card_w, thumb_h));
|
||||
let tex = if visible { thumb(app, &ctx, &tpl.id, &mut budget) } else { None };
|
||||
match tex {
|
||||
Some(tex) => {
|
||||
p.image(tex.id(), tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
None => {
|
||||
p.rect_filled(tr, 6.0, Color32::from_rgb(0x1e, 0x20, 0x27));
|
||||
icons::paint(&p, Rect::from_center_size(tr.center(), vec2(22.0, 22.0)), Icon::Comp, t.text_faint);
|
||||
}
|
||||
}
|
||||
p.rect_stroke(tr, 6.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.separator }), StrokeKind::Inside);
|
||||
// Category chip (built-in) or "Mine".
|
||||
let chip = if tpl.builtin { tpl.category.clone() } else { format!("{} · Mine", tpl.category) };
|
||||
let g = p.layout_no_wrap(chip, Tokens::medium(10.0), Color32::WHITE);
|
||||
let cr = Rect::from_min_size(tr.min + vec2(8.0, 8.0), g.size() + vec2(12.0, 4.0));
|
||||
p.rect_filled(cr, 8.0, Color32::from_black_alpha(150));
|
||||
p.galley(cr.min + vec2(6.0, 2.0), g, Color32::WHITE);
|
||||
let text = p.with_clip_rect(r.shrink(2.0));
|
||||
text.text(pos2(r.min.x + 10.0, tr.max.y + 16.0), Align2::LEFT_CENTER, &tpl.name, Tokens::semibold(13.0), Color32::WHITE);
|
||||
let meta = format!("{}×{} · {} · {:.2} fps", tpl.width, tpl.height, fmt_secs(tpl.duration), tpl.frame_rate);
|
||||
text.text(pos2(r.min.x + 10.0, tr.max.y + 34.0), Align2::LEFT_CENTER, meta, Tokens::ui(11.0), t.text_dim);
|
||||
let ctl = match tpl.controls.len() {
|
||||
0 => "No Essential Graphics controls".to_string(),
|
||||
1 => format!("Essential Graphics: {}", tpl.controls[0]),
|
||||
n => format!("{n} Essential Graphics controls"),
|
||||
};
|
||||
text.text(pos2(r.min.x + 10.0, tr.max.y + 50.0), Align2::LEFT_CENTER, ctl, Tokens::ui(10.5), t.text_faint);
|
||||
let base = format!("home.templates.{}", tpl.id);
|
||||
app.auto.add(&base, r, &tpl.name);
|
||||
app.auto.add(&format!("{base}.open"), tr, &format!("New project from {}", tpl.name));
|
||||
let mut del_hit = false;
|
||||
if !tpl.builtin {
|
||||
let dr = Rect::from_min_size(pos2(tr.max.x - 30.0, tr.min.y + 6.0), vec2(24.0, 24.0));
|
||||
let dresp = ui.interact(dr, egui::Id::new(("template-del", &tpl.id)), Sense::click()).on_hover_text("Delete this template");
|
||||
if resp.hovered() || dresp.hovered() {
|
||||
p.circle_filled(dr.center(), 12.0, if dresp.hovered() { Color32::from_rgb(0xc0, 0x3a, 0x3a) } else { Color32::from_black_alpha(170) });
|
||||
icons::paint(&p, dr.shrink(6.0), Icon::Close, Color32::WHITE);
|
||||
}
|
||||
app.auto.add(&format!("{base}.delete"), dr, "Delete Template");
|
||||
if dresp.clicked() {
|
||||
del_hit = true;
|
||||
delete = Some(tpl.id.clone());
|
||||
}
|
||||
}
|
||||
if resp.clicked() && !del_hit {
|
||||
open = Some(tpl.id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(id) = delete {
|
||||
match app.session.execute("templates.delete", json!({"id": id})) {
|
||||
Ok(_) => {
|
||||
app.template_thumbs.remove(&id);
|
||||
}
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
} else if let Some(id) = open {
|
||||
match app.session.execute("templates.create", json!({"id": id})) {
|
||||
Ok(_) => app.ui.start_screen = false,
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
ctx.run_ui(Default::default(), |ui| {
|
||||
app.auto.begin_frame();
|
||||
if app.ui.start_screen {
|
||||
crate::panels::home::show(app, ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 860.0)));
|
||||
}
|
||||
crate::panels::dialogs::show(app, ui.ctx());
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
}
|
||||
|
||||
fn app_with_store() -> EffectcraftApp {
|
||||
let s = effectcraft_engine::Session { config: Some(std::sync::Arc::new(effectcraft_engine::config::MemoryConfig::default())), ..Default::default() };
|
||||
EffectcraftApp::new(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gallery_lists_renders_and_opens_templates() {
|
||||
let mut app = app_with_store();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install(&ctx, &app.tokens);
|
||||
// File ▸ New ▸ New Project from Template… opens the Templates tab.
|
||||
let ev = app.session.execute("file.newFromTemplate", json!({})).unwrap();
|
||||
assert_eq!(ev["frontend"], "file.newFromTemplate");
|
||||
crate::menus::invoke(&mut app, &ctx, "file.newFromTemplate", json!({})).unwrap();
|
||||
assert!(app.ui.start_screen && app.ui.home_templates && !app.ui.home_learn);
|
||||
// One thumbnail per frame until all are rendered.
|
||||
for _ in 0..12 {
|
||||
frame(&mut app, &ctx);
|
||||
}
|
||||
let list = entries(&mut app);
|
||||
assert!(list.len() >= 8);
|
||||
for t in &list {
|
||||
assert!(app.auto.find(&format!("home.templates.{}", t.id)).is_some(), "{}", t.id);
|
||||
assert!(app.auto.find(&format!("home.templates.{}.open", t.id)).is_some(), "{}", t.id);
|
||||
assert!(app.template_thumbs.get(&t.id).is_some_and(Option::is_some), "{}: no thumbnail", t.id);
|
||||
}
|
||||
for id in ["home.templates.saveCurrent", "home.tab.templates", "home.tab.learn"] {
|
||||
assert!(app.auto.find(id).is_some(), "{id}");
|
||||
}
|
||||
assert!(app.auto.find("home.recent.0").is_none());
|
||||
// Save as Template… opens its form (once there is a comp).
|
||||
app.session.execute("templates.create", json!({"id": "logo-reveal"})).unwrap();
|
||||
crate::menus::invoke(&mut app, &ctx, "templates.saveAs", json!({})).unwrap();
|
||||
assert!(matches!(app.dialog, Some(crate::Dialog::Form)));
|
||||
assert_eq!(app.dialog_state.form.command, "templates.saveAs");
|
||||
app.dialog_state.form.fields[0].kind = crate::panels::forms::FieldKind::Text("Brand Intro".into());
|
||||
frame(&mut app, &ctx);
|
||||
for id in ["form.field.name", "form.field.description", "form.ok", "form.cancel"] {
|
||||
assert!(app.auto.find(id).is_some(), "{id}");
|
||||
}
|
||||
let params = app.dialog_state.form.params();
|
||||
app.session.execute("templates.saveAs", params).unwrap();
|
||||
app.dialog = None;
|
||||
app.ui.start_screen = true;
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, &ctx);
|
||||
}
|
||||
let mine = "user/Brand Intro.ectemplate";
|
||||
assert!(app.auto.find(&format!("home.templates.{mine}.delete")).is_some());
|
||||
assert!(app.template_thumbs.get(mine).is_some_and(Option::is_some));
|
||||
// Opening makes an untitled copy.
|
||||
app.session.execute("templates.create", json!({"id": mine})).unwrap();
|
||||
assert!(app.session.path.is_none());
|
||||
assert!(app.session.active_comp().is_some_and(|c| c.layers.iter().any(|l| l.name == "BRAND NAME")));
|
||||
app.session.execute("templates.delete", json!({"id": mine})).unwrap();
|
||||
frame(&mut app, &ctx);
|
||||
frame(&mut app, &ctx);
|
||||
assert!(app.auto.find(&format!("home.templates.{mine}")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn my_custom_rgb_dialog() {
|
||||
let mut app = app_with_store();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install(&ctx, &app.tokens);
|
||||
// Menu check marks follow the active comp's viewer.
|
||||
app.session.execute("comp.new", json!({"name": "C", "width": 64, "height": 48})).unwrap();
|
||||
let item = crate::menus::menu_items(&app).into_iter().find(|m| m.id == "view.customRgb").expect("menu item");
|
||||
assert_eq!(item.label, "My Custom RGB...");
|
||||
assert_eq!(item.checked, Some(false));
|
||||
crate::menus::invoke(&mut app, &ctx, "view.customRgb", json!({})).unwrap();
|
||||
assert!(matches!(app.dialog, Some(crate::Dialog::Form)));
|
||||
frame(&mut app, &ctx);
|
||||
for id in ["form.field.red[0]", "form.field.white[1]", "form.field.gamma", "form.field.icc", "form.field.icc.browse", "form.ok"] {
|
||||
assert!(app.auto.find(id).is_some(), "{id}");
|
||||
}
|
||||
let mut p = app.dialog_state.form.params();
|
||||
assert_eq!(p["red"], json!([0.64, 0.33]));
|
||||
p["gamma"] = json!(1.8);
|
||||
app.session.execute("view.customRgb", p).unwrap();
|
||||
assert_eq!(app.session.prefs.custom_rgb.gamma, 1.8);
|
||||
assert_eq!(app.session.state.viewer.simulation.profile, effectcraft_engine::viewer::SimProfile::MyCustom);
|
||||
let item = crate::menus::menu_items(&app).into_iter().find(|m| m.id == "view.customRgb").unwrap();
|
||||
assert_eq!(item.checked, Some(true));
|
||||
}
|
||||
}
|
||||
|
|
@ -348,7 +348,8 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
|
|||
}
|
||||
let dc = vw::DisplayColor::of(&app.session);
|
||||
// The display conversion's inputs, for the texture caches.
|
||||
let dc_key = format!("{:?}{:?}{}", dc.is_some(), opts.simulation, app.session.prefs.previews.display_profile)
|
||||
let custom = if opts.simulation.profile == vw::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() };
|
||||
let dc_key = format!("{:?}{:?}{}{custom}", dc.is_some(), opts.simulation, app.session.prefs.previews.display_profile)
|
||||
+ &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management);
|
||||
let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0 && dc.is_none();
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
|
|
|
|||
|
|
@ -605,6 +605,9 @@ pub struct UiState {
|
|||
/// The Home screen shows its Learn tab (tutorials) instead of the recent projects.
|
||||
#[serde(default)]
|
||||
pub home_learn: bool,
|
||||
/// The Home screen shows its Templates tab (New from Template gallery).
|
||||
#[serde(default)]
|
||||
pub home_templates: bool,
|
||||
/// Align panel: Align Layers to Selection (off: to the composition).
|
||||
#[serde(default)]
|
||||
pub align_to_selection: bool,
|
||||
|
|
@ -677,6 +680,7 @@ impl Default for UiState {
|
|||
tool_creates_shape: true,
|
||||
start_screen: false,
|
||||
home_learn: false,
|
||||
home_templates: false,
|
||||
align_to_selection: false,
|
||||
cache_when_idle: false,
|
||||
tracker_source: None,
|
||||
|
|
|
|||
|
|
@ -156,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) |
|
||||
|
|
@ -348,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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue