Merge main into M13.4 (codec completion): HEVC/AV1 on the export pipeline (Cx), one key-frame interval for all movie codecs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 07:29:32 -04:00
commit a30cf38180
181 changed files with 20123 additions and 1427 deletions

View File

@ -61,6 +61,11 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
- Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.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`,
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
(sidecars `.attribution`).
- Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly).
## First-party brand marks

164
Cargo.lock generated
View File

@ -1204,6 +1204,7 @@ dependencies = [
"effectcraft-project",
"effectcraft-raster",
"effectcraft-track",
"log",
"rayon",
"serde",
"serde_json",
@ -1220,6 +1221,7 @@ dependencies = [
"effectcraft-lottie",
"effectcraft-model",
"effectcraft-path",
"effectcraft-pdf",
"effectcraft-project",
"effectcraft-psd",
"effectcraft-raster",
@ -1245,6 +1247,7 @@ dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
"effectcraft-hevcenc",
"effectcraft-keyframe",
"effectcraft-media",
"effectcraft-opusenc",
"effectcraft-project",
@ -1325,12 +1328,15 @@ dependencies = [
"effectcraft-export",
"effectcraft-expr",
"effectcraft-media",
"effectcraft-pdf",
"effectcraft-plugin",
"effectcraft-project",
"effectcraft-psd",
"effectcraft-raster",
"effectcraft-script",
"effectcraft-svg",
"effectcraft-time",
"rayon",
"serde_json",
]
@ -1368,6 +1374,7 @@ name = "effectcraft-media"
version = "0.1.0"
dependencies = [
"effectcraft-model",
"effectcraft-pdf",
"effectcraft-project",
"effectcraft-psd",
"effectcraft-raster",
@ -1414,6 +1421,27 @@ dependencies = [
"tiny-skia",
]
[[package]]
name = "effectcraft-pdf"
version = "0.1.0"
dependencies = [
"effectcraft-svg",
"kurbo",
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-plugin"
version = "0.1.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
"effectcraft-project",
"effectcraft-raster",
"serde_json",
"wasmi",
]
[[package]]
name = "effectcraft-project"
version = "0.1.0"
@ -2118,6 +2146,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@ -2292,7 +2326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@ -2432,6 +2466,15 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@ -2440,7 +2483,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@ -2451,7 +2494,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@ -2769,6 +2812,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lebe"
version = "0.5.3"
@ -4448,6 +4497,12 @@ dependencies = [
"serde",
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spirv"
version = "0.4.0+sdk-1.4.341.0"
@ -4475,6 +4530,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "string-interner"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
dependencies = [
"hashbrown 0.15.5",
"serde",
]
[[package]]
name = "strum"
version = "0.28.0"
@ -5143,6 +5208,99 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
dependencies = [
"leb128fmt",
"wasmparser 0.261.0",
]
[[package]]
name = "wasmi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
dependencies = [
"spin",
"wasmi_collections",
"wasmi_core",
"wasmi_ir",
"wasmparser 0.228.0",
"wat",
]
[[package]]
name = "wasmi_collections"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
dependencies = [
"string-interner",
]
[[package]]
name = "wasmi_core"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
dependencies = [
"libm",
]
[[package]]
name = "wasmi_ir"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
dependencies = [
"wasmi_core",
]
[[package]]
name = "wasmparser"
version = "0.228.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
dependencies = [
"bitflags 2.13.2",
"indexmap",
]
[[package]]
name = "wasmparser"
version = "0.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
dependencies = [
"bitflags 2.13.2",
"indexmap",
]
[[package]]
name = "wast"
version = "261.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder",
]
[[package]]
name = "wat"
version = "1.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
dependencies = [
"wast",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"

View File

@ -29,6 +29,7 @@ effectcraft-media = { path = "crates/media" }
effectcraft-export = { path = "crates/export" }
effectcraft-expr = { path = "crates/expr" }
effectcraft-lottie = { path = "crates/lottie" }
effectcraft-pdf = { path = "crates/pdf" }
effectcraft-psd = { path = "crates/psd" }
effectcraft-vp9enc = { path = "crates/vp9enc" }
effectcraft-svg = { path = "crates/svg" }
@ -40,6 +41,7 @@ effectcraft-opusenc = { path = "crates/opusenc" }
effectcraft-script = { path = "crates/script" }
effectcraft-hevcenc = { path = "crates/hevcenc" }
effectcraft-av1enc = { path = "crates/av1enc" }
effectcraft-plugin = { path = "crates/plugin" }
# foundation
thiserror = "2"
@ -54,6 +56,8 @@ proptest = "1"
# scripting (expressions)
boa_engine = { version = "0.22", default-features = false }
# WebAssembly effect plug-ins (sandboxed interpreter; MIT/Apache-2.0, pure Rust)
wasmi = { version = "2", default-features = false, features = ["std", "validate", "wat", "deterministic", "portable-dispatch"] }
# geometry / fonts
kurbo = { version = "0.13", features = ["serde"] }

View File

@ -136,8 +136,11 @@ tools, and Front, Top, Left and Custom views.
## Export
A Render Queue like After Effects', with Render Settings and Output Modules: **H.264** MP4 and
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, PNG, JPEG, TIFF and 32-bit EXR
sequences, and animated GIF. The same queue runs from the command line. Every encoder is
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **WebM** (VP9 with inter frames and
alpha, Opus), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and
resize, Render Settings and Output Module templates with defaults, post-render actions, storage
overflow and render logs. The same queue runs from the command line. Every encoder is
FilmCraft's pure-Rust code; there is no FFmpeg inside.
**Lottie** goes both ways: File ▸ Export ▸ Lottie JSON… writes a composition (precomps, shape,

View File

@ -25,12 +25,14 @@ once.
## How far from full parity
Measured feature by feature in [docs/parity.md](docs/parity.md) (late 2 October 2026):
Measured feature by feature in [docs/parity.md](docs/parity.md) (3 October 2026):
- **≈ 93% of After Effects' features, weighted by importance** (96% of the essentials, 90% of
professional daily-use features, 65% of the long tail); all 298 After Effects effects.
- **≈ 50–55 agent-hours of work remain**: about **11–14 hours of wall-clock time** with five Claude
Opus 5.5 agents in parallel, ≈ 6–8 hours for the essentials and daily-use features.
- Hardest remaining pieces: the web app's depth, GPU 3D, Content-Aware Fill, a plug-in API.
- **≈ 94% of After Effects' features, weighted by importance**, counting partial features as half
done (≈ 98% with per-feature fractions): 80 of 92 features done, 12 partial, none missing; all
298 After Effects effects (42 still simplified).
- **≈ 19–22 agent-hours of work remain**: about **4–5 hours of wall-clock time** with five Claude
Opus 5.5 agents in parallel, ≈ 3 hours for the essentials and daily-use features.
- Hardest remaining pieces: the web app's threading, wider GPU effect coverage, Advanced 3D
completion, and learned-model quality for Roto Brush and face tracking.
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).

View File

@ -216,6 +216,18 @@ impl ConfigStore for WebConfig {
self.store.changed();
Ok(())
}
fn list(&self, dir: &str) -> Vec<String> {
let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/'));
let mut v: Vec<String> = self
.store
.lock()
.list(&pre)
.into_iter()
.filter_map(|(k, ..)| k.strip_prefix(&pre).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string))
.collect();
v.sort();
v
}
fn dir(&self) -> Option<PathBuf> {
Some(PathBuf::from("/"))
}

View File

@ -66,6 +66,11 @@ fn main() -> eframe::Result {
// Settings, shortcut presets and the crash-recovery sentinel live in the platform
// config directory. Agent-driven runs (`--control`) skip crash recovery.
if let Some(dir) = config_dir() {
// WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.
let plugins = dir.join("Plug-ins");
if plugins.is_dir() {
let _ = session.execute("effect.plugins.load", json!({"folder": plugins.to_string_lossy()}));
}
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
}
session.load_settings();

View File

@ -265,6 +265,32 @@ fn bridge_rejects_non_loopback() {
assert!(Backend::bridge("localhost:9877").is_ok());
}
#[test]
fn script_ui_and_history_tools() {
let mut sess = Session::default();
effectcraft_script::install(&mut sess);
let mut s = McpServer::new(Backend::headless(sess));
let code = "var d = new Window('dialog', 'Ask'); var g = d.add('group'); g.add('button', undefined, 'OK', {name: 'ok'}); \
if (d.show() == 1) app.project.items.addComp('Answered', 64, 64, 1, 1, 24); 'done'";
let r = call_json(&mut s, "run_script", json!({"code": code}));
assert_eq!(r["waiting"], json!(true), "{r}");
let list = call_json(&mut s, "script_ui", json!({}));
assert_eq!(list[0]["title"], "Ask");
let tree = call_json(&mut s, "script_ui", json!({"action": "get"}));
assert_eq!(tree["root"]["children"][0]["children"][0]["name"], "ok");
let r = call_json(&mut s, "script_ui", json!({"action": "click", "widget": "ok"}));
assert_eq!(r["result"], json!("done"), "{r}");
// History: undo the comp, make another, and jump back to the first on its branch.
call_json(&mut s, "undo", json!({}));
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Other", "width": 64, "height": 64}}));
let h = call_json(&mut s, "history", json!({}));
let branch = h["states"].as_array().unwrap().iter().find(|n| n["depth"] == 1).unwrap()["index"].clone();
let h = call_json(&mut s, "history", json!({"goto": branch}));
let cur = h["current"].as_u64().unwrap() as usize;
assert_eq!(h["states"][cur]["depth"], 0, "the jumped-to state is now the working line");
assert!(call_json(&mut s, "get_project", json!({})).to_string().contains("Answered"));
}
#[test]
fn run_script_round_trip() {
let mut sess = Session::default();

View File

@ -315,6 +315,32 @@ fn redo(b: &mut Backend, a: &Value) -> Result<Reply> {
history(b, a, "edit.redo")
}
fn script_ui_tool(b: &mut Backend, a: &Value) -> Result<Reply> {
let action = get(a, "action").and_then(Value::as_str).unwrap_or("list");
let id = match action {
"list" => "scriptui.list",
"get" => "scriptui.get",
"click" => "scriptui.click",
"set" => "scriptui.set",
"close" => "scriptui.close",
other => return Err(Error::BadArgs(format!("unknown action `{other}` (list | get | click | set | close)"))),
};
let p = obj(&[("window", get(a, "window")), ("widget", get(a, "widget")), ("value", get(a, "value")), ("result", get(a, "result"))]);
json_reply(b.exec(id, p)?)
}
fn history_tool(b: &mut Backend, a: &Value) -> Result<Reply> {
if let Some(g) = get(a, "goto") {
let p = match g {
Value::String(id) if id.parse::<u64>().is_err() => json!({"id": id}),
Value::String(n) => json!({"index": n.parse::<u64>().unwrap_or(0)}),
v => json!({"index": v}),
};
b.exec("edit.history.goto", p)?;
}
json_reply(b.exec("edit.history.list", json!({}))?)
}
// ------------------------------------------------------------------ bridge-only tools
fn screenshot(b: &mut Backend, a: &Value) -> Result<Reply> {
@ -535,6 +561,31 @@ static TOOLS: &[ToolDef] = &[
schema: || schema(json!({"steps": {"type": "integer", "minimum": 1}}), &[]),
run: redo,
},
ToolDef {
name: "history",
description: "The branching undo history (History panel): without arguments lists every state {index, id, label, parent, depth (0 = working line, >0 = branch), current, future}; undoing and then editing keeps the undone states as a branch. With `goto` (an index or id from the listing) jumps to that state, on any branch, without losing the others.",
bridge_only: false,
schema: || schema(json!({"goto": {"type": ["integer", "string"], "description": "State index or id to jump to (edit.history.goto)."}}), &[]),
run: history_tool,
},
ToolDef {
name: "script_ui",
description: "Drive ScriptUI windows that scripts opened (dialogs, palettes, dockable panels). action `list` (default): open windows; `get`: a window's control tree (type, name, text, value, checked, items, selection, bounds); `click`: press a button / toggle a checkbox / pick a radio button; `set`: type text, move a slider, pick a list item (index or text); `close`: close a window (`result` for a dialog's show(), default 2 = Cancel). Controls by id, \"#id\", properties.name or text. A dialog's script resumes when the dialog closes.",
bridge_only: false,
schema: || {
schema(
json!({
"action": {"type": "string", "enum": ["list", "get", "click", "set", "close"]},
"window": {"type": ["integer", "string"], "description": "Window id or title (optional when one is open)."},
"widget": {"type": ["integer", "string"], "description": "Control id, \"#id\", properties.name or text."},
"value": {"description": "For `set`: text, number, bool, item index or item text."},
"result": {"type": "integer", "description": "For `close`: what the dialog's show() returns."}
}),
&[],
)
},
run: script_ui_tool,
},
ToolDef {
name: "screenshot",
description: "Screenshot of the live app window, or one panel (`Composition`, `Timeline`, `EffectControls`, `Project`, ...) or element id, as PNG (longest side `max_side`, default 1600). Use it to check what the UI looks like after an action.",

View File

@ -19,4 +19,5 @@ effectcraft-track = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }

View File

@ -8,8 +8,12 @@
//! Shot Type, Angle of View, Solve Method and deleted points it was solved with, so changing
//! those re-solves without re-tracking). **Method Used** and **Average Error** report the solve.
//!
//! The effect passes its input through; with **Render Track Points** on it draws the solved
//! points (as the viewer shows them) into the frame. The viewer overlay, target and the Create
//! **Solve Lens Distortion** (Advanced; also on with Detailed Analysis) solves radial lens
//! distortion `k1, k2` jointly with the camera; **Undistort Footage** then renders the input
//! undistorted (the pinhole image the solved camera sees) so 3D layers line up with it.
//!
//! The effect passes its input through (undistorted when asked); with **Render Track Points**
//! on it draws the solved points (as the viewer shows them) into the frame. The viewer overlay, target and the Create
//! commands live in the engine and the UI.
use std::collections::HashMap;
@ -19,6 +23,7 @@ use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use effectcraft_track::camtrack::{CameraSolve, CameraTracks, ShotType, SolveMethod, SolveSettings, linalg, point_color};
use rayon::prelude::*;
use crate::warp_stab::param;
use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider};
@ -58,6 +63,8 @@ pub fn specs() -> Vec<EffectSpec> {
p(METHOD_USED, "Method Used", Value::Str(String::new()), ParamUi::Hidden),
p(AVERAGE_ERROR, "Average Error", num(0.0), ParamUi::Hidden),
p("advanced/detailedAnalysis", "Detailed Analysis", Value::Bool(false), ParamUi::Checkbox),
p("advanced/lensDistortion", "Solve Lens Distortion", Value::Bool(false), ParamUi::Checkbox),
p("advanced/undistort", "Undistort Footage", Value::Bool(false), ParamUi::Checkbox),
p("advanced/autoDeletePoints", "Auto-delete Points Across Time", Value::Bool(true), ParamUi::Checkbox),
p("advanced/hideWarningBanner", "Hide Warning Banner", Value::Bool(false), ParamUi::Checkbox),
p(TRACKS, "Tracks", Value::Str(String::new()), ParamUi::Hidden),
@ -104,13 +111,15 @@ pub fn settings(params: &Params) -> SolveSettings {
hfov: f(params, "horizontalAngleOfView"),
method: SolveMethod::ALL.get(e(params, "advanced/solveMethod") as usize).copied().unwrap_or_default(),
deleted: del,
lens_distortion: b(params, "advanced/lensDistortion") || b(params, "advanced/detailedAnalysis"),
}
}
/// What a solve depends on besides the tracks.
pub fn solve_key(st: &SolveSettings) -> String {
let hfov = if st.shot == ShotType::SpecifyAngle { format!("{:.4}", st.hfov) } else { String::new() };
format!("{:?}|{hfov}|{:?}|{:?}", st.shot, st.method, st.deleted)
let lens = if st.lens_distortion { "|lens" } else { "" };
format!("{:?}|{hfov}|{:?}|{:?}{lens}", st.shot, st.method, st.deleted)
}
fn fnv(bytes: &[u8]) -> u64 {
@ -165,18 +174,49 @@ pub fn target_radius(size_pct: f64, z: f64, median_z: f64) -> f64 {
(6.0 * size_pct / 100.0 * (median_z / z.max(1e-9))).clamp(1.5, 40.0)
}
/// The solved points visible on frame `k`: (id, layer pixel position, camera depth).
/// The solved points visible on frame `k`: (id, layer pixel position in the footage, camera
/// depth).
pub fn projected(solve: &CameraSolve, k: usize) -> Vec<(u32, [f64; 2], f64)> {
projected_with(solve, k, false)
}
/// [`projected`] into the footage, or (`undistorted`) into the undistorted image.
pub fn projected_with(solve: &CameraSolve, k: usize, undistorted: bool) -> Vec<(u32, [f64; 2], f64)> {
let Some(cam) = solve.frames.get(k) else { return vec![] };
solve
.visible(k)
.filter_map(|p| {
let c = cam.to_cam(p.pos);
cam.project(solve.size, p.pos).map(|q| (p.id, q, c[2]))
solve.project(k, p.pos, undistorted).map(|q| (p.id, q, c[2]))
})
.collect()
}
/// Whether the instance renders its input undistorted (Undistort Footage with a solved
/// distortion).
pub fn undistorts(params: &Params, solve: &CameraSolve) -> bool {
b(params, "advanced/undistort") && solve.distortion.is_some_and(|d| !d.is_none())
}
/// The input resampled through the solved distortion: every output pixel shows the footage
/// at its distorted position (transparent where that falls outside the layer).
fn undistort(buf: &Buf, sol: &CameraSolve) -> Buf {
let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 };
let mut out = Image::new(buf.img.width, buf.img.height);
let size = sol.size;
out.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let p = [(x as f64 + 0.5 - buf.offset[0]) / sc, (y as f64 + 0.5 - buf.offset[1]) / sc];
let q = sol.to_image(p);
if q[0] < 0.0 || q[1] < 0.0 || q[0] > size[0] || q[1] > size[1] {
continue;
}
*px = buf.img.sample_bilinear_clamped(q[0] * sc + buf.offset[0], q[1] * sc + buf.offset[1]);
}
});
Buf { img: out, offset: buf.offset, scale: buf.scale }
}
/// Median of the depths.
pub fn median_depth(v: &[(u32, [f64; 2], f64)]) -> f64 {
let mut z: Vec<f64> = v.iter().map(|x| x.2).collect();
@ -188,15 +228,23 @@ pub fn median_depth(v: &[(u32, [f64; 2], f64)]) -> f64 {
}
fn render(ctx: &EffectCtx, mut buf: Buf) -> Buf {
if !b(ctx.params, "renderTrackPoints") {
let points = b(ctx.params, "renderTrackPoints");
if !points && !b(ctx.params, "advanced/undistort") {
return buf;
}
let Some(sol) = solve(ctx.params) else { return buf };
if (sol.size[0] - ctx.layer_size[0]).abs() > 0.5 || (sol.size[1] - ctx.layer_size[1]).abs() > 0.5 {
return buf;
}
let und = undistorts(ctx.params, &sol);
if und {
buf = undistort(&buf, &sol);
}
if !points {
return buf;
}
let Some(k) = sol.frame_at(ctx.time) else { return buf };
let pts = projected(&sol, k);
let pts = projected_with(&sol, k, und);
let med = median_depth(&pts);
let size = f(ctx.params, "trackPointSize");
let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 };
@ -287,5 +335,37 @@ mod tests {
let st = settings(&Params::default());
assert_eq!(st.shot, ShotType::FixedAngle);
assert!(solve_key(&st).contains("FixedAngle"));
assert!(!st.lens_distortion && !solve_key(&st).contains("lens"));
}
#[test]
fn undistort_footage_remaps_through_the_solved_lens() {
use effectcraft_track::camtrack::Distortion;
let sol = CameraSolve {
version: 1,
size: [64.0, 48.0],
start: 0.0,
frame_duration: 1.0 / 30.0,
frames: vec![SolvedFrame { rot: [0.0; 3], center: [0.0; 3], focal: 50.0, solved: true }],
distortion: Some(Distortion { k1: -0.2, k2: 0.0, radius: 40.0 }),
..Default::default()
};
let mut params = params_with(&sol, false);
params.values.insert("advanced/undistort".into(), Value::Bool(true));
assert!(undistorts(&params, &sol));
// A horizontal ramp: undistortion keeps the centre and pulls edge content outwards.
let mut img = Image::new(64, 48);
for (i, px) in img.data.iter_mut().enumerate() {
let x = (i % 64) as f32 / 64.0;
*px = [x, x, x, 1.0];
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [64.0, 48.0], seed: 0, adjustment: false, env: EffectEnv::default() };
let out = render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 });
let at = |im: &Image, x: u32, y: u32| im.data[im.idx(x, y)][0];
assert!((at(&out.img, 32, 24) - at(&img, 32, 24)).abs() < 0.02);
// Barrel distortion compresses the edges: the ideal pixel at x = 56 shows footage nearer the centre.
assert!(at(&out.img, 56, 24) < at(&img, 56, 24) - 0.01, "{} vs {}", at(&out.img, 56, 24), at(&img, 56, 24));
let sp = sol.to_image([56.0, 24.0]);
assert!(sp[0] < 56.0 && (sol.to_ideal(sp)[0] - 56.0).abs() < 1e-6);
}
}

View File

@ -0,0 +1,45 @@
//! Face tracking results (Tracker panel ▸ Method ▸ Face Tracking (Detailed Features)):
//!
//! - **Face Track Points** holds one point control per facial landmark (eyebrows, eyes and
//! pupils, nose, nostrils, mouth, chin and jaw), keyed on every tracked frame by the engine's
//! mask tracker ([`effectcraft_track::face`]); expressions and other layers can follow them.
//! - **Face Measurements** holds the measurements derived from those points (head position,
//! scale and orientation, eye openness, eyebrow raise, mouth openness, width and offset), keyed
//! by Extract & Copy Face Measurements (`track.extractFaceMeasurements`).
//!
//! Both pass their input through unchanged: they are data, like expression controls.
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_track::face::{LANDMARKS, MEASUREMENTS};
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, num, p, slider};
/// Effect ids.
pub const POINTS_ID: &str = "ec.utility.facetrackpoints";
pub const MEASUREMENTS_ID: &str = "ec.utility.facemeasurements";
fn passthrough(_: &EffectCtx, b: Buf) -> Buf {
b
}
fn measurement_ui(id: &str) -> ParamUi {
match id {
"headPositionX" | "headPositionY" => slider(-100_000.0, 100_000.0, 0.0, 1920.0, 1),
"headOrientationX" | "headOrientationY" | "headOrientationZ" => slider(-180.0, 180.0, -90.0, 90.0, 1),
_ => slider(-100_000.0, 100_000.0, 0.0, 200.0, 1),
}
}
pub fn specs() -> Vec<EffectSpec> {
let points: Vec<ParamSpec> = LANDMARKS
.iter()
// Point defaults are fractions of the layer size: the canonical face in the middle.
.map(|(id, name, q)| p(id, name, Value::Vec2([0.5 + 0.25 * q[0], 0.5 + 0.25 * q[1]]), ParamUi::Point))
.collect();
let measurements: Vec<ParamSpec> = MEASUREMENTS.iter().map(|(id, name)| p(id, name, num(0.0), measurement_ui(id))).collect();
vec![
EffectSpec { id: POINTS_ID, name: "Face Track Points", category: "Utility", params: points, render: passthrough, gpu: false, float: true },
EffectSpec { id: MEASUREMENTS_ID, name: "Face Measurements", category: "Utility", params: measurements, render: passthrough, gpu: false, float: true },
]
}

View File

@ -22,6 +22,7 @@ mod distort;
mod distort2;
mod distort3;
pub mod distort4;
pub mod face_track;
mod generate;
mod generate2;
mod generate3;
@ -41,6 +42,7 @@ pub mod ocio_config;
pub mod paint;
mod perspective;
mod perspective2;
pub mod plugin;
pub mod psim;
pub mod puppet;
pub mod roto;
@ -412,6 +414,7 @@ pub fn registry() -> &'static [EffectSpec] {
v.extend(puppet::specs());
v.extend(warp_stab::specs());
v.extend(camera_tracker::specs());
v.extend(face_track::specs());
v.extend(roto::specs());
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
for s in v.iter_mut() {
@ -423,14 +426,37 @@ pub fn registry() -> &'static [EffectSpec] {
})
}
/// A built-in effect or a registered plug-in by id.
pub fn find(id: &str) -> Option<&'static EffectSpec> {
registry().iter().find(|s| s.id == id)
registry().iter().find(|s| s.id == id).or_else(|| plugin::plugins().into_iter().find(|s| s.id == id))
}
/// Every effect: the built-ins ([`registry`]) and the registered plug-ins
/// ([`plugin::register_plugin`]), sorted by category then name.
pub fn all() -> Vec<&'static EffectSpec> {
let plugins = plugin::plugins();
let mut v: Vec<&'static EffectSpec> = registry().iter().collect();
if !plugins.is_empty() {
v.extend(plugins);
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
}
v
}
/// Effects & Presets categories: [`CATEGORIES`] then the plug-ins' own, alphabetically.
pub fn categories() -> Vec<&'static str> {
let mut v: Vec<&'static str> = CATEGORIES.to_vec();
let mut extra: Vec<&'static str> = plugin::plugins().iter().map(|s| s.category).filter(|c| !CATEGORIES.contains(c)).collect();
extra.sort_unstable();
extra.dedup();
v.extend(extra);
v
}
/// Find by id or (case-insensitive) display name.
pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> {
find(name_or_id)
.or_else(|| registry().iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id)))
.or_else(|| all().into_iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id)))
.or_else(|| {
// After Effects' third-party display names we register under a generic name.
keylight::KEYLIGHT_ALIASES.iter().any(|a| a.eq_ignore_ascii_case(name_or_id)).then(|| find("ec.keying.keylight")).flatten()

View File

@ -0,0 +1,338 @@
//! The effect plug-in API, version [`PLUGIN_API_VERSION`].
//!
//! A plug-in is anything implementing [`EffectPlugin`]: a [`PluginManifest`] (stable id,
//! display name, Effects & Presets category, typed parameters) and a `render` that processes a
//! [`PluginFrame`] in place. [`register_plugin`] turns it into an ordinary [`EffectSpec`]:
//! instances are property groups like every built-in effect, so plug-in parameters animate,
//! take expressions, show in Effect Controls and are addressable by agents, and the effect is
//! applied by id (`effect.apply {"effect": "org.example.posterize"}`) like a built-in.
//!
//! Plug-ins come from Rust code (implement the trait and register it at start-up) or from
//! sandboxed WebAssembly modules (`effectcraft-plugin`, see `docs/plugins.md`), which implement
//! the same contract through a small C-like ABI.
//!
//! The contract (stable within an API version):
//! * pixels are premultiplied RGBA `f32` (0–1, may exceed 1 in 32 bpc projects), row-major,
//! in the layer's buffer, which may be scaled (preview resolution) and padded;
//! * parameters are flattened to `f64`s in declaration order: slider / angle / checkbox (0 or 1)
//! / popup (0-based index) take one value, point two (**frame pixels**, already mapped to the
//! buffer), colour four (RGBA 0–1);
//! * `time` is layer time in seconds; `scale` is the buffer's pixels per layer pixel (scale
//! pixel distances like blur radii by it);
//! * rendering must be deterministic (same inputs, same output) and may run on several threads
//! at once.
use std::sync::{Arc, OnceLock, RwLock};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use serde::{Deserialize, Serialize};
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, RenderFn};
/// The plug-in API version this build implements.
pub const PLUGIN_API_VERSION: u32 = 1;
/// The most plug-in effects one process can register.
pub const MAX_PLUGINS: usize = 64;
/// A plug-in parameter's kind, default and range.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PluginParamKind {
Slider {
default: f64,
#[serde(default)]
min: f64,
#[serde(default = "hundred")]
max: f64,
/// Slider range (defaults to min..max).
#[serde(default, rename = "sliderMin")]
slider_min: Option<f64>,
#[serde(default, rename = "sliderMax")]
slider_max: Option<f64>,
#[serde(default = "two")]
decimals: u8,
},
Angle {
#[serde(default)]
default: f64,
},
Checkbox {
#[serde(default)]
default: bool,
},
Popup {
options: Vec<String>,
/// 0-based index.
#[serde(default)]
default: u32,
},
/// Default as fractions of the layer size (`[0.5, 0.5]` = centre).
Point {
#[serde(default = "centre")]
default: [f64; 2],
},
/// RGBA, 0–1.
Color {
#[serde(default = "white")]
default: [f64; 4],
},
}
fn hundred() -> f64 {
100.0
}
fn two() -> u8 {
2
}
fn centre() -> [f64; 2] {
[0.5, 0.5]
}
fn white() -> [f64; 4] {
[1.0; 4]
}
impl PluginParamKind {
/// How many `f64`s the parameter flattens to.
pub fn width(&self) -> usize {
match self {
PluginParamKind::Point { .. } => 2,
PluginParamKind::Color { .. } => 4,
_ => 1,
}
}
}
/// One parameter of a plug-in effect.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PluginParam {
/// Stable id (the property's match name; letters, digits, `_`).
pub id: String,
pub name: String,
#[serde(flatten)]
pub kind: PluginParamKind,
}
/// What a plug-in declares about itself.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PluginManifest {
/// The plug-in API version it was written for (must be [`PLUGIN_API_VERSION`]).
pub api: u32,
/// Stable, reverse-domain effect id (`org.example.posterize`); `ec.` is reserved.
pub id: String,
pub name: String,
/// Effects & Presets category: one of [`crate::CATEGORIES`] or a new one.
pub category: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub params: Vec<PluginParam>,
}
impl PluginManifest {
/// Check the manifest against this API version.
pub fn validate(&self) -> Result<(), String> {
if self.api != PLUGIN_API_VERSION {
return Err(format!("plug-in `{}` targets plug-in API {} but this build implements API {PLUGIN_API_VERSION}", self.id, self.api));
}
let ok_id = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
if !ok_id(&self.id) || self.id.starts_with("ec.") {
return Err(format!("bad plug-in id `{}` (reverse-domain, letters, digits, `.`, `_`, `-`; `ec.` is reserved)", self.id));
}
if self.name.trim().is_empty() || self.category.trim().is_empty() {
return Err(format!("plug-in `{}` needs a name and a category", self.id));
}
let mut seen = std::collections::HashSet::new();
for p in &self.params {
if p.id.is_empty() || !p.id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') || !seen.insert(p.id.as_str()) {
return Err(format!("plug-in `{}`: bad or duplicate parameter id `{}`", self.id, p.id));
}
if let PluginParamKind::Popup { options, default } = &p.kind
&& (options.is_empty() || *default as usize >= options.len())
{
return Err(format!("plug-in `{}`: popup `{}` needs options and a default index in range", self.id, p.id));
}
}
Ok(())
}
/// Total flattened parameter count.
pub fn flat_len(&self) -> usize {
self.params.iter().map(|p| p.kind.width()).sum()
}
}
/// The pixels a plug-in renders, in place.
pub struct PluginFrame<'a> {
pub width: u32,
pub height: u32,
/// Premultiplied RGBA, row-major, `width * height` pixels.
pub pixels: &'a mut [[f32; 4]],
/// Buffer pixels per layer pixel (preview resolution).
pub scale: f64,
}
/// Parameter values for one render, flattened (see the module docs) with typed accessors.
pub struct PluginParams<'a> {
pub manifest: &'a PluginManifest,
pub flat: &'a [f64],
}
impl PluginParams<'_> {
fn slot(&self, id: &str) -> Option<&[f64]> {
let mut at = 0;
for p in &self.manifest.params {
let w = p.kind.width();
if p.id == id {
return self.flat.get(at..at + w);
}
at += w;
}
None
}
/// Slider / angle / popup index / checkbox (0 or 1).
pub fn f(&self, id: &str) -> f64 {
self.slot(id).and_then(|s| s.first().copied()).unwrap_or(0.0)
}
pub fn b(&self, id: &str) -> bool {
self.f(id) != 0.0
}
/// Point in frame pixels.
pub fn point(&self, id: &str) -> [f64; 2] {
self.slot(id).map(|s| [s[0], s[1]]).unwrap_or([0.0; 2])
}
pub fn color(&self, id: &str) -> [f64; 4] {
self.slot(id).map(|s| [s[0], s[1], s[2], s[3]]).unwrap_or([1.0; 4])
}
}
/// An effect plug-in.
pub trait EffectPlugin: Send + Sync {
fn manifest(&self) -> &PluginManifest;
/// Process `frame` in place. `time` is layer time in seconds. An error leaves the frame as
/// it was (the error is logged once).
fn render(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String>;
/// Where the plug-in came from (a file path, `built-in`…), for listings.
fn source(&self) -> String {
"built-in".into()
}
}
static SLOTS: [OnceLock<Arc<dyn EffectPlugin>>; MAX_PLUGINS] = [const { OnceLock::new() }; MAX_PLUGINS];
/// Registered plug-in effects, in registration order.
fn plugin_specs() -> &'static RwLock<Vec<&'static EffectSpec>> {
static P: OnceLock<RwLock<Vec<&'static EffectSpec>>> = OnceLock::new();
P.get_or_init(|| RwLock::new(vec![]))
}
/// The registered plug-in effects.
pub fn plugins() -> Vec<&'static EffectSpec> {
plugin_specs().read().map(|v| v.clone()).unwrap_or_default()
}
/// The plug-in behind a registered effect id.
pub fn plugin(id: &str) -> Option<Arc<dyn EffectPlugin>> {
SLOTS.iter().filter_map(|s| s.get()).find(|p| p.manifest().id == id).cloned()
}
fn leak(s: &str) -> &'static str {
Box::leak(s.to_string().into_boxed_str())
}
fn param_spec(p: &PluginParam) -> ParamSpec {
let (default, ui) = match &p.kind {
PluginParamKind::Slider { default, min, max, slider_min, slider_max, decimals } => (
Value::Scalar(*default),
ParamUi::Slider { min: *min, max: *max, slider_min: slider_min.unwrap_or(*min), slider_max: slider_max.unwrap_or(*max), decimals: *decimals },
),
PluginParamKind::Angle { default } => (Value::Scalar(*default), ParamUi::Angle),
PluginParamKind::Checkbox { default } => (Value::Bool(*default), ParamUi::Checkbox),
PluginParamKind::Popup { options, default } => (Value::Enum(*default), ParamUi::Popup { options: options.clone() }),
PluginParamKind::Point { default } => (Value::Vec2(*default), ParamUi::Point),
PluginParamKind::Color { default } => (Value::Color(*default), ParamUi::Color),
};
ParamSpec { id: leak(&p.id), name: leak(&p.name), default, ui }
}
/// Register a plug-in effect: it appears in Effects & Presets under its category and is
/// applied by id like a built-in. Fails for a bad manifest, a taken id or when
/// [`MAX_PLUGINS`] are registered.
pub fn register_plugin(plugin: Arc<dyn EffectPlugin>) -> Result<&'static EffectSpec, String> {
let m = plugin.manifest().clone();
m.validate()?;
let mut specs = plugin_specs().write().map_err(|_| "plug-in registry poisoned".to_string())?;
if crate::registry().iter().any(|s| s.id == m.id) || specs.iter().any(|s| s.id == m.id) {
return Err(format!("an effect with id `{}` is already registered", m.id));
}
let slot = SLOTS.iter().position(|s| s.get().is_none()).ok_or_else(|| format!("too many plug-ins (at most {MAX_PLUGINS})"))?;
SLOTS[slot].set(plugin).map_err(|_| "plug-in slot taken".to_string())?;
let spec: &'static EffectSpec = Box::leak(Box::new(EffectSpec {
id: leak(&m.id),
name: leak(&m.name),
category: leak(&m.category),
params: m.params.iter().map(param_spec).collect(),
render: TRAMPOLINES[slot],
gpu: false,
float: true,
}));
specs.push(spec);
Ok(spec)
}
/// Each slot needs its own `fn` (effect render functions are plain function pointers).
fn trampoline<const I: usize>(ctx: &EffectCtx, buf: Buf) -> Buf {
run_slot(I, ctx, buf)
}
macro_rules! trampolines {
($($i:literal)*) => { [$(trampoline::<$i> as RenderFn),*] };
}
const TRAMPOLINES: [RenderFn; MAX_PLUGINS] = trampolines!(
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
);
/// Flatten an instance's evaluated parameters for the plug-in (points mapped to frame pixels).
pub fn flatten(m: &PluginManifest, ctx: &EffectCtx, buf: &Buf) -> Vec<f64> {
let mut out = Vec::with_capacity(m.flat_len());
for p in &m.params {
let v = ctx.params.get(&p.id);
match &p.kind {
PluginParamKind::Point { .. } => {
let q = v.map(Value::as_vec2).unwrap_or([0.0; 2]);
let (x, y) = buf.to_px(q);
out.extend([x, y]);
}
PluginParamKind::Color { .. } => {
let c = v.map(Value::as_color).unwrap_or([1.0; 4]);
out.extend(c.iter().map(|x| *x as f64));
}
PluginParamKind::Checkbox { .. } => out.push(if v.map(Value::as_bool).unwrap_or(false) { 1.0 } else { 0.0 }),
PluginParamKind::Popup { .. } => out.push(v.map(Value::as_enum).unwrap_or(0) as f64),
_ => out.push(v.map(Value::as_f64).unwrap_or(0.0)),
}
}
out
}
fn run_slot(slot: usize, ctx: &EffectCtx, mut buf: Buf) -> Buf {
let Some(plugin) = SLOTS[slot].get() else { return buf };
let m = plugin.manifest();
let flat = flatten(m, ctx, &buf);
let params = PluginParams { manifest: m, flat: &flat };
let (width, height) = (buf.img.width, buf.img.height);
let before = buf.img.data.clone();
let mut frame = PluginFrame { width, height, pixels: &mut buf.img.data, scale: buf.scale };
if let Err(e) = plugin.render(&mut frame, &params, ctx.time) {
log::warn!("plug-in `{}`: {e}", m.id);
buf.img.data = before;
}
buf
}

View File

@ -17,8 +17,11 @@
//! Edge Cropping ignores bad edge pixels of the input frames;
//! - **Show Track Points** draws the analysed background features.
//!
//! *Subspace Warp* is approximated by a per-frame perspective warp (no content-preserving
//! mesh warp yet), and *Rolling Shutter Ripple* is stored but has no effect.
//! *Subspace Warp* warps each frame with a content-preserving mesh fitted to the analysis'
//! subspace-smoothed feature trajectories ([`effectcraft_track::subspace`]); analyses made
//! before trajectories were stored fall back to a perspective warp. *Rolling Shutter Ripple*
//! picks the mesh: Automatic Reduction (12 × 8, stiffer) or Enhanced Reduction (twice the rows,
//! softer), which follows the row-wise wobble of rolling-shutter shake more closely.
//!
//! Without an analysis (or with one made for another layer size) the input passes through.
@ -30,7 +33,7 @@ use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use effectcraft_track::Homography;
use effectcraft_track::stabilize::{Framing, Method, Plan, StabResult, StabSettings, WarpAnalysis, plan};
use effectcraft_track::stabilize::{Framing, Method, Plan, Ripple, StabResult, StabSettings, WarpAnalysis, plan};
use rayon::prelude::*;
use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider};
@ -145,6 +148,7 @@ pub fn settings(params: &Params, fps: f64) -> StabSettings {
// AE's 0…100 slider (50 = balanced) onto the planner's −100…100.
crop_less_smooth_more: (f(params, "advanced/cropLessSmoothMore") - 50.0) * 2.0,
fps,
ripple: if e(params, "advanced/rollingShutterRipple") == 1 { Ripple::Enhanced } else { Ripple::Automatic },
}
}
@ -175,7 +179,7 @@ fn settings_key(s: &StabSettings) -> u64 {
for v in [s.smoothness, s.max_scale, s.action_safe, s.additional_scale, s.crop_less_smooth_more, s.fps] {
h.write_u64(v.to_bits());
}
h.write(format!("{:?}{:?}{:?}{:?}", s.result, s.method, s.preserve_scale, s.framing).as_bytes());
h.write(format!("{:?}{:?}{:?}{:?}{:?}", s.result, s.method, s.preserve_scale, s.framing, s.ripple).as_bytes());
h.finish()
}
@ -275,6 +279,13 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
}
let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 };
let Some(inv) = pl.warps[k].inverse() else { return buf };
// Output point → source point of this frame (a mesh lookup for Subspace Warp).
let mesh = pl.meshes.get(k);
let finv = pl.framing.inverse().unwrap_or(Homography::IDENTITY);
let src_of = |p: [f64; 2]| match mesh {
Some(m) => m.sample(finv.apply(p)),
None => inv.apply(p),
};
let (ow, oh) = (((size[0] * sc).ceil() as u32).max(1), ((size[1] * sc).ceil() as u32).max(1));
let synth = matches!(settings(ctx.params, 30.0).framing, Framing::SynthesizeEdges);
let crop = [
@ -306,7 +317,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
{
continue;
}
let q = inv.apply(p);
let q = src_of(p);
if !synth {
*px = sample(src, q);
continue;
@ -337,12 +348,12 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
});
let need = need_m.into_inner().unwrap_or_else(|e| e.into_inner());
if synth && need.iter().any(|w| *w > 0.0) {
synthesize(ctx, &a, k, &inv, &crop, sc, &mut out, &need);
synthesize(ctx, &a, k, &src_of, &crop, sc, &mut out, &need);
}
if b(ctx.params, "advanced/showTrackPoints") {
let r = (2.5 * f(ctx.params, "advanced/trackPointSize") / 100.0 * sc).max(0.5);
for q in &a.frames[k].points {
let o = pl.warps[k].apply([q[0] as f64, q[1] as f64]);
let Some(o) = pl.output_of(k, [q[0] as f64, q[1] as f64]) else { continue };
dot(&mut out, [o[0] * sc, o[1] * sc], r, [0.25, 0.75, 1.0, 1.0]);
}
}
@ -352,7 +363,16 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
/// Fill the pixels flagged in `need` (weight = how much synthesized content they take) from
/// neighbouring frames, nearest first.
#[allow(clippy::too_many_arguments)]
fn synthesize(ctx: &EffectCtx, a: &WarpAnalysis, k: usize, inv: &Homography, crop: &[f64; 4], sc: f64, out: &mut Image, need: &[f32]) {
fn synthesize(
ctx: &EffectCtx,
a: &WarpAnalysis,
k: usize,
src_of: &(dyn Fn([f64; 2]) -> [f64; 2] + Sync),
crop: &[f64; 4],
sc: f64,
out: &mut Image,
need: &[f32],
) {
let Some(host) = ctx.env.host else { return };
let n = a.frames.len();
let range = ((f(ctx.params, "advanced/synthesizeInputRange").max(0.0) / a.frame_duration.max(1e-6)).round() as usize).min(60);
@ -376,7 +396,7 @@ fn synthesize(ctx: &EffectCtx, a: &WarpAnalysis, k: usize, inv: &Homography, cro
.filter(|i| !filled[**i])
.filter_map(|i| {
let (x, y) = (i % ow, i / ow);
let q = inv.apply([(x as f64 + 0.5) / sc, (y as f64 + 0.5) / sc]);
let q = src_of([(x as f64 + 0.5) / sc, (y as f64 + 0.5) / sc]);
let qj = m.apply(q);
inside(crop, qj).then(|| (*i, nb.img.sample_bilinear_clamped(qj[0] * nb.scale + nb.offset[0], qj[1] * nb.scale + nb.offset[1])))
})
@ -412,7 +432,7 @@ mod tests {
use effectcraft_track::stabilize::FrameMotion;
fn analysis_json(n: usize, w: f64, h: f64, shift: impl Fn(usize) -> [f64; 2]) -> String {
let mut a = WarpAnalysis { version: 1, start: 0.0, frame_duration: 1.0 / 25.0, size: [w, h], detailed: false, frames: vec![] };
let mut a = WarpAnalysis { version: 1, start: 0.0, frame_duration: 1.0 / 25.0, size: [w, h], detailed: false, frames: vec![], tracks: vec![] };
for k in 0..n {
let mut fm = FrameMotion::default();
if k > 0 {

View File

@ -26,6 +26,7 @@ effectcraft-lottie = { workspace = true }
effectcraft-psd = { workspace = true }
kurbo = { workspace = true }
effectcraft-svg = { workspace = true }
effectcraft-pdf = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@ -0,0 +1,41 @@
// Create Null at Selected Layers — an EffectCraft sample script (original work, MIT OR
// Apache-2.0).
//
// Adds a null at the centroid of the selected layers' positions (at the current time) and
// parents the layers that have no parent to it (set PARENT below to false to skip that).
// One undo step.
(function createNullAtCentroid() {
var PARENT = true;
var comp = app.project.activeItem;
if (!(comp instanceof CompItem)) {
alert("Create Null: open a composition first.");
return;
}
var layers = comp.selectedLayers;
if (layers.length === 0) {
alert("Create Null: select one or more layers.");
return;
}
var t = comp.time;
var sum = [0, 0, 0];
for (var i = 0; i < layers.length; i++) {
var p = layers[i].transform.position.valueAtTime(t, false);
sum[0] += p[0];
sum[1] += p[1];
sum[2] += p.length > 2 ? p[2] : 0;
}
var c = [sum[0] / layers.length, sum[1] / layers.length, sum[2] / layers.length];
app.beginUndoGroup("Create Null at Selected Layers");
var nul = comp.layers.addNull();
nul.name = "Centroid";
nul.transform.position.setValue(c);
if (PARENT) {
for (var k = 0; k < layers.length; k++) {
if (!layers[k].parent) layers[k].setParentWithJump(nul);
}
}
app.endUndoGroup();
writeLn("Null at [" + c[0].toFixed(1) + ", " + c[1].toFixed(1) + "]");
})();

View File

@ -0,0 +1,7 @@
asset: Create Null at Selected Layers.jsx
title: EffectCraft sample script (Create Null at Selected Layers)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-03 by EffectCraft contributors (M13.1)
notes: Written from scratch for EffectCraft against the documented scripting behaviour; not derived from any Adobe sample.

View File

@ -0,0 +1,71 @@
// Rename Layers — an EffectCraft sample script (original work, MIT OR Apache-2.0).
//
// Renames the selected layers of the active composition (all layers when none are selected):
// find & replace, then an optional prefix, suffix and running number. One undo step.
(function renameLayers() {
var comp = app.project.activeItem;
if (!(comp instanceof CompItem)) {
alert("Rename Layers: open a composition first.");
return;
}
var layers = comp.selectedLayers;
if (layers.length === 0) {
layers = [];
for (var i = 1; i <= comp.numLayers; i++) layers.push(comp.layer(i));
}
var dlg = new Window("dialog", "Rename Layers");
dlg.orientation = "column";
dlg.alignChildren = ["fill", "top"];
var find = dlg.add("panel", undefined, "Find and Replace");
find.alignChildren = ["fill", "top"];
var row1 = find.add("group");
row1.add("statictext", undefined, "Find:");
var findText = row1.add("edittext", undefined, "");
findText.characters = 20;
var row2 = find.add("group");
row2.add("statictext", undefined, "Replace:");
var replaceText = row2.add("edittext", undefined, "");
replaceText.characters = 20;
var extra = dlg.add("panel", undefined, "Add");
extra.alignChildren = ["fill", "top"];
var row3 = extra.add("group");
row3.add("statictext", undefined, "Prefix:");
var prefix = row3.add("edittext", undefined, "");
prefix.characters = 12;
row3.add("statictext", undefined, "Suffix:");
var suffix = row3.add("edittext", undefined, "");
suffix.characters = 12;
var number = extra.add("checkbox", undefined, "Number the layers");
var row4 = extra.add("group");
row4.add("statictext", undefined, "Start at:");
var start = row4.add("edittext", undefined, "1");
start.characters = 5;
start.enabled = false;
number.onClick = function () {
start.enabled = number.value;
};
var buttons = dlg.add("group");
buttons.alignment = "right";
buttons.add("button", undefined, "Cancel", { name: "cancel" });
buttons.add("button", undefined, "OK", { name: "ok" });
if (dlg.show() !== 1) return;
app.beginUndoGroup("Rename Layers");
var n = parseInt(start.text, 10);
if (isNaN(n)) n = 1;
for (var k = 0; k < layers.length; k++) {
var name = layers[k].name;
if (findText.text !== "") name = name.split(findText.text).join(replaceText.text);
name = prefix.text + name + suffix.text;
if (number.value) name += " " + (n + k);
layers[k].name = name;
}
app.endUndoGroup();
writeLn("Renamed " + layers.length + " layer(s).");
})();

View File

@ -0,0 +1,7 @@
asset: Rename Layers.jsx
title: EffectCraft sample script (Rename Layers)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-03 by EffectCraft contributors (M13.1)
notes: Written from scratch for EffectCraft against the documented scripting behaviour; not derived from any Adobe sample.

View File

@ -0,0 +1,52 @@
// Render Queue Batch — an EffectCraft sample script (original work, MIT OR Apache-2.0).
//
// Adds the compositions selected in the Project panel (every composition when none are
// selected) to the Render Queue, each with an output file named after the comp in a folder you
// choose, and optionally starts rendering.
(function renderQueueBatch() {
var comps = [];
var sel = app.project.selection;
for (var i = 0; i < sel.length; i++) if (sel[i] instanceof CompItem) comps.push(sel[i]);
if (comps.length === 0) {
for (var j = 1; j <= app.project.numItems; j++) {
var it = app.project.item(j);
if (it instanceof CompItem) comps.push(it);
}
}
if (comps.length === 0) {
alert("Render Queue Batch: the project has no compositions.");
return;
}
var dlg = new Window("dialog", "Render Queue Batch");
dlg.alignChildren = ["fill", "top"];
dlg.add("statictext", undefined, comps.length + " composition(s) will be queued.");
var row = dlg.add("group");
row.add("statictext", undefined, "Output folder:");
var folder = row.add("edittext", undefined, Folder.desktop ? Folder.desktop.fsName : "");
folder.characters = 30;
var fmtRow = dlg.add("group");
fmtRow.add("statictext", undefined, "Format:");
var format = fmtRow.add("dropdownlist", undefined, ["H.264 (.mp4)", "PNG Sequence"]);
format.selection = 0;
var go = dlg.add("checkbox", undefined, "Start rendering");
var buttons = dlg.add("group");
buttons.alignment = "right";
buttons.add("button", undefined, "Cancel", { name: "cancel" });
buttons.add("button", undefined, "Queue", { name: "ok" });
if (dlg.show() !== 1) return;
var png = format.selection && format.selection.index === 1;
app.beginUndoGroup("Render Queue Batch");
for (var k = 0; k < comps.length; k++) {
var rq = app.project.renderQueue.items.add(comps[k]);
var name = comps[k].name.replace(/[\\\/:*?"<>|]/g, "_");
var om = rq.outputModule(1);
if (png) om.format = "png";
om.file = new File(folder.text + "/" + name + (png ? "_[#####].png" : ".mp4"));
}
app.endUndoGroup();
writeLn("Queued " + comps.length + " composition(s).");
if (go.value) app.project.renderQueue.render();
})();

View File

@ -0,0 +1,7 @@
asset: Render Queue Batch.jsx
title: EffectCraft sample script (Render Queue Batch)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-03 by EffectCraft contributors (M13.1)
notes: Written from scratch for EffectCraft against the documented scripting behaviour; not derived from any Adobe sample.

View File

@ -0,0 +1,99 @@
// Layer Tools — an EffectCraft sample ScriptUI panel (original work, MIT OR Apache-2.0).
//
// Installed in the ScriptUI Panels folder it docks like any panel (Window ▸ Layer Tools.jsx);
// run from File ▸ Scripts it opens as a floating palette. Buttons act on the selected layers of
// the active composition.
(function layerTools(thisObj) {
var ui = thisObj instanceof Panel ? thisObj : new Window("palette", "Layer Tools", undefined, { resizeable: true });
ui.orientation = "column";
ui.alignChildren = ["fill", "top"];
ui.spacing = 6;
function comp() {
var c = app.project.activeItem;
if (!(c instanceof CompItem)) {
status.text = "Open a composition first.";
return null;
}
return c;
}
function selected(c) {
var l = c.selectedLayers;
if (l.length === 0) status.text = "Select some layers.";
return l;
}
var labelRow = ui.add("group");
labelRow.add("statictext", undefined, "Opacity:");
var opacity = labelRow.add("slider", undefined, 100, 0, 100);
opacity.preferredSize.width = 120;
var opacityValue = labelRow.add("statictext", undefined, "100%");
opacityValue.characters = 5;
opacity.onChanging = function () {
opacityValue.text = Math.round(opacity.value) + "%";
};
opacity.onChange = function () {
var c = comp();
if (!c) return;
var layers = selected(c);
app.beginUndoGroup("Set Opacity");
for (var i = 0; i < layers.length; i++) layers[i].transform.opacity.setValue(Math.round(opacity.value));
app.endUndoGroup();
status.text = "Opacity " + Math.round(opacity.value) + "% on " + layers.length + " layer(s).";
};
var shy = ui.add("checkbox", undefined, "Shy");
shy.onClick = function () {
var c = comp();
if (!c) return;
var layers = selected(c);
app.beginUndoGroup("Shy Layers");
for (var i = 0; i < layers.length; i++) layers[i].shy = shy.value;
app.endUndoGroup();
};
var nameRow = ui.add("group");
nameRow.alignChildren = ["fill", "center"];
var newName = nameRow.add("edittext", undefined, "Layer");
newName.characters = 14;
var renameBtn = nameRow.add("button", undefined, "Rename");
renameBtn.onClick = function () {
var c = comp();
if (!c) return;
var layers = selected(c);
app.beginUndoGroup("Rename Layers");
for (var i = 0; i < layers.length; i++) layers[i].name = layers.length > 1 ? newName.text + " " + (i + 1) : newName.text;
app.endUndoGroup();
status.text = "Renamed " + layers.length + " layer(s).";
};
var nullBtn = ui.add("button", undefined, "Null at Centroid");
nullBtn.onClick = function () {
var c = comp();
if (!c) return;
var layers = selected(c);
if (layers.length === 0) return;
var x = 0, y = 0;
for (var i = 0; i < layers.length; i++) {
var p = layers[i].transform.position.value;
x += p[0];
y += p[1];
}
app.beginUndoGroup("Null at Centroid");
var n = c.layers.addNull();
n.transform.position.setValue([x / layers.length, y / layers.length]);
app.endUndoGroup();
status.text = "Added " + n.name + ".";
};
var status = ui.add("statictext", undefined, "Select layers, then pick a tool.");
status.characters = 30;
if (ui instanceof Window) {
ui.center();
ui.show();
} else {
ui.layout.layout(true);
}
})(this);

View File

@ -0,0 +1,7 @@
asset: Layer Tools.jsx
title: EffectCraft sample script (Layer Tools)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-03 by EffectCraft contributors (M13.1)
notes: Written from scratch for EffectCraft against the documented scripting behaviour; not derived from any Adobe sample.

View File

@ -0,0 +1,37 @@
// Sort Layers by In Point — an EffectCraft sample script (original work, MIT OR Apache-2.0).
//
// Reorders the selected layers of the active composition (all layers when none are selected)
// so the layer that starts first is on top. Layers with the same in point keep their order.
// One undo step.
(function sortLayersByInPoint() {
var comp = app.project.activeItem;
if (!(comp instanceof CompItem)) {
alert("Sort Layers by In Point: open a composition first.");
return;
}
var layers = comp.selectedLayers;
if (layers.length === 0) {
layers = [];
for (var i = 1; i <= comp.numLayers; i++) layers.push(comp.layer(i));
}
if (layers.length < 2) return;
// The slots the layers occupy now (stack indices, top first).
var slots = layers.map(function (l) { return l.index; }).sort(function (a, b) { return a - b; });
var order = layers
.map(function (l, k) { return { layer: l, inPoint: l.inPoint, k: k }; })
.sort(function (a, b) { return a.inPoint - b.inPoint || a.k - b.k; });
app.beginUndoGroup("Sort Layers by In Point");
// Place each layer, earliest first, in the next slot from the top.
for (var j = 0; j < order.length; j++) {
var target = slots[j];
var layer = order[j].layer;
if (layer.index === target) continue;
var anchor = comp.layer(target);
layer.moveBefore(anchor);
}
app.endUndoGroup();
writeLn("Sorted " + layers.length + " layer(s) by in point.");
})();

View File

@ -0,0 +1,7 @@
asset: Sort Layers by In Point.jsx
title: EffectCraft sample script (Sort Layers by In Point)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-03 by EffectCraft contributors (M13.1)
notes: Written from scratch for EffectCraft against the documented scripting behaviour; not derived from any Adobe sample.

View File

@ -433,8 +433,8 @@ fn ae(v: [f64; 3]) -> Vec3 {
}
/// Composition ▸ VR ▸ Create VR Environment: renders the active comp's 3D scene in every
/// direction. Six face comps (a 90° camera each at `position`, default the comp centre, with a
/// copy of the scene's layers), a 3:2 cube map of them, and an equirectangular output comp
/// direction. Six face comps (a 90° camera each at `position`, default the comp centre, looking
/// at the scene nested as a collapsed 3D precomp), a 3:2 cube map of them, and an equirectangular output comp
/// (VR Converter, cube map → equirectangular). One undo step.
fn create_vr_environment(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
@ -462,30 +462,41 @@ fn create_vr_environment(s: &mut Session, p: &Value) -> Result<Value> {
id_of(&s.execute("layer.newCamera", json!({"comp": fc, "name": format!("{fname} Camera"), "angleOfView": 90.0, "type": "oneNode"}))?, "layer")?;
s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/position", "value": center}))?;
s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/orientation", "value": o}))?;
// The scene's layers (not its cameras), in the scene's coordinates, under the face
// camera.
let layers: Vec<effectcraft_project::Layer> = src.layers.iter().filter(|l| !l.is_camera()).cloned().collect();
s.edit("Create VR Environment", None, |proj, _| {
let mut next = proj.next_id;
let mut map = std::collections::BTreeMap::new();
let mut copies = vec![];
for l in &layers {
let mut c = l.clone();
super::edit::reid(&mut c, &mut next);
map.insert(l.id, c.id);
copies.push(c);
}
for c in &mut copies {
c.parent = c.parent.and_then(|p| map.get(&p).copied());
if let Some(m) = &mut c.track_matte {
m.layer = map.get(&m.layer).copied().unwrap_or(m.layer);
// The scene, nested as a collapsed 3D precomp whose layer maps the scene's
// coordinates onto the face comp's unchanged (anchor = position): its 3D layers are
// seen through the face camera and edits to the scene show in every face. Lights
// inside a collapsed precomp do not light it (the containing comp's do), so the
// scene's lights are copied next to it.
let pre = id_of(&s.execute("layer.addItem", json!({"comp": fc, "item": cid.0, "time": 0.0}))?, "layer")?;
let mid = [src.width as f64 / 2.0, src.height as f64 / 2.0, 0.0];
s.execute("layer.setSwitch", json!({"comp": fc, "layers": [pre], "switch": "threeD", "value": true}))?;
s.execute("layer.setSwitch", json!({"comp": fc, "layers": [pre], "switch": "collapse", "value": true}))?;
s.execute("prop.set", json!({"comp": fc, "layer": pre, "path": "transform/anchor", "value": mid}))?;
s.execute("prop.set", json!({"comp": fc, "layer": pre, "path": "transform/position", "value": mid}))?;
let lights: Vec<effectcraft_project::Layer> = src.layers.iter().filter(|l| l.is_light()).cloned().collect();
if !lights.is_empty() {
s.edit("Create VR Environment", None, |proj, _| {
let mut next = proj.next_id;
let mut map = std::collections::BTreeMap::new();
let mut copies = vec![];
for l in &lights {
let mut c = l.clone();
super::edit::reid(&mut c, &mut next);
map.insert(l.id, c.id);
copies.push(c);
}
}
proj.next_id = next;
let comp = proj.comp_mut(ItemId(fc)).ok_or(EngineError::NoComp)?;
comp.layers.extend(copies);
Ok(())
})?;
for c in &mut copies {
c.parent = c.parent.and_then(|p| map.get(&p).copied());
}
proj.next_id = next;
let comp = proj.comp_mut(ItemId(fc)).ok_or(EngineError::NoComp)?;
let at = comp.layers.len().saturating_sub(1);
for (k, c) in copies.into_iter().enumerate() {
comp.layers.insert(at + k, c);
}
Ok(())
})?;
}
faces.push(fc);
}
let cube = id_of(

View File

@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Track Camera",
["Animation"],
None,
"{layer?, shotType?: fixed|variable|specify, aov?: degrees, solveMethod?: auto|typical|flat|tripod, detailed?, wait?}",
"{layer?, shotType?: fixed|variable|specify, aov?: degrees, solveMethod?: auto|typical|flat|tripod, detailed?, lensDistortion?: solve k1/k2, undistort?: render undistorted, wait?}",
can_track,
track_camera
),
@ -140,6 +140,8 @@ fn track_camera(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("track.camera", "aov: 1..170 degrees"));
}
let detailed = b_p(p, "detailed");
let lens = b_p(p, "lensDistortion");
let undistort = b_p(p, "undistort");
let existing = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(Layer::effects).and_then(|fx| fx.groups().find(|g| is_tracker(g)).map(|g| g.uid));
let uid = match existing {
Some(u) => u,
@ -148,7 +150,7 @@ fn track_camera(s: &mut Session, p: &Value) -> Result<Value> {
r["effects"].get(0).and_then(Value::as_u64).ok_or_else(|| EngineError::Other("the effect could not be applied".into()))?
}
};
if shot.is_some() || method.is_some() || aov.is_some() || detailed.is_some() {
if shot.is_some() || method.is_some() || aov.is_some() || detailed.is_some() || lens.is_some() || undistort.is_some() {
s.edit("3D Camera Tracker Settings", None, |proj, _| {
let g = proj
.comp_mut(cid)
@ -176,6 +178,12 @@ fn track_camera(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(d) = detailed {
set("advanced/detailedAnalysis", KV::Bool(d));
}
if let Some(d) = lens {
set("advanced/lensDistortion", KV::Bool(d));
}
if let Some(d) = undistort {
set("advanced/undistort", KV::Bool(d));
}
Ok(())
})?;
}
@ -222,6 +230,7 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
out["shotType"] = json!(settings.shot.label());
out["solveMethod"] = json!(settings.method.label());
out["deleted"] = json!(settings.deleted.len());
out["lensDistortionSolved"] = json!(settings.lens_distortion);
let solve = ct::solve(&params);
out["solved"] = json!(solve.is_some());
if let Some(sv) = &solve {
@ -233,6 +242,11 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
out["focalLength"] = json!(f);
out["horizontalAngleOfView"] = json!(sv.hfov(f));
out["groundPlane"] = json!(sv.ground.is_some());
out["lensDistortion"] = match &sv.distortion {
Some(d) => json!({"k1": d.k1, "k2": d.k2, "radius": d.radius}),
None => Value::Null,
};
out["undistort"] = json!(ct::undistorts(&params, sv));
if let Some(pl) = placed(&s.project, cid, lid, uid) {
let k = pl.frame_at(s.time_of(cid));
let (pos, ori, zoom) = pl.camera(k);

View File

@ -82,7 +82,37 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result<Vec
Ok(out)
}
/// Save Frame As ▸ File with `queue`: a Render Queue item for the current frame, from the Frame
/// Default templates (Edit ▸ Templates).
fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan};
use effectcraft_project::render_templates::TemplateSlot;
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
let mut it = RenderQueueItem::new(0, cid);
it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still);
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
it.output = s.project.render_templates.default_output_module(TemplateSlot::Still);
if let Some(path) = str_p(p, "path") {
it.output.output = path.to_string();
} else if !it.output.format.is_sequence() {
it.output.output = "[compName]_frame.[fileExtension]".into();
}
let id = s.edit("Save Frame As", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
let id = it.id;
proj.render_queue.push(it);
Ok(id)
})?;
let path = s.project.render_queue.last().and_then(|i| s.resolve_output(i));
Ok(json!({"item": id, "queued": true, "output": path, "time": t.seconds()}))
}
fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("queue").and_then(Value::as_bool) == Some(true) {
return queue_frame(s, p);
}
let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string();
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
@ -124,7 +154,15 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi),
cmd!("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", ["Composition"], None, "{layers?}", has_layers, crop_to_layers),
cmd!("comp.saveFrameAs", "File...", ["Composition", "Save Frame As"], Some("Cmd+Alt+S"), "{path (.png), time?, scale?}", has_comp, save_frame),
cmd!(
"comp.saveFrameAs",
"File...",
["Composition", "Save Frame As"],
Some("Cmd+Alt+S"),
"{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}",
has_comp,
save_frame
),
cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time),
]
}

View File

@ -28,12 +28,14 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
});
match op {
"shapesFromVector" => {
let Some(f) = footage.filter(|f| f.codec == "SVG" || f.path.to_ascii_lowercase().ends_with(".svg")) else {
skipped.push(format!("{}: not a vector (SVG) footage layer", src.name));
let Some(f) = footage.filter(crate::vector::is_vector) else {
skipped.push(format!("{}: not a vector (SVG, PDF, AI or EPS) footage layer", src.name));
continue;
};
let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?;
let doc = effectcraft_svg::parse(&bytes).map_err(|e| EngineError::Other(format!("{}: {e}", f.path)))?;
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref())
.unwrap_or_else(|| Err(format!("{}: not a vector file", f.path)))
.map_err(EngineError::Other)?;
let id = s.edit("Create Shapes from Vector Layer", None, |proj, st| {
let l = crate::vector::shapes_from_vector(proj, &comp, &src, &doc);
let id = l.id;

View File

@ -591,8 +591,7 @@ fn edit_original(s: &mut Session, _: &Value) -> Result<Value> {
}
fn purge(s: &mut Session, _: &Value) -> Result<Value> {
s.history.undo.clear();
s.history.redo.clear();
s.history.clear();
s.toast("Purged undo history");
Ok(Value::Null)
}
@ -637,9 +636,60 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("edit.purge", "Purge", [], None, "{what?: all|memoryAndDisk|memory|disk|3d|image|snapshot}", always_ok, purge_caches),
crate::query!("cache.diskStats", "Disk Cache Statistics", "{}", disk_stats),
cmd!("edit.editOriginal", "Edit Original...", ["Edit"], Some("Cmd+E"), "{}", has_selected_footage, edit_original),
crate::query!(
"edit.history.list",
"History",
"{} → {states: [{index, id, label, parent, depth, current, line, future}], current, branches}",
history_list
),
cmd!(
"edit.history.goto",
"Go to History State",
[],
None,
"{index? (from edit.history.list) | id? | steps? (negative = back along the line)}",
always_ok,
history_goto
),
]
}
// ---------------------------------------------------------------- History panel
/// `edit.history.list`: every state of the (branching) undo history.
fn history_list(s: &mut Session, _: &Value) -> Result<Value> {
let nodes = s.history_tree();
let current = nodes.iter().position(|n| n.current);
Ok(json!({"states": nodes, "current": current, "branches": s.history.branches.len()}))
}
/// `edit.history.goto`: jump to a state by `index` (from `edit.history.list`), `id`, or
/// `steps` along the working line (negative = back, like Undo).
fn history_goto(s: &mut Session, p: &Value) -> Result<Value> {
let c = "edit.history.goto";
if let Some(n) = p.get("steps").and_then(Value::as_i64) {
for _ in 0..n.unsigned_abs() {
let moved = if n < 0 { s.undo() } else { s.redo() };
if !moved {
break;
}
}
} else {
let nodes = s.history_tree();
let id = match (p.get("id").and_then(Value::as_str), p.get("index").and_then(Value::as_u64)) {
(Some(id), _) => id.to_string(),
(None, Some(i)) => nodes.get(i as usize).map(|n| n.id.clone()).ok_or_else(|| super::bad(c, format!("no state {i} (0..{})", nodes.len())))?,
_ => return Err(super::bad(c, "give `index`, `id` or `steps`")),
};
if !s.goto_history(&id) {
return Err(super::bad(c, format!("no history state `{id}`")));
}
}
let nodes = s.history_tree();
let cur = nodes.iter().find(|n| n.current).cloned();
Ok(json!({"current": cur.as_ref().map(|n| n.index), "label": cur.map(|n| n.label)}))
}
fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
if !s.state.selected_keys.is_empty() || !s.state.selected_vertices.is_empty() { super::has_comp(s) } else { has_layers(s) }
}

View File

@ -7,6 +7,68 @@ use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
/// `effect.plugins.list`: the registered plug-in effects (WebAssembly or Rust).
fn plugins_list(s: &mut Session, _: &Value) -> Result<Value> {
use effectcraft_effects::plugin;
let list: Vec<Value> = plugin::plugins()
.into_iter()
.map(|spec| {
let p = plugin::plugin(spec.id);
let m = p.as_ref().map(|p| p.manifest().clone());
json!({
"id": spec.id,
"name": spec.name,
"category": spec.category,
"version": m.as_ref().map(|m| m.version.clone()),
"author": m.as_ref().map(|m| m.author.clone()),
"description": m.as_ref().map(|m| m.description.clone()),
"source": p.map(|p| p.source()),
"params": spec.params.iter().map(|p| p.id).collect::<Vec<_>>(),
})
})
.collect();
Ok(json!({"api": plugin::PLUGIN_API_VERSION, "wasm": s.plugin_loader.is_some(), "plugins": list}))
}
/// `effect.plugins.load`: load WebAssembly effect plug-ins (a file, or every `.wasm` in a
/// folder) through the host's loader ([`Session::plugin_loader`]).
fn plugins_load(s: &mut Session, p: &Value) -> Result<Value> {
let c = "effect.plugins.load";
let loader = s.plugin_loader.ok_or_else(|| EngineError::Other("WebAssembly effect plug-ins are not available in this build".into()))?;
let files: Vec<String> = match (str_p(p, "path"), str_p(p, "folder")) {
(Some(path), _) => vec![path.to_string()],
(None, Some(dir)) => {
let mut v: Vec<String> = std::fs::read_dir(dir)
.map_err(|e| bad(c, format!("cannot list {dir}: {e}")))?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm")))
.map(|p| p.to_string_lossy().to_string())
.collect();
v.sort();
v
}
_ => return Err(bad(c, "give `path` or `folder`")),
};
let (mut loaded, mut errors) = (vec![], vec![]);
for f in &files {
let r = s.services.read_file(f).map_err(|e| format!("cannot read {f}: {e}")).and_then(|bytes| loader(&bytes, f));
match r {
Ok(v) => loaded.push(v),
Err(e) => errors.push(json!({"path": f, "error": e})),
}
}
if str_p(p, "path").is_some()
&& let Some(e) = errors.first()
{
return Err(EngineError::Other(format!("{}: {}", files[0], e["error"].as_str().unwrap_or("failed"))));
}
if !loaded.is_empty() {
s.toast(format!("Loaded {} effect plug-in(s)", loaded.len()));
}
Ok(json!({"loaded": loaded, "errors": errors}))
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "effect").ok_or_else(|| bad("effect.apply", "missing `effect` (id or name)"))?;
let spec = effectcraft_effects::lookup(name).ok_or_else(|| bad("effect.apply", format!("unknown effect `{name}`")))?;
@ -284,8 +346,8 @@ fn last(s: &mut Session, p: &Value) -> Result<Value> {
fn list(_: &mut Session, p: &Value) -> Result<Value> {
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
let v: Vec<Value> = effectcraft_effects::registry()
.iter()
let v: Vec<Value> = effectcraft_effects::all()
.into_iter()
.filter(|e| filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str())))
.map(|e| json!({"id": e.id, "name": e.name, "category": e.category, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::<Vec<_>>()}))
.collect();
@ -305,5 +367,20 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste),
cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset),
crate::query!("effect.list", "List Effects", "{filter?}", list),
crate::query!(
"effect.plugins.list",
"List Effect Plug-ins",
"{} → {api, wasm, plugins: [{id, name, category, version, author, source, params}]}",
plugins_list
),
cmd!(
"effect.plugins.load",
"Load Effect Plug-in...",
[],
None,
"{path (.wasm / .wat plug-in, API v1) | folder (loads every .wasm in it)}",
super::always,
plugins_load
),
]
}

View File

@ -111,6 +111,17 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
for path in paths {
let bytes = match s.services.read_file(&path) {
Ok(b) if effectcraft_psd::is_psd(&b) => b,
Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => {
// PDF / Illustrator / EPS: one layer per file layer.
match import_vector_comp(s, &path, &b) {
Ok((comp, items)) => {
out_comps.push(comp);
ids.extend(items);
}
Err(e) => errors.push(e.to_string()),
}
continue;
}
_ => {
rest.push(path);
continue;
@ -179,7 +190,9 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
&& f.codec == "PSD"
{
match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) {
Some((index, name)) => f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false }),
Some((index, name)) => {
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None })
}
None => {
errors.push(format!("{path}: no layer {sel}"));
continue;
@ -285,6 +298,22 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec<u8>, retai
Ok((r.comp.0, items, r.warnings))
}
/// Import a PDF / Illustrator / EPS file as a composition (one undo step). Returns (comp, items).
fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8]) -> Result<(u64, Vec<u64>)> {
let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into());
let rate = effectcraft_time::FrameRate::FPS_29_97;
let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs));
let (comp, folder, items) = s.edit("Import", None, |proj, st| {
let r = crate::vector::import_vector_comp(proj, path, bytes, &name, rate, duration).map_err(EngineError::Other)?;
st.project_selection = vec![r.0];
Ok(r)
})?;
let mut out = vec![folder.0];
out.extend(items.iter().map(|i| i.0));
Ok((comp.0, out))
}
/// Extensions imported as data footage.
pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"];
@ -449,7 +478,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File...",
["File", "Import"],
Some("Cmd+I"),
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}",
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}",
always,
import
),

View File

@ -364,6 +364,14 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
/// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's
/// `{"method": "engine.execute", "params": {"id", "params"}}`).
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
// File ▸ Scripts ▸ <installed or sample script>.
if let Some(name) = str_p(p, "name")
&& p.get("path").is_none()
&& p.get("steps").is_none()
{
let name = name.to_string();
return super::scripts::run_named(s, &name);
}
if let Some(path) = str_p(p, "path")
&& p.get("steps").is_none()
&& is_js_script(path)
@ -375,13 +383,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
let code = String::from_utf8_lossy(&bytes).to_string();
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let out = run_js(s, &code, path, false)?;
if let Some(e) = out.get("error").filter(|e| !e.is_null()) {
let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default();
let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error");
s.events.push(crate::Event::Toast { message: format!("{name}{line}: {msg}"), error: true });
return Err(EngineError::Other(format!("{name}{line}: {msg}")));
}
return Ok(out);
return report_js(s, &name, out);
}
let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
(Some(Value::Array(a)), _) => a.clone(),
@ -426,6 +428,23 @@ fn is_js_script(path: &str) -> bool {
l.ends_with(".jsx") || l.ends_with(".js") || l.ends_with(".jsxbin")
}
/// A script error becomes a toast and a command error.
fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
if let Some(e) = out.get("error").filter(|e| !e.is_null()) {
let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default();
let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error");
s.events.push(crate::Event::Toast { message: format!("{name}{line}: {msg}"), error: true });
return Err(EngineError::Other(format!("{name}{line}: {msg}")));
}
Ok(out)
}
/// Run a script's code under its file name (installed / sample scripts, ScriptUI panels).
pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result<Value> {
let out = run_js(s, code, name, false)?;
report_js(s, name, out)
}
/// Run JavaScript through the session's scripting engine.
fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Value> {
let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?;
@ -809,7 +828,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Script File...",
["File", "Scripts"],
None,
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | steps: [{command, params}]}",
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | steps: [{command, params}]}",
always,
run_script
),

View File

@ -1163,7 +1163,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Text",
[],
None,
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, OpenType: discretionaryLigatures?, contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01…ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
has_layers,
set_text
),

View File

@ -1,5 +1,6 @@
//! Mask tracking (Layer ▸ Mask ▸ Track Mask, the Tracker panel in mask mode) and Smart Mask
//! Interpolation (Window ▸ Mask Interpolation).
//! Mask tracking (Layer ▸ Mask ▸ Track Mask, the Tracker panel in mask mode, including the Face
//! Tracking methods and Extract & Copy Face Measurements) and Smart Mask Interpolation
//! (Window ▸ Mask Interpolation).
use effectcraft_keyframe::{Keyframe, ShapePath, Value as KV};
use effectcraft_path::interp::{self, AddVertices, InterpOpts, Matching};
@ -20,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Track Mask",
["Animation"],
None,
"{layer?, mask?: uid|index|name, method?: position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective, direction?: forward|backward|frameForward|frameBackward, start? (s), end? (s), wait?: block until done}",
"{layer?, mask?: uid|index|name, method?: position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective|faceOutline|faceDetailed, direction?: forward|backward|frameForward|frameBackward, start? (s), end? (s), wait?: block until done}",
has_mask,
track_mask
),
@ -29,10 +30,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Mask Tracking Method",
[],
None,
"{method: position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective}",
"{method: position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective|faceOutline|faceDetailed}",
super::always,
mask_method
),
cmd!(
"track.extractFaceMeasurements",
"Extract & Copy Face Measurements",
[],
None,
"{layer?} → keys a Face Measurements effect from the layer's Face Track Points (one key per tracked frame) and copies those keys",
has_face_points,
extract_face
),
cmd!(
"mask.interpolate",
"Apply Mask Interpolation",
@ -116,9 +126,12 @@ fn has_mask(s: &Session) -> std::result::Result<(), String> {
fn method_p(p: &Value, cmd: &str) -> Result<Option<MaskMethod>> {
match str_p(p, "method") {
Some(m) => MaskMethod::from_name(m)
.map(Some)
.ok_or_else(|| bad(cmd, format!("unknown method `{m}` (position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective)"))),
Some(m) => MaskMethod::from_name(m).map(Some).ok_or_else(|| {
bad(
cmd,
format!("unknown method `{m}` (position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective|faceOutline|faceDetailed)"),
)
}),
None => Ok(None),
}
}
@ -190,6 +203,112 @@ fn track_mask(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
// ---------------------------------------------------------------- face measurements
fn face_points_layer(s: &Session, p: &Value) -> Option<(effectcraft_project::ItemId, LayerId)> {
use effectcraft_effects::face_track::POINTS_ID;
let has = |l: &Layer| l.effects().is_some_and(|fx| fx.groups().any(|g| g.match_id == POINTS_ID));
if p.get("layer").is_some() {
return layer_p(s, p, "track.extractFaceMeasurements").ok();
}
let cid = s.active_comp_id()?;
let comp = s.project.comp(cid)?;
let sel = s.state.selected_layers.iter().filter_map(|l| comp.layer(*l)).find(|l| has(l));
sel.or_else(|| comp.layers.iter().find(|l| has(l))).map(|l| (cid, l.id))
}
fn has_face_points(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
face_points_layer(s, &json!({})).map(|_| ()).ok_or_else(|| "no Face Track Points: track a mask with Face Tracking (Detailed Features) first".into())
}
/// Extract & Copy Face Measurements: measure the Face Track Points at every keyed time, key them
/// into the layer's Face Measurements effect and put those keys on the keyframe clipboard.
fn extract_face(s: &mut Session, p: &Value) -> Result<Value> {
use effectcraft_effects::face_track::{MEASUREMENTS_ID, POINTS_ID};
use effectcraft_track::face::{LANDMARKS, MEASUREMENTS, N, face_frame, measure};
const CMD: &str = "track.extractFaceMeasurements";
let (cid, lid) = face_points_layer(s, p).ok_or_else(|| bad(CMD, "no Face Track Points: track a mask with Face Tracking (Detailed Features) first"))?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.match_id == POINTS_ID)).ok_or_else(|| bad(CMD, "the layer has no Face Track Points"))?;
let mut times: Vec<Tick> = LANDMARKS.iter().filter_map(|l| g.get(l.0)).flat_map(|pr| pr.keys.iter().map(|k| k.time)).collect();
times.sort();
times.dedup();
if times.is_empty() {
times.push(layer.layer_time(s.time_of(cid)));
}
let rows: Vec<(Tick, [f64; 14])> = {
let pts_at = |t: Tick| {
let mut l = [[0.0; 2]; N];
for (i, (id, _, _)) in LANDMARKS.iter().enumerate() {
if let Some(KV::Vec2(v)) = g.get(id).map(|pr| pr.value_at(t)) {
l[i] = v;
}
}
l
};
let reference = face_frame(&pts_at(times[0])).1;
times.iter().map(|t| (*t, measure(&pts_at(*t), reference))).collect()
};
let comp_times: Vec<Tick> = times.iter().map(|t| layer.comp_time(*t)).collect();
let fd = comp.frame_duration();
let uid = s.edit("Extract Face Measurements", None, |proj, _| {
let uid = crate::mask_track::face_effect(proj, cid, lid, MEASUREMENTS_ID).ok_or_else(|| bad(CMD, "Face Measurements is not available"))?;
let g = proj.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)).ok_or(EngineError::NoComp)?;
for (k, (id, _)) in MEASUREMENTS.iter().enumerate() {
let Some(pr) = g.get_mut(id) else { continue };
pr.keys.clear();
for (t, m) in &rows {
effectcraft_keyframe::set_key(&mut pr.keys, Keyframe::new(*t, KV::Scalar(m[k])));
}
}
Ok(uid)
})?;
// Copy the keys (Edit ▸ Paste pastes them at the CTI, like copied keyframes).
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let mg = layer.props.find_group(uid).ok_or(EngineError::NoComp)?;
let earliest = comp_times.iter().copied().min().unwrap_or(Tick::ZERO);
let mut clip = vec![];
for (id, _) in MEASUREMENTS {
let Some(pr) = mg.get(id) else { continue };
let Some(path) = layer.props.match_path_of(pr.uid) else { continue };
let keys = pr
.keys
.iter()
.map(|k| {
let mut k = k.clone();
k.time = layer.comp_time(k.time) - earliest;
k
})
.collect();
clip.push(crate::KeyClip { layer: lid, path, keys });
}
s.state.key_clipboard = clip;
s.state.clip_is_keys = true;
// The same data as text (tab-separated, one row per frame) for other applications.
let mut text = String::from("EffectCraft Face Measurements\n\nFrame\tTime");
for (_, name) in MEASUREMENTS {
text.push('\t');
text.push_str(name);
}
text.push('\n');
let mut table = vec![];
for ((_, m), ct) in rows.iter().zip(&comp_times) {
let frame = if fd > Tick::ZERO { (ct.seconds() / fd.seconds()).round() as i64 } else { 0 };
text.push_str(&format!("{frame}\t{:.4}", ct.seconds()));
for v in m {
text.push_str(&format!("\t{v:.3}"));
}
text.push('\n');
let obj: serde_json::Map<String, Value> = MEASUREMENTS.iter().zip(m).map(|((id, _), v)| (id.to_string(), json!(v))).collect();
table.push(json!({"time": ct.seconds(), "frame": frame, "values": obj}));
}
s.bump();
Ok(json!({"layer": lid.0, "effect": uid, "frames": rows.len(), "measurements": table, "clipboard": text}))
}
// ---------------------------------------------------------------- mask interpolation
/// Mask Interpolation panel options (kept in the editor state, serde for agents).

View File

@ -48,6 +48,7 @@ mod render_queue;
pub(crate) mod rig3d;
pub mod roto_cmds;
mod scene_detect;
pub mod scripts;
mod settings;
mod shape_stroke;
mod stubs;
@ -175,6 +176,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(essential::specs());
v.extend(expr_tools::specs());
v.extend(proxy::specs());
v.extend(scripts::specs());
v.extend(panels_cmds::specs());
v.extend(footage_panel::specs());
v.extend(autotrace::specs());
@ -182,6 +184,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(align_data::specs());
v.extend(content_fill::specs());
v.extend(stubs::specs());
v.extend(crate::learn::specs());
v
})
}

View File

@ -4,6 +4,7 @@
//! item's switch).
use effectcraft_project::render_queue::{Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, TimeSpan};
use effectcraft_project::render_templates::TemplateSlot;
use effectcraft_project::{Footage, ItemId, ItemKind, Proxy};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@ -159,18 +160,28 @@ fn create_proxy(s: &mut Session, p: &Value) -> Result<Value> {
k => return Err(bad(C, format!("kind: still|movie, not `{k}`"))),
};
let mut it = RenderQueueItem::new(0, cid);
// The Movie / Still Proxy Default templates, at draft quality and the proxy resolution.
let templates = &s.project.render_templates;
let slot = if still { TemplateSlot::StillProxy } else { TemplateSlot::MovieProxy };
it.settings = templates.default_render_settings(slot);
it.settings.quality = RenderQuality::Draft;
it.settings.resolution = p.get("resolution").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.05, 1.0);
if still {
// One frame at the current time (the comp's poster frame when not the active comp).
let t = if s.active_comp_id() == Some(cid) { s.time() } else { comp.poster_time };
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
it.output = OutputModule::for_format(OutputFormat::PngSequence);
it.output.output = "[compName]_proxy_[#####].png".into();
it.output = templates.default_output_module(slot);
if !it.output.format.is_sequence() {
it.output = OutputModule::for_format(OutputFormat::PngSequence);
}
it.output.output = "[compName]_proxy_[#####].[fileExtension]".into();
} else {
it.settings.time_span = TimeSpan::LengthOfComp;
it.output = OutputModule::for_format(OutputFormat::ProRes);
it.output.prores_profile = ProResProfile::P4444;
it.output = templates.default_output_module(slot);
if !it.output.format.is_movie() {
it.output = OutputModule::for_format(OutputFormat::ProRes);
it.output.prores_profile = ProResProfile::P4444;
}
it.output.output = "[compName]_proxy.[fileExtension]".into();
}
it.output.channels = Channels::Rgba;

View File

@ -7,7 +7,7 @@
use effectcraft_effects::puppet::{self, MeshOpts, PinKind};
use effectcraft_effects::{Buf, Params};
use effectcraft_keyframe::Value as KV;
use effectcraft_keyframe::{Keyframe, Value as KV};
use effectcraft_project::build::Ids;
use effectcraft_project::{ItemId, LayerId, Node, PropGroup, Uid};
use effectcraft_render::{EvalCtx, RenderOpts, Renderer};
@ -309,6 +309,122 @@ fn mesh_opts(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// Parse recorded samples `[[t, x, y]…]` (t = seconds since the drag began, layer space).
fn samples(p: &Value, cmd: &str) -> Result<Vec<(f64, [f64; 2])>> {
let arr = p
.get("samples")
.and_then(Value::as_array)
.ok_or_else(|| bad(cmd, "missing `samples`: [[t, x, y]…] (t = seconds since the drag began, layer space)"))?;
let mut rec = Vec::with_capacity(arr.len());
for (i, v) in arr.iter().enumerate() {
let a: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
if a.len() < 3 || a.iter().any(|x| !x.is_finite()) {
return Err(bad(cmd, format!("sample {i}: expected [t, x, y]")));
}
rec.push((a[0], [a[1], a[2]]));
}
if rec.is_empty() {
return Err(bad(cmd, "need at least one sample"));
}
rec.sort_by(|a, b| a.0.total_cmp(&b.0));
Ok(rec)
}
/// Puppet tool Record mode (⌘/Ctrl-drag a pin): the pin's drag, sampled in real time, becomes
/// Position keyframes at the comp frame rate from the current time on, replacing the keys in
/// the recorded span. Record Options: `speed` (%: 100 plays back at the speed of the drag,
/// 200 twice as slow) and `smoothing` (removes extraneous keys like the Smoother; 0 = a key
/// on every frame). The current time stays where recording began.
fn record_pin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "puppet.recordPin";
let (cid, lid) = layer_p(s, p, cmd)?;
let key = p.get("pin").cloned().ok_or_else(|| bad(cmd, "missing `pin` (uid or name)"))?;
let uid = pin_uid(s, cid, lid, &key, cmd)?;
let rec = samples(p, cmd)?;
let opts = &s.state.puppet;
let speed = f_p(p, "speed").unwrap_or(opts.record_speed).clamp(1.0, 10_000.0) / 100.0;
// Smoothing 10 (the default) keeps the path within 1 layer pixel of the drag.
let smoothing = f_p(p, "smoothing").unwrap_or(opts.record_smoothing).max(0.0) / 10.0;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let fr = comp.frame_rate;
let fd = fr.frame_duration().seconds();
let start = match p.get("start").and_then(Value::as_f64) {
Some(t) => fr.snap_nearest(Tick::from_seconds_f64(t)),
None => s.time_of(cid),
};
let f0 = fr.frame_at(start);
let end_frame = fr.frame_at(comp.duration - comp.frame_duration());
let t_rec0 = rec[0].0;
let span = (rec[rec.len() - 1].0 - t_rec0) * speed;
let nframes = (span / fd).round().max(0.0) as i64;
let at = |secs: f64| {
let i = rec.partition_point(|r| r.0 <= secs).clamp(1, rec.len().max(2) - 1);
if rec.len() == 1 {
return rec[0].1;
}
let (a, b) = (rec[i - 1], rec[i]);
let f = if b.0 > a.0 { ((secs - a.0) / (b.0 - a.0)).clamp(0.0, 1.0) } else { 1.0 };
[a.1[0] + (b.1[0] - a.1[0]) * f, a.1[1] + (b.1[1] - a.1[1]) * f]
};
let n = s.edit("Record Puppet Pin", None, |proj, st| {
let l = layer_mut(proj, cid, lid)?;
let lc = l.clone();
let g = l.props.find_group_mut(uid).ok_or_else(|| bad(cmd, "pin gone"))?;
let kind = PinKind::from_index(g.get("kind").map(|k| k.value.as_enum()).unwrap_or(0));
if !kind.moves() {
return Err(bad(cmd, "only Position and Advanced pins record motion"));
}
let pos = g.get_mut("position").ok_or_else(|| bad(cmd, "the pin has no Position"))?;
let mut new: Vec<Keyframe> = vec![];
for k in 0..=nframes {
let f = f0 + k;
if f > end_frame {
break;
}
let q = at(t_rec0 + k as f64 * fd / speed);
new.push(Keyframe::new(lc.layer_time(fr.tick_of(f)), KV::Vec2(q)));
}
let (Some(t0), Some(t1)) = (new.first().map(|k| k.time), new.last().map(|k| k.time)) else { return Ok(0) };
let mut keys: Vec<Keyframe> = pos.keys.iter().filter(|k| k.time < t0 || k.time > t1).cloned().collect();
keys.extend(new);
keys.sort_by_key(|k| k.time);
let (a, b) = (keys.iter().position(|k| k.time == t0).unwrap_or(0), keys.iter().position(|k| k.time == t1).unwrap_or(0));
if smoothing > 0.0 && b > a + 1 {
let samples: Vec<(Tick, Vec<f64>)> = keys[a..=b].iter().map(|k| (k.time, k.value.components())).collect();
keys = super::anim_tools::smooth_keys(&keys, a, b, &samples, smoothing, true);
}
pos.keys = keys;
pos.value = KV::Vec2(rec[0].1);
let puid = pos.uid;
st.selected_props = vec![(lid, uid)];
st.selected_keys = pos.keys.iter().filter(|k| k.time >= t0 && k.time <= t1).map(|k| crate::KeyRef { layer: lid, prop: puid, time: k.time }).collect();
Ok(st.selected_keys.len())
})?;
// The current time returns to where recording began.
s.state.times.insert(cid, start);
let end = fr.tick_of((f0 + nframes).min(end_frame));
Ok(json!({"keys": n, "start": start.seconds(), "end": end.seconds(), "frames": nframes + 1}))
}
/// Record Options (the Puppet tool's Record Options… dialog): `speed`, `smoothing`,
/// `useDraftDeformation`, `showMesh`. Returns the options.
fn record_options(s: &mut Session, p: &Value) -> Result<Value> {
let o = &mut s.state.puppet;
if let Some(v) = f_p(p, "speed") {
o.record_speed = v.clamp(1.0, 10_000.0);
}
if let Some(v) = f_p(p, "smoothing") {
o.record_smoothing = v.clamp(0.0, 100.0);
}
if let Some(v) = p.get("useDraftDeformation").and_then(Value::as_bool) {
o.record_draft = v;
}
if let Some(v) = p.get("showMesh").and_then(Value::as_bool) {
o.record_show_mesh = v;
}
Ok(json!({"speed": o.record_speed, "smoothing": o.record_smoothing, "useDraftDeformation": o.record_draft, "showMesh": o.record_show_mesh}))
}
/// Meshes, pins and the deformed mesh at the current time (layer space).
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "puppet.info")?;
@ -364,6 +480,24 @@ pub fn specs() -> Vec<CommandSpec> {
mesh_opts
),
crate::query!("puppet.info", "Puppet Mesh Info", "{layer?, time? | frame?}", info),
cmd!(
"puppet.recordPin",
"Record Puppet Pin",
[],
None,
"{layer?, pin: uid | name, samples: [[t, x, y]…] (t = seconds since the drag began, layer space), start? (s, default current time), speed? (%, Record Options), smoothing? (Record Options)}",
has_comp,
record_pin
),
cmd!(
"puppet.recordOptions",
"Record Options...",
[],
None,
"{speed? (%, 100), smoothing? (0-100), useDraftDeformation?, showMesh?}",
super::always,
record_options
),
]
}
@ -376,11 +510,30 @@ pub struct PuppetOptions {
pub triangles: f64,
/// Show the mesh in the viewer.
pub show_mesh: bool,
/// Record Options ▸ Speed (%): 100 plays a recording back at the speed of the drag.
pub record_speed: f64,
/// Record Options ▸ Smoothing: removes extraneous keys from recorded motion (0 = a key per
/// frame).
pub record_smoothing: f64,
/// Record Options ▸ Use Draft Deformation: the viewer shows the draft (unexpanded) mesh
/// while recording.
pub record_draft: bool,
/// Record Options ▸ Show Mesh while recording.
pub record_show_mesh: bool,
}
impl Default for PuppetOptions {
fn default() -> Self {
let d = MeshOpts::default();
PuppetOptions { density: d.density, expansion: d.expansion, triangles: d.triangles, show_mesh: false }
PuppetOptions {
density: d.density,
expansion: d.expansion,
triangles: d.triangles,
show_mesh: false,
record_speed: 100.0,
record_smoothing: 10.0,
record_draft: false,
record_show_mesh: false,
}
}
}

View File

@ -1,16 +1,20 @@
//! Render Queue: Composition ▸ Add to Render Queue, the queue's Render Settings / Output Module /
//! Output To, Render and Stop.
//! Output To / Log, Render and Stop, Notify, storage overflow folders, and the Render Settings /
//! Output Module templates (Edit ▸ Templates).
//!
//! Items are addressed by `item` (the stable id from `renderQueue.add` / `renderQueue.list`) or
//! `index` (1-based, the # column).
use effectcraft_project::render_queue::{
AudioOutput, Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, RenderSettings, RenderStatus, TimeSpan,
AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, DEFAULT_SEQUENCE_TEMPLATE, DEFAULT_TEMPLATE, EffectsMode, FieldRender,
OutputFormat, OutputModule, PostRenderAction, ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderSettings,
RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, post_render_parse,
};
use effectcraft_project::render_templates::{RenderTemplates, TemplateKind, TemplateSlot};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, str_p};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p};
use crate::{EngineError, Result, Session, cmd, query};
fn can_add(s: &Session) -> std::result::Result<(), String> {
@ -54,16 +58,32 @@ fn time_p(p: &Value, k: &str) -> Option<Tick> {
f_p(p, k).map(Tick::from_seconds_f64)
}
/// Apply Render Settings parameters.
fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool> {
/// Parse an enum parameter.
fn enum_p<T>(p: &Value, k: &str, cmd: &str, parse: fn(&str) -> Option<T>, help: &str) -> Result<Option<T>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(v)) => parse(v).map(Some).ok_or_else(|| bad(cmd, format!("{k}: {help}"))),
Some(Value::Bool(b)) => parse(if *b { "on" } else { "off" }).map(Some).ok_or_else(|| bad(cmd, format!("{k}: {help}"))),
Some(Value::Number(n)) => parse(&n.to_string()).map(Some).ok_or_else(|| bad(cmd, format!("{k}: {help}"))),
Some(_) => Err(bad(cmd, format!("{k}: {help}"))),
}
}
/// Apply Render Settings parameters (`template` first, then the individual settings). Any
/// individual change makes the settings "Custom".
fn apply_settings(rs: &mut RenderSettings, templates: &RenderTemplates, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(t) = str_p(p, "template") {
*rs = templates.render_settings(t).ok_or_else(|| bad(cmd, format!("no Render Settings template “{t}”")))?;
any = true;
}
let before = rs.clone();
if let Some(q) = str_p(p, "quality") {
rs.quality = match q.to_ascii_lowercase().as_str() {
"best" => RenderQuality::Best,
"draft" => RenderQuality::Draft,
_ => return Err(bad(cmd, "quality: best|draft")),
};
any = true;
}
match p.get("resolution") {
Some(Value::String(r)) => {
@ -74,15 +94,21 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
"quarter" => 0.25,
_ => return Err(bad(cmd, "resolution: full|half|third|quarter|<scale>")),
};
any = true;
}
Some(Value::Number(n)) => {
rs.resolution = n.as_f64().unwrap_or(1.0).clamp(0.01, 4.0);
any = true;
}
_ => {}
}
let (start, end) = (time_p(p, "start"), time_p(p, "end"));
let (start, mut end) = (time_p(p, "start"), time_p(p, "end"));
if let Some(d) = time_p(p, "duration") {
// Time Span ▸ Custom ▸ Duration: from the given (or current custom) start.
let a = start.unwrap_or(match rs.time_span {
TimeSpan::Custom { start, .. } => start,
_ => Tick::ZERO,
});
end = Some(a + d);
}
if let Some(ts) = str_p(p, "timeSpan") {
rs.time_span = match ts.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"workarea" | "workareaonly" => TimeSpan::WorkArea,
@ -90,14 +116,12 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
"custom" => TimeSpan::Custom { start: start.unwrap_or(Tick::ZERO), end: end.unwrap_or(Tick::from_seconds_f64(1.0)) },
_ => return Err(bad(cmd, "timeSpan: workArea|comp|custom")),
};
any = true;
} else if start.is_some() || end.is_some() {
let (a, b) = match rs.time_span {
TimeSpan::Custom { start, end } => (start, end),
_ => (Tick::ZERO, Tick(i64::MAX / 4)),
};
rs.time_span = TimeSpan::Custom { start: start.unwrap_or(a), end: end.unwrap_or(b) };
any = true;
}
if let TimeSpan::Custom { start, end } = rs.time_span
&& end <= start
@ -106,62 +130,194 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
}
if let Some(u) = str_p(p, "proxyUse") {
rs.proxy_use = effectcraft_project::render_queue::ProxyUse::parse(u).ok_or_else(|| bad(cmd, "proxyUse: current|all|comp|none"))?;
any = true;
}
match p.get("frameRate") {
Some(Value::Null) => {
rs.frame_rate = None;
any = true;
}
Some(v) => {
let f = v.as_f64().filter(|f| *f > 0.0 && *f <= 1000.0).ok_or_else(|| bad(cmd, "frameRate: fps > 0 or null (comp rate)"))?;
rs.frame_rate = Some(FrameRate::from_f64(f));
any = true;
}
None => {}
}
if let Some(b) = b_p(p, "motionBlur") {
rs.motion_blur = b;
any = true;
match p.get("motionBlur") {
Some(Value::Bool(b)) => {
rs.motion_blur = *b;
if *b && rs.motion_blur_mode == SwitchOverride::OffForAll {
rs.motion_blur_mode = SwitchOverride::Current;
}
}
Some(_) => {
let m = enum_p(p, "motionBlur", cmd, SwitchOverride::parse, "current|onForChecked|offForAll")?.unwrap_or_default();
rs.motion_blur = m != SwitchOverride::OffForAll;
rs.motion_blur_mode = m;
}
None => {}
}
if let Some(m) = enum_p(p, "frameBlending", cmd, SwitchOverride::parse, "current|onForChecked|offForAll")? {
rs.frame_blending = m;
}
if let Some(f) = enum_p(p, "fieldRender", cmd, FieldRender::parse, "off|upper|lower")? {
rs.field_render = f;
}
if let Some(f) = enum_p(p, "pulldown", cmd, Pulldown::parse, "off|WSSWW|SSWWW|SWWWS|WWWSS|WWSSW")? {
rs.pulldown = f;
}
if let Some(e) = enum_p(p, "effects", cmd, EffectsMode::parse, "current|allOn|allOff")? {
rs.effects = e;
}
if let Some(e) = enum_p(p, "solo", cmd, CurrentOrOff::parse, "current|allOff")? {
rs.solo = e;
}
if let Some(e) = enum_p(p, "guideLayers", cmd, CurrentOrOff::parse, "current|allOff")? {
rs.guide_layers = e;
}
if let Some(e) = enum_p(p, "colorDepth", cmd, ColorDepth::parse, "current|8|16|32")? {
rs.color_depth = e;
}
if let Some(b) = b_p(p, "skipExisting") {
rs.skip_existing = b;
}
if let Some(b) = b_p(p, "storageOverflow") {
rs.storage_overflow = b;
}
if *rs != before {
rs.name = "Custom".into();
any = true;
}
Ok(any)
}
/// Apply Output Module parameters.
fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
/// Apply a template to a module, keeping its output path (with the extension fixed).
fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
let keep = om.output.clone();
let old = om.format;
*om = t.clone();
om.output = keep;
let f = om.format;
om.format = old;
om.set_format(f);
om.channels = t.channels;
om.name = t.name.clone();
}
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
/// (captured by `crop.useRoi`).
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(t) = str_p(p, "template") {
let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?;
apply_om_template(om, &t);
any = true;
}
let before = om.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
om.set_format(f);
any = true;
}
if let Some(c) = str_p(p, "channels") {
om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() {
"rgb" => Channels::Rgb,
"rgba" | "rgbalpha" => Channels::Rgba,
_ => return Err(bad(cmd, "channels: rgb|rgba")),
"alpha" | "alphaonly" => Channels::Alpha,
_ => return Err(bad(cmd, "channels: rgb|rgba|alpha")),
};
if om.channels == Channels::Rgba && !om.supports_alpha() {
return Err(bad(cmd, format!("{} has no alpha channel", om.format.label())));
}
any = true;
}
if let Some(c) = str_p(p, "color").or(str_p(p, "alphaMode")) {
om.alpha_mode = match c.to_ascii_lowercase().replace([' ', '(', ')'], "").as_str() {
"straight" | "straightunmatted" => AlphaMode::Straight,
"premultiplied" | "premultipliedmatted" | "premul" => AlphaMode::Premultiplied,
_ => return Err(bad(cmd, "color: straight|premultiplied")),
};
}
any |= apply_codec_options(om, p, cmd)?;
if let Some(q) = f_p(p, "quality") {
om.quality = q.clamp(1.0, 100.0) as u8;
any = true;
}
if let Some(b) = f_p(p, "bitrate") {
om.bitrate_kbps = b.clamp(100.0, 500_000.0) as u32;
any = true;
om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32;
}
if let Some(b) = b_p(p, "webmBitrate") {
om.webm_bitrate = b;
}
if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) {
om.keyframe_interval = k.min(100_000) as u32;
}
if let Some(pp) = str_p(p, "proresProfile") {
om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?;
any = true;
}
match p.get("crop") {
Some(Value::Bool(b)) => om.crop.enabled = *b,
Some(Value::Object(c)) => {
om.crop.enabled = c.get("enabled").and_then(Value::as_bool).unwrap_or(true);
for (k, v) in [("top", &mut om.crop.top), ("left", &mut om.crop.left), ("bottom", &mut om.crop.bottom), ("right", &mut om.crop.right)] {
if let Some(n) = c.get(k).and_then(Value::as_i64) {
*v = n.clamp(-20_000, 20_000) as i32;
}
}
if let Some(u) = c.get("useRoi").and_then(Value::as_bool) {
om.crop.use_roi = u;
if u {
om.crop.roi = c.get("roi").and_then(|r| serde_json::from_value::<[f64; 4]>(r.clone()).ok()).or(roi);
if om.crop.roi.is_none() {
return Err(bad(
cmd,
"Use Region of Interest: the composition has no region of interest (View ▸ Region of Interest) — pass crop.roi [x, y, w, h]",
));
}
}
}
}
Some(Value::Null) | None => {}
Some(_) => return Err(bad(cmd, "crop: bool or {enabled?, useRoi?, roi?: [x,y,w,h], top?, left?, bottom?, right?}")),
}
// Flat keys (dialog forms): cropTop/cropLeft/cropBottom/cropRight, resizeWidth/resizeHeight.
for (k, v) in [("cropTop", &mut om.crop.top), ("cropLeft", &mut om.crop.left), ("cropBottom", &mut om.crop.bottom), ("cropRight", &mut om.crop.right)] {
if let Some(n) = p.get(k).and_then(Value::as_f64) {
*v = (n.round() as i64).clamp(-20_000, 20_000) as i32;
om.crop.enabled = true;
om.crop.use_roi = false;
}
}
for (k, v) in [("resizeWidth", &mut om.resize.width), ("resizeHeight", &mut om.resize.height)] {
if let Some(n) = p.get(k).and_then(Value::as_f64) {
*v = (n.round() as u32).clamp(1, 30_000);
om.resize.enabled = true;
}
}
match p.get("resize") {
Some(Value::Bool(b)) => om.resize.enabled = *b,
Some(Value::Object(r)) => {
om.resize.enabled = r.get("enabled").and_then(Value::as_bool).unwrap_or(true);
if let Some(name) = r.get("preset").and_then(Value::as_str) {
let (_, w, h) =
RESIZE_PRESETS.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name)).ok_or_else(|| bad(cmd, format!("no resize preset “{name}”")))?;
(om.resize.width, om.resize.height) = (*w, *h);
om.resize.lock_aspect = false;
}
if let Some(l) = r.get("lockAspect").and_then(Value::as_bool) {
om.resize.lock_aspect = l;
}
if let Some(w) = r.get("width").and_then(Value::as_u64) {
om.resize.width = (w as u32).clamp(1, 30_000);
}
if let Some(h) = r.get("height").and_then(Value::as_u64) {
om.resize.height = (h as u32).clamp(1, 30_000);
}
if let Some(q) = r.get("quality").and_then(Value::as_str) {
om.resize.quality = match q.to_ascii_lowercase().as_str() {
"low" | "bilinear" => ResizeQuality::Low,
"high" | "bicubic" => ResizeQuality::High,
_ => return Err(bad(cmd, "resize.quality: low|high")),
};
}
}
Some(Value::Null) | None => {}
Some(_) => return Err(bad(cmd, "resize: bool or {enabled?, preset?, width?, height?, lockAspect?, quality?: low|high}")),
}
if let Some(a) = p.get("audio") {
om.audio = match a {
@ -175,15 +331,29 @@ fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
},
_ => return Err(bad(cmd, "audio: auto|on|off")),
};
any = true;
}
if let Some(r) = p.get("sampleRate").and_then(Value::as_u64) {
om.audio_sample_rate = (r as u32).clamp(8_000, 192_000);
any = true;
}
match p.get("audioChannels") {
Some(Value::Number(n)) => om.audio_channels = if n.as_u64() == Some(1) { 1 } else { 2 },
Some(Value::String(c)) => {
om.audio_channels = match c.to_ascii_lowercase().as_str() {
"mono" | "1" => 1,
"stereo" | "2" => 2,
_ => return Err(bad(cmd, "audioChannels: mono|stereo")),
}
}
_ => {}
}
if let Some(f) = enum_p(p, "audioFormat", cmd, AudioFormat::parse, "16|24|32 (float)")? {
om.audio_format = f;
}
if let Some(l) = b_p(p, "loop") {
om.gif_loop = l;
any = true;
}
if let Some(l) = b_p(p, "includeProjectLink") {
om.include_project_link = l;
}
if let Some(o) = str_p(p, "output").or(str_p(p, "path")) {
om.output = o.to_string();
@ -195,6 +365,14 @@ fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
om.set_format(f);
om.output = keep;
}
}
if *om != before {
// Output To alone doesn't make the module custom.
let mut o = om.clone();
o.output = before.output.clone();
if o != before {
om.name = "Custom".into();
}
any = true;
}
Ok(any)
@ -241,10 +419,6 @@ fn apply_codec_options(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bo
if let Some(q) = f_p(p, "quality") {
om.codec.quality = q.clamp(1.0, 100.0) as u8;
}
if let Some(k) = f_p(p, "keyframeInterval") {
om.codec.keyframe_interval = k.clamp(0.0, 100_000.0) as u32;
any = true;
}
if let Some(b) = f_p(p, "audioBitrate") {
om.opus_bitrate_kbps = b.clamp(6.0, 510.0) as u32;
any = true;
@ -288,7 +462,7 @@ fn requeue(it: &mut RenderQueueItem) {
fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
let comp = s.project.comp(it.comp);
let name = s.project.item(it.comp).map(|i| i.name.clone());
let (w, h) = comp.map(|c| it.settings.output_size(c)).map(|(w, h)| it.output.format.coded_size(w, h)).unwrap_or((0, 0));
let (w, h) = comp.map(|c| it.output.output_size(c, &it.settings)).unwrap_or((0, 0));
let frames = comp.map(|c| it.settings.frame_count(c)).unwrap_or(0);
let mut v = serde_json::to_value(it).unwrap_or(Value::Null);
if let Some(o) = v.as_object_mut() {
@ -310,6 +484,10 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
o.insert("outputModules".into(), json!(it.extra_outputs.len() + 1));
o.insert("extraOutputs".into(), json!(extra));
o.insert("postRenderAction".into(), json!(it.post_render.label()));
o.insert("logLabel".into(), json!(it.log.label()));
if let Some(p) = s.resolve_output(it) {
o.insert("logPath".into(), json!(effectcraft_project::render_queue::log_path(&p)));
}
o.insert("width".into(), json!(w));
o.insert("height".into(), json!(h));
o.insert("frames".into(), json!(frames));
@ -320,16 +498,25 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
let mut it = RenderQueueItem::new(0, cid);
// New items start from the Movie Default templates (Edit ▸ Templates).
let templates = s.project.render_templates.clone();
it.settings = templates.default_render_settings(TemplateSlot::Movie);
it.output = templates.default_output_module(TemplateSlot::Movie);
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
it.output = OutputModule::for_format(f);
if f != it.output.format {
it.output = OutputModule::for_format(f);
}
}
apply_settings(&mut it.settings, p, "renderQueue.add")?;
apply_settings(&mut it.settings, &templates, p, "renderQueue.add")?;
let mut op = p.clone();
if let Some(o) = op.as_object_mut() {
o.remove("format");
}
apply_output(&mut it.output, &op, "renderQueue.add")?;
apply_output(&mut it.output, &templates, s.state.region_of_interest, &op, "renderQueue.add")?;
if let Some(l) = enum_p(p, "log", "renderQueue.add", RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")? {
it.log = l;
}
let id = s.edit("Add to Render Queue", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
let id = it.id;
@ -366,7 +553,7 @@ fn set_render(s: &mut Session, p: &Value) -> Result<Value> {
fn set_render_settings(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.setRenderSettings")?;
let mut rs = s.project.render_queue[i].settings.clone();
if !apply_settings(&mut rs, p, "renderQueue.setRenderSettings")? {
if !apply_settings(&mut rs, &s.project.render_templates, p, "renderQueue.setRenderSettings")? {
return Err(bad("renderQueue.setRenderSettings", "nothing to change"));
}
s.edit("Render Settings", None, |proj, _| {
@ -382,12 +569,20 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.setOutputModule")?;
let m = module_p(s, i, p, "renderQueue.setOutputModule")?;
let mut om = module_ref(&s.project.render_queue[i], m).clone();
if !apply_output(&mut om, p, "renderQueue.setOutputModule")? {
let changed = apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, p, "renderQueue.setOutputModule")?;
let post = match str_p(p, "postRenderAction") {
Some(a) => Some(post_render_parse(a).ok_or_else(|| bad("renderQueue.setOutputModule", "postRenderAction: none|import|importAndReplace|setProxy"))?),
None => None,
};
if !changed && post.is_none() {
return Err(bad("renderQueue.setOutputModule", "nothing to change"));
}
s.edit("Output Module Settings", None, |proj, _| {
let it = &mut proj.render_queue[i];
*module_mut(it, m) = om;
if let Some(a) = post {
it.post_render = a;
}
requeue(it);
Ok(())
})?;
@ -399,7 +594,7 @@ fn set_output(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").or(str_p(p, "output")).ok_or_else(|| bad("renderQueue.setOutput", "pass `path` (file path or template)"))?;
let m = module_p(s, i, p, "renderQueue.setOutput")?;
let mut om = module_ref(&s.project.render_queue[i], m).clone();
apply_output(&mut om, &json!({"output": path}), "renderQueue.setOutput")?;
apply_output(&mut om, &s.project.render_templates, None, &json!({"output": path}), "renderQueue.setOutput")?;
s.edit("Output To", None, |proj, _| {
let it = &mut proj.render_queue[i];
*module_mut(it, m) = om;
@ -523,7 +718,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = op.as_object_mut() {
o.remove("format");
}
apply_output(&mut om, &op, "render.addOutputModule")?;
apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, &op, "render.addOutputModule")?;
s.edit("Add Output Module", None, |proj, _| {
let it = &mut proj.render_queue[i];
it.extra_outputs.push(om);
@ -540,11 +735,11 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
fn pre_render(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
let mut it = RenderQueueItem::new(0, cid);
it.output = OutputModule::for_format(OutputFormat::ProRes);
it.output.prores_profile = ProResProfile::P4444;
it.output.channels = Channels::Rgba;
let templates = s.project.render_templates.clone();
it.settings = templates.default_render_settings(TemplateSlot::PreRender);
it.output = templates.default_output_module(TemplateSlot::PreRender);
it.output.output = "[compName]_prerender.[fileExtension]".into();
apply_output(&mut it.output, p, "render.preRender")?;
apply_output(&mut it.output, &templates, s.state.region_of_interest, p, "render.preRender")?;
it.post_render = PostRenderAction::ImportAndReplace;
let id = s.edit("Pre-render", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
@ -577,6 +772,8 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
accel: s.accel.as_deref(),
item: &item,
path: &path,
storage: s.storage_quota.as_deref(),
label: "Save Current Preview".into(),
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?;
@ -584,6 +781,187 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
Ok(serde_json::to_value(&r).unwrap_or_default())
}
// ---------------------------------------------------------------- templates (Edit ▸ Templates)
fn kind_p(p: &Value, cmd: &str) -> Result<TemplateKind> {
str_p(p, "kind").and_then(TemplateKind::parse).ok_or_else(|| bad(cmd, "kind: renderSettings|outputModule"))
}
fn name_p<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "pass `name`"))
}
/// Templates as JSON (lists, built-in flags, defaults per slot).
fn templates_json(t: &RenderTemplates) -> Value {
let rs: Vec<Value> = t
.render_settings_list()
.iter()
.map(|r| json!({"name": r.name, "builtin": RenderTemplates::is_builtin(TemplateKind::RenderSettings, &r.name), "settings": r}))
.collect();
let om: Vec<Value> = t
.output_module_list()
.iter()
.map(|m| json!({"name": m.name, "builtin": RenderTemplates::is_builtin(TemplateKind::OutputModule, &m.name), "summary": format!("{} · {}", m.format.label(), m.channels.label()), "module": m}))
.collect();
let slots = |k: TemplateKind| -> Value {
TemplateSlot::ALL.iter().map(|sl| (format!("{sl:?}"), json!(t.default_name(k, *sl)))).collect::<serde_json::Map<String, Value>>().into()
};
json!({
"renderSettings": rs,
"outputModules": om,
"defaults": {"renderSettings": slots(TemplateKind::RenderSettings), "outputModules": slots(TemplateKind::OutputModule)},
"slots": TemplateSlot::ALL.iter().map(|sl| json!({"id": format!("{sl:?}"), "label": sl.label()})).collect::<Vec<_>>(),
"resizePresets": RESIZE_PRESETS.iter().map(|(n, w, h)| json!({"name": n, "width": w, "height": h})).collect::<Vec<_>>(),
})
}
fn templates(s: &mut Session, _: &Value) -> Result<Value> {
Ok(templates_json(&s.project.render_templates))
}
/// Save a template: from an item's settings (`item`/`index`, `module`), or the slot default,
/// adjusted by `params` (the same keys as setRenderSettings / setOutputModule).
fn save_template(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.saveTemplate";
let kind = kind_p(p, C)?;
let name = name_p(p, C)?.to_string();
if name.eq_ignore_ascii_case("custom") {
return Err(bad(C, "“Custom” is reserved"));
}
let item = if p.get("item").is_some() || p.get("index").is_some() { Some(item_index(s, p, C)?) } else { None };
let params = p.get("params").cloned().unwrap_or(Value::Null);
let from = str_p(p, "from");
let mut t = s.project.render_templates.clone();
match kind {
TemplateKind::RenderSettings => {
let mut rs = match (item, from) {
(Some(i), _) => s.project.render_queue[i].settings.clone(),
(None, Some(f)) => t.render_settings(f).ok_or_else(|| bad(C, format!("no Render Settings template “{f}”")))?,
(None, None) => t.default_render_settings(TemplateSlot::Movie),
};
if params.is_object() {
apply_settings(&mut rs, &t, &params, C)?;
}
t.save_render_settings(&name, rs);
}
TemplateKind::OutputModule => {
let mut om = match (item, from) {
(Some(i), _) => module_ref(&s.project.render_queue[i], module_p(s, i, p, C)?).clone(),
(None, Some(f)) => t.output_module(f).ok_or_else(|| bad(C, format!("no Output Module template “{f}”")))?,
(None, None) => t.default_output_module(TemplateSlot::Movie),
};
if params.is_object() {
apply_output(&mut om, &t, s.state.region_of_interest, &params, C)?;
}
// Templates name files after the composition, not a particular path.
om.output = if om.format.is_sequence() { DEFAULT_SEQUENCE_TEMPLATE } else { DEFAULT_TEMPLATE }.into();
t.save_output_module(&name, om);
}
}
s.edit("Save Template", None, |proj, _| {
proj.render_templates = t;
Ok(())
})?;
Ok(json!({"saved": name, "templates": templates_json(&s.project.render_templates)}))
}
fn delete_template(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.deleteTemplate";
let kind = kind_p(p, C)?;
let name = name_p(p, C)?.to_string();
let mut t = s.project.render_templates.clone();
t.delete(kind, &name).map_err(|e| bad(C, e))?;
s.edit("Delete Template", None, |proj, _| {
proj.render_templates = t;
Ok(())
})?;
Ok(json!({"deleted": name}))
}
fn set_template_default(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.setTemplateDefault";
let kind = kind_p(p, C)?;
let slot = str_p(p, "slot").and_then(TemplateSlot::parse).ok_or_else(|| bad(C, "slot: movie|still|preRender|movieProxy|stillProxy"))?;
let name = name_p(p, C)?.to_string();
let mut t = s.project.render_templates.clone();
t.set_default(kind, slot, &name).map_err(|e| bad(C, e))?;
s.edit("Template Default", None, |proj, _| {
proj.render_templates = t;
Ok(())
})?;
Ok(json!({"slot": format!("{slot:?}"), "name": name}))
}
/// Apply templates to an item: `renderSettings` and/or `outputModule` (to `module`).
fn apply_template(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.applyTemplate";
let i = item_index(s, p, C)?;
let m = module_p(s, i, p, C)?;
let t = s.project.render_templates.clone();
let rs = match str_p(p, "renderSettings") {
Some(n) => Some(t.render_settings(n).ok_or_else(|| bad(C, format!("no Render Settings template “{n}”")))?),
None => None,
};
let om = match str_p(p, "outputModule") {
Some(n) => {
let tpl = t.output_module(n).ok_or_else(|| bad(C, format!("no Output Module template “{n}”")))?;
let mut om = module_ref(&s.project.render_queue[i], m).clone();
apply_om_template(&mut om, &tpl);
Some(om)
}
None => None,
};
if rs.is_none() && om.is_none() {
return Err(bad(C, "pass `renderSettings` and/or `outputModule` (template names)"));
}
s.edit("Apply Template", None, |proj, _| {
let it = &mut proj.render_queue[i];
if let Some(rs) = rs {
it.settings = rs;
}
if let Some(om) = om {
*module_mut(it, m) = om;
}
requeue(it);
Ok(())
})?;
Ok(item_json(s, &s.project.render_queue[i], i))
}
fn set_log(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.setLog";
let i = item_index(s, p, C)?;
let log = enum_p(p, "log", C, RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")?.ok_or_else(|| bad(C, "pass `log`"))?;
s.edit("Render Queue: Log", None, |proj, _| {
proj.render_queue[i].log = log;
Ok(())
})?;
Ok(item_json(s, &s.project.render_queue[i], i))
}
fn set_notify(s: &mut Session, p: &Value) -> Result<Value> {
let on = b_p(p, "notify").or(b_p(p, "value")).unwrap_or(!s.project.render_prefs.notify);
s.edit("Render Queue: Notify", None, |proj, _| {
proj.render_prefs.notify = on;
Ok(())
})?;
Ok(json!({"notify": on}))
}
fn set_overflow(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.setOverflowFolders";
let folders: Vec<String> = match p.get("folders") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::trim).filter(|f| !f.is_empty()).map(String::from).collect(),
Some(Value::Null) | None => vec![],
_ => return Err(bad(C, "folders: [path, …]")),
};
s.edit("Storage Overflow Folders", None, |proj, _| {
proj.render_prefs.overflow_folders = folders.clone();
Ok(())
})?;
Ok(json!({"folders": s.project.render_prefs.overflow_folders}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@ -591,7 +969,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Render Queue",
["Composition"],
Some("Cmd+M"),
"{comp?: id|name, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
can_add,
add
),
@ -602,7 +980,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Render Settings...",
[],
None,
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none}",
"{item?|index?, template?: name, quality?: best|draft, resolution?: full|half|third|quarter|scale, proxyUse?: current|all|comp|none, effects?: current|allOn|allOff, solo?: current|allOff, guideLayers?: current|allOff, colorDepth?: current|8|16|32, frameBlending?: current|onForChecked|offForAll, fieldRender?: off|upper|lower, pulldown?: off|WSSWW|SSWWW|SWWWS|WWWSS|WWSSW, motionBlur?: bool|current|onForChecked|offForAll, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool}",
has_items,
set_render_settings
),
@ -611,7 +989,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Output Module Settings...",
[],
None,
"{item?|index?, module?: n (1 = first), format?: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, keyframeInterval?: frames (0 = auto), webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, audio?: auto|on|off, sampleRate?, loop?: bool, output?}",
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
has_items,
set_output_module
),
@ -640,6 +1018,46 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("renderQueue.render", "Render", [], None, "{wait?: bool (default true; the UI renders in the background)}", has_queued, render),
cmd!("renderQueue.stop", "Stop Rendering", [], None, "{}", rendering, stop),
query!("renderQueue.list", "Render Queue Items", "{}", list),
query!("renderQueue.templates", "Render Templates", "{}", templates),
cmd!(
"renderQueue.saveTemplate",
"Save Template...",
[],
None,
"{kind: renderSettings|outputModule, name, item?|index? (save from this item), from?: template to copy, module?, params?: {setRenderSettings / setOutputModule keys}}",
always,
save_template
),
cmd!("renderQueue.deleteTemplate", "Delete Template", [], None, "{kind: renderSettings|outputModule, name}", always, delete_template),
cmd!(
"renderQueue.setTemplateDefault",
"Set Template Default",
[],
None,
"{kind: renderSettings|outputModule, slot: movie|still|preRender|movieProxy|stillProxy, name}",
always,
set_template_default
),
cmd!(
"renderQueue.applyTemplate",
"Apply Template",
[],
None,
"{item?|index?, renderSettings?: template name, outputModule?: template name, module?: n}",
has_items,
apply_template
),
cmd!("renderQueue.setLog", "Render Queue: Log", [], None, "{item?|index?, log: errorsOnly|plusSettings|plusPerFrameInfo}", has_items, set_log),
cmd!("renderQueue.setNotify", "Notify When Done", [], None, "{notify?: bool (toggles)}", always, set_notify),
cmd!(
"renderQueue.setOverflowFolders",
"Storage Overflow Folders",
[],
None,
"{folders: [path, …] (used in order when the output volume is full)}",
always,
set_overflow
),
query!("renderQueue.formats", "Output Formats", "{}", formats),
]
}

View File

@ -0,0 +1,222 @@
//! File ▸ Scripts and ScriptUI.
//!
//! * **Installed scripts** live in the settings store under `Scripts/` (File ▸ Scripts ▸ Install
//! Script File…) and `Scripts/ScriptUI Panels/` (Install ScriptUI Panel…), next to the
//! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts
//! lists them with the bundled sample scripts ([`SAMPLES`]) and runs them by name; ScriptUI
//! panels appear at the bottom of the Window menu and open as dockable panels
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects.
//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
use serde::Serialize;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::{EngineError, Event, Result, Session, cmd, scriptui};
/// The settings-store folders of installed scripts.
pub const SCRIPTS_DIR: &str = "Scripts";
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
/// Sample scripts that ship with EffectCraft (original work): (file name, ScriptUI panel, code).
pub const SAMPLES: &[(&str, bool, &str)] = &[
("Create Null at Selected Layers.jsx", false, include_str!("../../scripts/Create Null at Selected Layers.jsx")),
("Rename Layers.jsx", false, include_str!("../../scripts/Rename Layers.jsx")),
("Render Queue Batch.jsx", false, include_str!("../../scripts/Render Queue Batch.jsx")),
("Sort Layers by In Point.jsx", false, include_str!("../../scripts/Sort Layers by In Point.jsx")),
("Layer Tools.jsx", true, include_str!("../../scripts/ScriptUI Panels/Layer Tools.jsx")),
];
/// A script File ▸ Scripts (or the Window menu, for panels) offers.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ScriptEntry {
/// File name (`Rename Layers.jsx`): what the menus show and commands take.
pub name: String,
/// In the ScriptUI Panels folder: opens as a dockable panel.
pub panel: bool,
/// `installed` or `sample`.
pub source: &'static str,
}
fn is_script(name: &str) -> bool {
let l = name.to_ascii_lowercase();
l.ends_with(".jsx") || l.ends_with(".js")
}
/// Installed scripts and panels, then the samples not shadowed by an installed one, by name.
pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
let mut v: Vec<ScriptEntry> = vec![];
if let Some(cfg) = &s.config {
for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
for n in cfg.list(dir).into_iter().filter(|n| is_script(n)) {
v.push(ScriptEntry { name: n, panel, source: "installed" });
}
}
}
for (name, panel, _) in SAMPLES {
if !v.iter().any(|e| e.name == *name && e.panel == *panel) {
v.push(ScriptEntry { name: name.to_string(), panel: *panel, source: "sample" });
}
}
v.sort_by(|a, b| a.panel.cmp(&b.panel).then(a.name.to_lowercase().cmp(&b.name.to_lowercase())));
v
}
/// The code of an installed or sample script / panel.
pub fn script_code(s: &Session, name: &str, panel: Option<bool>) -> Option<(bool, String)> {
if let Some(cfg) = &s.config {
for (dir, is_panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
if panel.is_some_and(|p| p != is_panel) {
continue;
}
if let Some(code) = cfg.read(&format!("{dir}/{name}")) {
return Some((is_panel, code));
}
}
}
SAMPLES.iter().find(|(n, p, _)| *n == name && panel.is_none_or(|w| w == *p)).map(|(_, p, c)| (*p, c.to_string()))
}
fn install(s: &mut Session, p: &Value, panel: bool) -> Result<Value> {
let c = if panel { "file.installScriptUIPanel" } else { "file.installScript" };
let path = str_p(p, "path").ok_or_else(|| bad(c, "missing `path` (a .jsx / .js script)"))?;
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
if !is_script(&name) {
return Err(bad(c, "scripts are .jsx or .js files (.jsxbin is not supported)"));
}
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let code = String::from_utf8(bytes).map_err(|_| bad(c, "the script is not UTF-8 text"))?;
let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?;
let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR };
cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?;
// After Effects asks for a restart; our menus pick it up at once.
s.toast(if panel {
format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu")
} else {
format!("Installed script {name}: find it in File ▸ Scripts")
});
Ok(json!({"name": name, "panel": panel}))
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(scripts(s)))
}
fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
let c = "file.uninstallScript";
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?;
let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?;
let mut removed = false;
for dir in [SCRIPTS_DIR, PANELS_DIR] {
let key = format!("{dir}/{name}");
if cfg.read(&key).is_some() {
cfg.remove(&key).map_err(|e| EngineError::Other(e.to_string()))?;
removed = true;
}
}
if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) }
}
/// Run an installed or sample script by name (File ▸ Scripts ▸ <name>). A ScriptUI panel run
/// this way gets `this` = a floating palette-like panel window.
pub(crate) fn run_named(s: &mut Session, name: &str) -> Result<Value> {
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or sample script `{name}`")))?;
if panel {
return open_panel(s, &json!({"name": name}));
}
super::file_more::run_js_named(s, &code, name)
}
/// The code a ScriptUI panel runs with: `this` is a dockable Panel titled after the script.
pub fn panel_wrapper(title: &str, code: &str) -> String {
// On one line, so line numbers in errors stay those of the file.
format!("(function () {{ {code}\n}}).call(__uiDockPanel({}));", Value::String(title.to_string()))
}
/// Window ▸ <ScriptUI panel>: run the panel script (or bring its panel forward).
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
let c = "window.scriptPanel";
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string();
let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) {
let id = w.id;
s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) });
return Ok(json!({"window": id, "open": true}));
}
let (_, code) = script_code(s, &name, Some(true)).or_else(|| script_code(s, &name, None)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
let out = super::file_more::run_js_named(s, &panel_wrapper(&title, &code), &name)?;
let id = s.script_ui.windows.iter().rev().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name).map(|w| w.id);
if let Some(id) = id {
s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) });
}
Ok(json!({"window": id, "result": out}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"file.installScript",
"Install Script File...",
["File", "Scripts"],
None,
"{path (.jsx / .js)} → copies it to the Scripts folder; it appears in File ▸ Scripts",
always,
|s, p| install(s, p, false)
),
cmd!(
"file.installScriptUIPanel",
"Install ScriptUI Panel...",
["File", "Scripts"],
None,
"{path (.jsx / .js)} → copies it to the ScriptUI Panels folder; it appears in the Window menu",
always,
|s, p| install(s, p, true)
),
cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall),
crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample}]", list),
cmd!(
"window.scriptPanel",
"ScriptUI Panel",
[],
None,
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel",
always,
open_panel
),
crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list),
crate::query!(
"scriptui.get",
"Script Window Controls",
"{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, children…}}",
scriptui::get
),
cmd!(
"scriptui.click",
"Click Script Window Control",
[],
None,
"{window?: id | title, widget: id | \"#id\" | properties.name | text} (buttons, checkboxes, radio buttons, tabs)",
always,
scriptui::click
),
cmd!(
"scriptui.set",
"Set Script Window Control",
[],
None,
"{window?, widget, value: text | number | bool | item index | item text} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange",
always,
scriptui::set
),
cmd!(
"scriptui.close",
"Close Script Window",
[],
None,
"{window?, result? (a dialog's show() returns it; default 2 = Cancel)}",
always,
scriptui::close
),
]
}

View File

@ -465,6 +465,38 @@ pub(crate) fn sanitize(s: &mut Session) {
}
}
/// The OpenType features of a font (`font` / `style`, else the target text layer's style at the
/// caret or its first character), and which Character panel options it sets with its own glyphs
/// (the others are synthesized or have no effect).
pub fn font_features(s: &mut Session, p: &Value) -> Result<Value> {
let (family, style) = match p.get("font").and_then(Value::as_str) {
Some(f) => (f.to_string(), p.get("style").and_then(Value::as_str).unwrap_or("Regular").to_string()),
None => {
let lid = target(s, p, "text.fontFeatures")?;
let doc = layer_doc(s, lid).ok_or_else(|| bad("text.fontFeatures", "not a text layer"))?;
let at = s.state.text_edit.as_ref().filter(|e| e.layer == lid).map_or(0, |e| e.range().start);
let st = doc.style_at(at);
(st.font, st.style)
}
};
let r = effectcraft_text::resolve(&family, &style);
let face = effectcraft_text::fonts::face(r.face);
let feats = face.features();
let has = |t: &str| feats.iter().any(|f| f == t);
let sets: Vec<u32> = (1..=20u32).filter(|n| has(&format!("ss{n:02}"))).collect();
Ok(json!({
"family": face.info.family, "style": face.info.style, "missing": r.missing,
"features": feats,
"options": {
"kerning": has("kern"), "ligatures": has("liga") || has("clig"), "discretionaryLigatures": has("dlig"),
"contextualAlternates": has("calt"), "stylisticAlternates": has("salt"), "stylisticSets": sets,
"swash": has("swsh"), "titling": has("titl"), "ordinals": has("ordn"), "fractions": has("frac"),
"smallCaps": has("smcp"), "allSmallCaps": has("c2sc"), "superscript": has("sups"), "subscript": has("subs"),
"oldStyleFigures": has("onum"), "liningFigures": has("lnum"), "tabularFigures": has("tnum"), "proportionalFigures": has("pnum"),
},
}))
}
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
@ -485,6 +517,15 @@ pub fn specs() -> Vec<CommandSpec> {
edit
),
cmd!("text.endEdit", "Exit Text Editing", [], None, "{}", always_ok, end_edit),
cmd!(
"text.fontFeatures",
"OpenType Features",
[],
None,
"{layer?, font?, style?} → {family, style, features: [tags], options: {smallCaps, superscript, stylisticSets: [n], fractions, …}} (what the font sets with its own glyphs)",
always_ok,
font_features
),
cmd!(
"text.setSelection",
"Set Text Selection",

View File

@ -89,6 +89,18 @@ fn share_view_options(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(s.state.share_view_options))
}
/// Settings ▸ 3D ▸ Extended Viewer: 3D views (custom views, or Draft 3D) show the pasteboard
/// around the comp frame with the layers that reach onto it.
fn extended_viewer(s: &mut Session, p: &Value) -> Result<Value> {
let v = super::b_p(p, "value").unwrap_or(!s.prefs.three_d.extended_viewer);
if v != s.prefs.three_d.extended_viewer {
s.prefs.three_d.extended_viewer = v;
s.prefs_changed();
s.save_prefs();
}
Ok(json!(v))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide),
@ -97,6 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides),
cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout),
cmd!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], None, "{value?}", has_comp, share_view_options),
cmd!("view.extendedViewer", "Extended Viewer", [], None, "{value?}", always, extended_viewer),
cmd!("view.setRegionOfInterest", "Region of Interest", [], None, "{rect?: [x, y, w, h] | null}", always, region_of_interest),
]
}

View File

@ -16,6 +16,12 @@ pub trait ConfigStore: Send + Sync {
fn read(&self, name: &str) -> Option<String>;
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
fn remove(&self, name: &str) -> std::io::Result<()>;
/// Names of the entries directly in `dir` (a `/`-separated name prefix such as `Scripts`),
/// sorted. Stores that can't list return nothing.
fn list(&self, dir: &str) -> Vec<String> {
let _ = dir;
vec![]
}
/// The folder next to the configuration (default Auto-Save folder for untitled projects),
/// in the namespace of [`ConfigStore::files`]; `None` when there is none.
fn dir(&self) -> Option<PathBuf> {
@ -86,6 +92,11 @@ impl ConfigStore for MemoryConfig {
}
Ok(())
}
fn list(&self, dir: &str) -> Vec<String> {
let pre = format!("{}/", dir.trim_end_matches('/'));
let Ok(m) = self.files.lock() else { return vec![] };
m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect()
}
}
/// Files in one directory, written atomically.
@ -104,8 +115,15 @@ impl ConfigStore for DirConfig {
std::fs::read_to_string(self.dir.join(name)).ok()
}
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
atomic_write(&self.dir.join(name), data.as_bytes())
let path = self.dir.join(name);
std::fs::create_dir_all(path.parent().unwrap_or(&self.dir))?;
atomic_write(&path, data.as_bytes())
}
fn list(&self, dir: &str) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(self.dir.join(dir)) else { return vec![] };
let mut v: Vec<String> = rd.flatten().filter(|e| e.path().is_file()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
v.sort();
v
}
fn remove(&self, name: &str) -> std::io::Result<()> {
match std::fs::remove_file(self.dir.join(name)) {

View File

@ -0,0 +1,346 @@
//! Branching undo history (the History panel; better than After Effects, whose undo is a
//! straight line).
//!
//! [`History`] keeps After Effects' linear model as its working line — `undo` (the states
//! before each step, oldest first) and `redo` (undone states, next first) — and adds
//! **branches**: when you undo and then do something new, the undone states are not thrown
//! away but kept as a [`Branch`] hanging off the state they grew from. [`History::tree`] lists
//! every state as a tree and [`Session::goto_history`] jumps to any of them, on any branch: the
//! path to the target becomes the working line, and the line you left becomes a branch.
//!
//! States are whole-project snapshots (`Arc<Project>`), identified by their address while the
//! history holds them ([`HistoryNode::id`]), so ids stay valid across undo, redo and jumps.
use std::collections::HashMap;
use std::sync::Arc;
use effectcraft_project::Project;
use serde::Serialize;
use crate::Session;
/// Label of the oldest state in the history (the project as opened, or the oldest state kept).
pub const ROOT_LABEL: &str = "Original";
/// Undo history of whole-project snapshots.
#[derive(Clone, Default)]
pub struct History {
/// The states before each step on the working line, oldest first: (step label, state).
pub undo: Vec<(String, Arc<Project>)>,
/// Undone steps, the next one last: (step label, the state it leads to).
pub redo: Vec<(String, Arc<Project>)>,
/// Key of the last merged step: a continuous gesture with the same key folds into one step.
pub merge_key: Option<String>,
/// Alternative futures kept when a new step was made after undoing.
pub branches: Vec<Branch>,
}
/// A line of states that grows from another state of the history.
#[derive(Clone)]
pub struct Branch {
/// The state the branch grows from.
pub parent: Arc<Project>,
/// The branch's states in order: (label of the step that made it, state).
pub steps: Vec<(String, Arc<Project>)>,
}
/// One state of the history tree (`edit.history.list`).
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryNode {
/// Position in the listing (depth-first, the working line's continuation first).
pub index: usize,
/// Stable id of the state while the history keeps it.
pub id: String,
/// The step that made this state ([`ROOT_LABEL`] for the oldest).
pub label: String,
/// Index of the state it was made from.
pub parent: Option<usize>,
/// Branch nesting (0 = the working line).
pub depth: usize,
/// The current state.
pub current: bool,
/// On the working line (Undo / Redo walk it).
pub line: bool,
/// On the working line after the current state (Redo reaches it).
pub future: bool,
}
fn key(p: &Arc<Project>) -> usize {
Arc::as_ptr(p) as usize
}
/// Id of a state (its address while the history holds it).
pub fn state_id(p: &Arc<Project>) -> String {
format!("s{:x}", key(p))
}
/// The whole tree as an arena (internal).
struct Arena {
label: Vec<String>,
state: Vec<Arc<Project>>,
parent: Vec<Option<usize>>,
children: Vec<Vec<usize>>,
/// Index of each working-line state, oldest first, and the current state's position in it.
line: Vec<usize>,
current: usize,
}
impl Arena {
fn build(h: &History, current: &Arc<Project>) -> Arena {
let mut a = Arena { label: vec![], state: vec![], parent: vec![], children: vec![], line: vec![], current: 0 };
let add = |a: &mut Arena, label: String, st: Arc<Project>, parent: Option<usize>| {
let i = a.state.len();
a.label.push(label);
a.state.push(st);
a.parent.push(parent);
a.children.push(vec![]);
if let Some(p) = parent {
a.children[p].push(i);
}
i
};
// The working line: undo states, the current state, then the redo states.
let mut prev: Option<usize> = None;
let mut next_label = ROOT_LABEL.to_string();
for (label, st) in &h.undo {
let i = add(&mut a, std::mem::take(&mut next_label), st.clone(), prev);
a.line.push(i);
prev = Some(i);
next_label = label.clone();
}
let cur = add(&mut a, next_label, current.clone(), prev);
a.line.push(cur);
a.current = cur;
prev = Some(cur);
for (label, st) in h.redo.iter().rev() {
let i = add(&mut a, label.clone(), st.clone(), prev);
a.line.push(i);
prev = Some(i);
}
// Branches, attached to whichever state they grow from (branches of branches need the
// parent branch first, hence the passes). Orphans (their state was dropped) are skipped.
let mut index: HashMap<usize, usize> = a.state.iter().enumerate().map(|(i, s)| (key(s), i)).collect();
let mut pending: Vec<&Branch> = h.branches.iter().collect();
loop {
let before = pending.len();
pending.retain(|b| {
let Some(&p) = index.get(&key(&b.parent)) else { return true };
let mut prev = p;
for (label, st) in &b.steps {
if index.contains_key(&key(st)) {
// Already in the tree (shared state): continue from it.
prev = index[&key(st)];
continue;
}
let i = add(&mut a, label.clone(), st.clone(), Some(prev));
index.insert(key(st), i);
prev = i;
}
false
});
if pending.is_empty() || pending.len() == before {
break;
}
}
a
}
fn on_line(&self) -> Vec<bool> {
let mut v = vec![false; self.state.len()];
for &i in &self.line {
v[i] = true;
}
v
}
/// Depth-first listing: the working line's child first, then branches in creation order.
fn listing(&self) -> Vec<HistoryNode> {
let on_line = self.on_line();
let pos_in_line: HashMap<usize, usize> = self.line.iter().enumerate().map(|(k, &i)| (i, k)).collect();
let cur_pos = pos_in_line[&self.current];
let mut out: Vec<HistoryNode> = vec![];
let mut order: HashMap<usize, usize> = HashMap::new();
// (node, depth)
let mut stack: Vec<(usize, usize)> = vec![(self.line[0], 0)];
while let Some((n, depth)) = stack.pop() {
order.insert(n, out.len());
let line = on_line[n];
out.push(HistoryNode {
index: out.len(),
id: state_id(&self.state[n]),
label: self.label[n].clone(),
parent: self.parent[n].and_then(|p| order.get(&p).copied()),
depth,
current: n == self.current,
line,
future: line && pos_in_line[&n] > cur_pos,
});
// Push in reverse so the first child pops first; the line child continues at the
// same depth, branch children one deeper; a branch's own continuation (its first
// child) stays at the branch's depth.
for (k, &c) in self.children[n].iter().enumerate().rev() {
let cont = if line { on_line[c] } else { k == 0 };
stack.push((c, if cont { depth } else { depth + 1 }));
}
}
out
}
}
impl History {
/// Record a new step made from `before`: the redo line (if any) is kept as a branch.
pub fn record(&mut self, label: &str, before: Arc<Project>, levels: usize) {
self.abandon_redo(&before);
self.undo.push((label.to_string(), before.clone()));
self.merge_key = None;
self.trim(levels, &before);
}
/// Keep the undone states as a branch growing from `from` (the state a new step starts from).
pub fn abandon_redo(&mut self, from: &Arc<Project>) {
if self.redo.is_empty() {
return;
}
let steps: Vec<(String, Arc<Project>)> = self.redo.drain(..).rev().collect();
self.branches.push(Branch { parent: from.clone(), steps });
}
/// Apply the undo-levels limit: drop the oldest working-line states, the branches that grew
/// from them, and the oldest branches when branches hold more states than `levels`.
pub fn trim(&mut self, levels: usize, current: &Arc<Project>) {
let levels = levels.max(1);
if self.undo.len() > levels {
let extra = self.undo.len() - levels;
self.undo.drain(..extra);
}
let mut total: usize = self.branches.iter().map(|b| b.steps.len()).sum();
while total > levels && !self.branches.is_empty() {
total -= self.branches.remove(0).steps.len();
}
self.prune(current);
}
/// Drop branches whose parent state is no longer in the history (`current` = the current
/// state, which the history doesn't store).
pub fn prune(&mut self, current: &Arc<Project>) {
if self.branches.is_empty() {
return;
}
let mut live: std::collections::HashSet<usize> = self.undo.iter().chain(self.redo.iter()).map(|(_, p)| key(p)).collect();
live.insert(key(current));
let mut keep = vec![false; self.branches.len()];
loop {
let mut changed = false;
for (i, b) in self.branches.iter().enumerate() {
if !keep[i] && live.contains(&key(&b.parent)) {
keep[i] = true;
live.extend(b.steps.iter().map(|(_, p)| key(p)));
changed = true;
}
}
if !changed {
break;
}
}
let mut k = keep.into_iter();
self.branches.retain(|_| k.next().unwrap_or(false));
}
/// Drop everything (Edit ▸ Purge ▸ Undo, New / Open Project).
pub fn clear(&mut self) {
*self = History::default();
}
/// The history as a tree, given the current state.
pub fn tree(&self, current: &Arc<Project>) -> Vec<HistoryNode> {
Arena::build(self, current).listing()
}
/// Number of states on branches (not on the working line).
pub fn branch_states(&self) -> usize {
self.branches.iter().map(|b| b.steps.len()).sum()
}
}
impl Session {
/// The undo history as a tree (the History panel, `edit.history.list`).
pub fn history_tree(&self) -> Vec<HistoryNode> {
self.history.tree(&self.project)
}
/// Jump to any state of the history (`id` from [`HistoryNode::id`]), including states on
/// other branches: the path to it becomes the working line (its undone continuation the
/// redo line), and the line left behind is kept as a branch. Returns false for an unknown
/// id.
pub fn goto_history(&mut self, id: &str) -> bool {
let a = Arena::build(&self.history, &self.project);
let Some(target) = a.state.iter().position(|s| state_id(s) == id) else { return false };
if target == a.current {
return true;
}
let on_line = a.on_line();
// Path root → target.
let mut path = vec![target];
while let Some(p) = a.parent[*path.last().unwrap_or(&target)] {
path.push(p);
}
path.reverse();
// Continue past the target: the working line where it runs on, else each branch's own
// continuation (its first child).
let mut new_line = path.clone();
let mut n = target;
loop {
let kids = &a.children[n];
let next = if on_line[n] { kids.iter().copied().find(|&c| on_line[c]) } else { kids.first().copied() };
match next {
Some(c) => {
new_line.push(c);
n = c;
}
None => break,
}
}
let mut in_new = vec![false; a.state.len()];
for &i in &new_line {
in_new[i] = true;
}
// Every state off the new line goes into branches: a chain per first child, the other
// children starting branches of their own.
fn chain(a: &Arena, start: usize, parent: usize, in_new: &[bool], out: &mut Vec<Branch>) {
let mut steps = vec![];
let mut n = start;
let mut extra: Vec<(usize, usize)> = vec![];
loop {
steps.push((a.label[n].clone(), a.state[n].clone()));
let kids: Vec<usize> = a.children[n].iter().copied().filter(|&c| !in_new[c]).collect();
let Some((&first, rest)) = kids.split_first() else { break };
extra.extend(rest.iter().map(|&c| (c, n)));
n = first;
}
out.push(Branch { parent: a.state[parent].clone(), steps });
for (c, p) in extra {
chain(a, c, p, in_new, out);
}
}
let mut branches = vec![];
for &n in &new_line {
for &c in &a.children[n] {
if !in_new[c] {
chain(&a, c, n, &in_new, &mut branches);
}
}
}
let k = new_line.iter().position(|&i| i == target).unwrap_or(0);
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
self.project = a.state[target].clone();
self.history.undo = undo;
self.history.redo = redo;
self.history.branches = branches;
self.history.merge_key = None;
self.sanitize_state();
self.bump();
true
}
}

525
crates/engine/src/learn.rs Normal file
View File

@ -0,0 +1,525 @@
//! Learn: interactive tutorials on the Home screen (our own content, licensed with the repo).
//!
//! A tutorial is a list of steps. Each step names the UI element to highlight (an automation id,
//! see `docs/control-protocol.md`), the commands that complete it when the user performs them,
//! and the commands "Show me" runs to perform it for the user. Progress lives on the
//! [`Session`] (not the project), so opening the demo project in a step keeps the tutorial going.
//!
//! Commands: `learn.list`, `learn.start {id}`, `learn.step {action: next|back|showMe|goto,
//! index?}`, `learn.stop`, `learn.state`.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, str_p};
use crate::{Result, Session, cmd, query};
/// One step of a tutorial.
#[derive(Clone, Debug, Serialize)]
pub struct Step {
pub title: String,
pub text: String,
/// Automation id of the element to highlight. `*` matches any run of characters and
/// `{layer}` stands for the first selected layer's id (see [`resolve_target`]).
pub target: Option<String>,
/// Commands that complete the step when they run (empty: a reading step, advanced with Next).
pub accepts: Vec<String>,
/// Parameters the completing command must carry (a subset match; `null` = any).
pub when: Value,
/// What "Show me" runs: (command, params), in order.
pub show: Vec<(String, Value)>,
}
/// An interactive tutorial.
#[derive(Clone, Debug, Serialize)]
pub struct Tutorial {
pub id: String,
pub title: String,
pub summary: String,
/// Rough length in minutes.
pub minutes: u32,
pub steps: Vec<Step>,
}
/// Where the user is in a tutorial.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Progress {
pub tutorial: String,
/// Index of the current step (`== steps.len()` once finished).
pub step: usize,
pub done: bool,
}
struct S<'a> {
title: &'a str,
text: &'a str,
target: Option<&'a str>,
accepts: &'a [&'a str],
when: Value,
show: Vec<(&'a str, Value)>,
}
fn step(s: S) -> Step {
Step {
title: s.title.into(),
text: s.text.into(),
target: s.target.map(str::to_string),
accepts: s.accepts.iter().map(|a| a.to_string()).collect(),
when: s.when,
show: s.show.into_iter().map(|(c, p)| (c.to_string(), p)).collect(),
}
}
/// The built-in tutorials.
pub fn tutorials() -> Vec<Tutorial> {
vec![
Tutorial {
id: "animate-title".into(),
title: "Animate a title".into(),
summary: "Make a composition, type a title and fly it in with two position keyframes and an easy ease.".into(),
minutes: 4,
steps: vec![
step(S {
title: "Make a composition",
text: "Everything you animate lives in a composition. Choose Composition ▸ New Composition (Cmd+N) and keep the HD 1920×1080 settings.",
target: Some("menu.Composition"),
accepts: &["comp.new"],
when: Value::Null,
show: vec![("comp.new", json!({"name": "Title Animation", "width": 1920, "height": 1080, "duration": 5}))],
}),
step(S {
title: "Type a title",
text: "Choose Layer ▸ New ▸ Text (or the Type tool) and type a short title. A new text layer appears at the top of the Timeline.",
target: Some("menu.Layer"),
accepts: &["layer.newText"],
when: Value::Null,
show: vec![("layer.newText", json!({"text": "Hello, motion", "size": 120}))],
}),
step(S {
title: "Start the animation",
text: "Twirl the text layer open, then Transform. Click the stopwatch beside Position: it turns blue and records a keyframe at the current time.",
target: Some("timeline.layer.{layer}.twirl"),
accepts: &["prop.toggleAnimation", "prop.addKey", "prop.toggleKey", "anim.addKeyframe"],
when: Value::Null,
show: vec![
("prop.set", json!({"path": "transform/position", "value": [-400.0, 540.0, 0.0]})),
("prop.toggleAnimation", json!({"path": "transform/position", "value": true})),
],
}),
step(S {
title: "Move in time",
text: "Drag the blue current-time indicator to about 0:00:01:00, or click in the time ruler above the layer bars.",
target: Some("timeline.ruler"),
accepts: &["time.set", "time.step", "time.go"],
when: Value::Null,
show: vec![("time.set", json!({"time": 1.0}))],
}),
step(S {
title: "Set the end position",
text: "Drag the title to the middle of the frame in the Composition viewer, or scrub the Position value. Because the stopwatch is on, a second keyframe appears by itself.",
target: Some("viewer.comp"),
accepts: &["prop.set"],
when: json!({"path": "transform/position"}),
show: vec![("prop.set", json!({"path": "transform/position", "value": [960.0, 540.0, 0.0]}))],
}),
step(S {
title: "Smooth it out",
text: "Select both keyframes (drag a box around them, or click the Position name) and press F9 for Easy Ease. The title now glides in and settles.",
target: Some("timeline.ruler"),
accepts: &["keys.easyEase"],
when: Value::Null,
show: vec![("keys.selectAll", json!({})), ("keys.easyEase", json!({}))],
}),
step(S {
title: "Watch it",
text: "Press the Spacebar (or Play in the Preview panel) to play your animation. Press it again to stop. That's it: you made your first animation.",
target: Some("panel.Preview"),
accepts: &["playback.toggle"],
when: Value::Null,
show: vec![("time.set", json!({"time": 0.0})), ("playback.toggle", json!({}))],
}),
],
},
Tutorial {
id: "track-attach".into(),
title: "Track and attach".into(),
summary: "Follow a moving element with the point tracker, then hang a null on the track and parent a layer to it.".into(),
minutes: 5,
steps: vec![
step(S {
title: "Open the demo",
text: "We'll track the moving lower third in the demo project. Open it from Home ▸ Open Demo Project (or File ▸ Open Demo Project).",
target: Some("menu.File"),
accepts: &["file.openDemoProject"],
when: Value::Null,
show: vec![("file.openDemoProject", json!({}))],
}),
step(S {
title: "Pick what to track",
text: "Click the Lower Third layer in the Timeline, then press I to jump to where it starts (0:00:05:00). The tracker follows the pixels of the selected layer's source.",
target: Some("timeline.layer.*.row"),
accepts: &["layer.select"],
when: Value::Null,
show: vec![("layer.select", json!({"layers": ["Lower Third"]})), ("time.set", json!({"time": 5.5}))],
}),
step(S {
title: "Add a track point",
text: "Open Window ▸ Tracker and click Track Motion. A track point appears: the inner box is the feature to follow, the outer box is where to look for it on the next frame.",
target: Some("tracker.trackMotion"),
accepts: &["track.motion", "track.new"],
when: Value::Null,
show: vec![("track.motion", json!({}))],
}),
step(S {
title: "Analyze",
text: "Click Analyze forward ▶ in the Tracker panel. The point follows the feature frame by frame and leaves a path of keyframes behind.",
target: Some("tracker.analyze.forward"),
accepts: &["track.analyze"],
when: Value::Null,
show: vec![("track.analyze", json!({"direction": "forward", "end": 6.0, "wait": true}))],
}),
step(S {
title: "Make a null to carry the motion",
text: "Choose Layer ▸ New ▸ Null Object. A null is invisible; it only holds a transform, which makes it a handy anchor for tracked motion.",
target: Some("menu.Layer"),
accepts: &["layer.newNull"],
when: Value::Null,
show: vec![("layer.newNull", json!({"name": "Track Null"}))],
}),
step(S {
title: "Point the track at the null",
text: "Select Lower Third again, click Edit Target… in the Tracker panel and choose the null as the motion target.",
target: Some("tracker.editTarget"),
accepts: &["track.setTarget"],
when: Value::Null,
show: vec![("track.setTarget", json!({"layer": "Lower Third", "target": "Track Null"}))],
}),
step(S {
title: "Apply",
text: "Click Apply and keep X and Y. The null's Position now follows the track.",
target: Some("tracker.apply"),
accepts: &["track.apply"],
when: Value::Null,
show: vec![("track.apply", json!({"layer": "Lower Third", "dimensions": "xy"}))],
}),
step(S {
title: "Attach a layer",
text: "Drag a layer's pick whip (the spiral in Parent & Link) onto the null, or pick it from the menu. The child now rides along with the track.",
target: Some("timeline.layer.*.pickWhip"),
accepts: &["layer.setParent"],
when: Value::Null,
show: vec![("layer.setParent", json!({"layers": ["Tagline"], "parent": "Track Null"}))],
}),
],
},
Tutorial {
id: "3d-scene".into(),
title: "Make a 3D scene".into(),
summary: "Turn flat layers into 3D, light them, add a camera and look at the scene from outside the frame.".into(),
minutes: 5,
steps: vec![
step(S {
title: "Make a composition",
text: "Choose Composition ▸ New Composition. Any size works; 1920×1080 is a good start.",
target: Some("menu.Composition"),
accepts: &["comp.new"],
when: Value::Null,
show: vec![("comp.new", json!({"name": "3D Scene", "width": 1920, "height": 1080, "duration": 5}))],
}),
step(S {
title: "Add a floor",
text: "Choose Layer ▸ New ▸ Solid and make a large solid in a calm colour. It will become the floor.",
target: Some("menu.Layer"),
accepts: &["layer.newSolid"],
when: Value::Null,
show: vec![("layer.newSolid", json!({"name": "Floor", "color": "#3c5a82", "width": 2400, "height": 2400}))],
}),
step(S {
title: "Make it 3D",
text: "Click the cube switch of the solid in the Timeline. The layer gains a Z position and X/Y/Z rotation.",
target: Some("timeline.layer.{layer}.switch.threeD"),
accepts: &["layer.setSwitch"],
when: json!({"switch": "threeD"}),
show: vec![("layer.setSwitch", json!({"switch": "threeD", "value": true}))],
}),
step(S {
title: "Lay it down",
text: "Set X Rotation to -80° (twirl open Transform and scrub the value) so the solid lies like a floor, and push its Position down a little.",
target: Some("timeline.layer.{layer}.twirl"),
accepts: &["prop.set"],
when: Value::Null,
show: vec![
("prop.set", json!({"path": "transform/rotationX", "value": -80.0})),
("prop.set", json!({"path": "transform/position", "value": [960.0, 760.0, 0.0]})),
],
}),
step(S {
title: "Light it",
text: "Choose Layer ▸ New ▸ Light and pick Spot. 3D layers now catch light and fall-off.",
target: Some("menu.Layer"),
accepts: &["layer.newLight"],
when: Value::Null,
show: vec![("layer.newLight", json!({"kind": "Spot", "name": "Key Light"}))],
}),
step(S {
title: "Add a camera",
text: "Choose Layer ▸ New ▸ Camera. The comp is now seen through this camera; orbit it with the camera tools (press C).",
target: Some("menu.Layer"),
accepts: &["layer.newCamera"],
when: Value::Null,
show: vec![("layer.newCamera", json!({"name": "Camera"}))],
}),
step(S {
title: "Step outside the camera",
text: "In the 3D View popup under the viewer choose Custom View 1. You see the scene, the camera and the light from a working view.",
target: Some("viewer.view3d"),
accepts: &[
"view.set3DView",
"view.3d.custom1",
"view.3d.custom2",
"view.3d.custom3",
"view.3d.front",
"view.3d.top",
"view.3d.left",
"view.3d.right",
"view.3d.back",
"view.3d.bottom",
],
when: Value::Null,
show: vec![("view.3d.custom1", json!({}))],
}),
step(S {
title: "See past the frame",
text: "Click Extended Viewer beside the 3D View popup. The viewer now shows the layers beyond the comp's edges, with the frame outlined.",
target: Some("viewer.extendedViewer"),
accepts: &["view.extendedViewer"],
when: Value::Null,
show: vec![("view.extendedViewer", json!({"value": true}))],
}),
],
},
]
}
/// The tutorial with this id.
pub fn find(id: &str) -> Option<Tutorial> {
tutorials().into_iter().find(|t| t.id == id)
}
/// The automation id pattern of a step's target with `{layer}` filled in (`*` stays a wildcard).
pub fn resolve_target(target: &str, layer: Option<u64>) -> String {
match layer {
Some(l) => target.replace("{layer}", &l.to_string()),
None => target.replace("{layer}", "*"),
}
}
/// Whether an automation id matches a target pattern (`*` = any run of characters).
pub fn target_matches(pattern: &str, id: &str) -> bool {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == id;
}
let mut rest = id;
for (i, part) in parts.iter().enumerate() {
if i == 0 {
let Some(r) = rest.strip_prefix(part) else { return false };
rest = r;
} else if i == parts.len() - 1 {
return rest.ends_with(part);
} else {
let Some(at) = rest.find(part) else { return false };
rest = &rest[at + part.len()..];
}
}
true
}
/// `want`'s keys all equal in `got` (`null` matches anything).
fn params_match(want: &Value, got: &Value) -> bool {
match want {
Value::Object(m) => m.iter().all(|(k, v)| got.get(k) == Some(v)),
_ => true,
}
}
impl Session {
/// The current tutorial and step, if one is running.
pub fn learn_current(&self) -> Option<(Tutorial, usize)> {
let p = self.learn.as_ref()?;
Some((find(&p.tutorial)?, p.step))
}
/// A command ran: complete the current step when it is one the step accepts.
pub(crate) fn learn_observe(&mut self, id: &str, params: &Value) {
if id.starts_with("learn.") {
return;
}
let Some((t, i)) = self.learn_current() else { return };
let Some(st) = t.steps.get(i) else { return };
if st.accepts.iter().any(|a| a == id) && params_match(&st.when, params) {
self.learn_goto(&t, i + 1);
}
}
fn learn_goto(&mut self, t: &Tutorial, i: usize) {
let n = t.steps.len();
let i = i.min(n);
if let Some(p) = &mut self.learn {
p.step = i;
p.done = i >= n;
}
if i >= n {
self.toast(format!("Tutorial complete: {}", t.title));
}
}
}
fn state_json(s: &Session) -> Value {
match s.learn_current() {
Some((t, i)) => json!({
"tutorial": t.id,
"title": t.title,
"step": i,
"steps": t.steps.len(),
"done": i >= t.steps.len(),
"current": t.steps.get(i).map(|st| json!({
"title": st.title,
"text": st.text,
"target": st.target.as_deref().map(|x| resolve_target(x, s.state.selected_layers.first().map(|l| l.0))),
"accepts": st.accepts,
})),
}),
None => Value::Null,
}
}
fn list(_: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(
tutorials()
.iter()
.map(|t| json!({"id": t.id, "title": t.title, "summary": t.summary, "minutes": t.minutes, "steps": t.steps.iter().map(|s| s.title.clone()).collect::<Vec<_>>()}))
.collect::<Vec<_>>()
))
}
fn start(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_p(p, "id").or_else(|| str_p(p, "tutorial")).ok_or_else(|| bad("learn.start", "missing `id`"))?;
let t = find(id).ok_or_else(|| bad("learn.start", format!("unknown tutorial `{id}` (see learn.list)")))?;
s.learn = Some(Progress { tutorial: t.id.clone(), step: 0, done: false });
Ok(state_json(s))
}
fn step_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let (t, i) = s.learn_current().ok_or_else(|| bad("learn.step", "no tutorial is running (learn.start)"))?;
match str_p(p, "action").unwrap_or("next") {
"next" => s.learn_goto(&t, i + 1),
"back" | "prev" | "previous" => s.learn_goto(&t, i.saturating_sub(1)),
"goto" => {
let n = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("learn.step", "goto needs `index`"))?;
s.learn_goto(&t, n as usize);
}
"showMe" | "show" => {
let st = t.steps.get(i).ok_or_else(|| bad("learn.step", "the tutorial is finished"))?;
for (c, params) in &st.show {
s.execute(c, params.clone()).map_err(|e| bad("learn.step", format!("Show me: {c}: {e}")))?;
}
// Steps whose commands don't complete them (or reading steps) still move on.
if s.learn.as_ref().is_some_and(|p| p.step == i) {
s.learn_goto(&t, i + 1);
}
}
a => return Err(bad("learn.step", format!("action must be next|back|showMe|goto, not `{a}`"))),
}
Ok(state_json(s))
}
fn stop(s: &mut Session, _: &Value) -> Result<Value> {
s.learn = None;
Ok(Value::Null)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("learn.list", "Learn Tutorials", "{} → [{id, title, summary, minutes, steps}]", list),
query!("learn.state", "Tutorial State", "{} → {tutorial, step, steps, done, current: {title, text, target, accepts}} | null", |s, _| Ok(state_json(
s
))),
cmd!("learn.start", "Start Tutorial", [], None, "{id}", always, start),
cmd!("learn.step", "Tutorial Step", [], None, "{action?: next|back|showMe|goto, index?}", always, step_cmd),
cmd!("learn.stop", "Close Tutorial", [], None, "{}", always, stop),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn targets_match_with_wildcards() {
assert!(target_matches("timeline.layer.*.row", "timeline.layer.12.row"));
assert!(!target_matches("timeline.layer.*.row", "timeline.layer.12.bar"));
assert!(target_matches("menu.File", "menu.File"));
assert!(!target_matches("menu.File", "menu.File.2"));
assert_eq!(resolve_target("timeline.layer.{layer}.twirl", Some(7)), "timeline.layer.7.twirl");
assert_eq!(resolve_target("timeline.layer.{layer}.twirl", None), "timeline.layer.*.twirl");
}
#[test]
fn every_step_names_real_commands() {
for t in tutorials() {
assert!(!t.steps.is_empty(), "{}", t.id);
for st in &t.steps {
for c in st.accepts.iter().chain(st.show.iter().map(|(c, _)| c)) {
assert!(crate::commands::find(c).is_some(), "{}: unknown command {c}", t.id);
}
assert!(!st.show.is_empty(), "{}: `{}` has no Show me", t.id, st.title);
}
}
}
#[test]
fn tutorials_run_end_to_end_with_show_me() {
for id in ["animate-title", "3d-scene", "track-attach"] {
let mut s = Session::default();
s.execute("learn.start", json!({"id": id})).unwrap();
let n = find(id).unwrap().steps.len();
for i in 0..n {
let st = s.execute("learn.step", json!({"action": "showMe"})).unwrap_or_else(|e| panic!("{id} step {i}: {e}"));
assert_eq!(st["step"], json!(i + 1), "{id}: {st}");
}
assert_eq!(s.execute("learn.state", json!({})).unwrap()["done"], json!(true));
}
}
#[test]
fn performing_the_command_advances() {
let mut s = Session::default();
let list = s.execute("learn.list", json!({})).unwrap();
assert_eq!(list.as_array().unwrap().len(), 3);
s.execute("learn.start", json!({"id": "animate-title"})).unwrap();
// Unrelated commands don't advance.
s.execute("file.openDemoProject", json!({})).unwrap();
assert_eq!(s.learn.as_ref().unwrap().step, 0);
s.execute("comp.new", json!({"name": "Mine"})).unwrap();
assert_eq!(s.learn.as_ref().unwrap().step, 1);
s.execute("layer.newText", json!({"text": "Hi"})).unwrap();
assert_eq!(s.learn.as_ref().unwrap().step, 2);
let cur = s.execute("learn.state", json!({})).unwrap();
let lid = s.state.selected_layers[0].0;
assert_eq!(cur["current"]["target"], json!(format!("timeline.layer.{lid}.twirl")));
// Back and Next.
s.execute("learn.step", json!({"action": "back"})).unwrap();
assert_eq!(s.learn.as_ref().unwrap().step, 1);
s.execute("learn.step", json!({})).unwrap();
assert_eq!(s.learn.as_ref().unwrap().step, 2);
// A matching command with the wrong parameters doesn't complete a `when` step.
s.execute("learn.start", json!({"id": "3d-scene"})).unwrap();
s.execute("learn.step", json!({"action": "goto", "index": 2})).unwrap();
s.execute("layer.setSwitch", json!({"switch": "shy", "value": true})).ok();
assert_eq!(s.learn.as_ref().unwrap().step, 2);
s.execute("learn.stop", json!({})).unwrap();
assert!(s.learn.is_none());
assert!(s.execute("learn.step", json!({})).is_err());
}
}

View File

@ -13,7 +13,9 @@ pub mod camera_track;
pub mod commands;
pub mod config;
pub mod demo;
pub mod history;
pub mod jobs;
pub mod learn;
pub mod links;
pub mod logging;
pub mod mask_track;
@ -25,6 +27,7 @@ pub mod prefs;
pub mod psd_import;
pub mod render_queue;
pub mod roto;
pub mod scriptui;
mod session_settings;
pub mod shortcuts;
pub mod sysinfo;
@ -54,6 +57,7 @@ pub use effectcraft_render as render;
pub use effectcraft_text as text;
pub use effectcraft_time as time;
pub use effectcraft_track as track;
pub use history::{Branch, History, HistoryNode};
pub use render_queue::{ExportJob, ExportResult, Exporter, JobState};
#[derive(Debug, thiserror::Error)]
@ -97,15 +101,6 @@ pub trait Importer: Send + Sync {
fn probe(&self, path: &str) -> std::result::Result<effectcraft_project::Footage, String>;
}
/// Undo history of whole-project snapshots.
#[derive(Clone, Default)]
pub struct History {
pub undo: Vec<(String, Arc<Project>)>,
pub redo: Vec<(String, Arc<Project>)>,
/// Key of the last merged step: a continuous gesture with the same key folds into one step.
pub merge_key: Option<String>,
}
/// A keyframe reference: layer, property uid, key time (layer time).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct KeyRef {
@ -291,6 +286,8 @@ pub struct Session {
pub importer: Option<Arc<dyn Importer>>,
/// Render Queue encoder (the export layer); `None` = export unavailable.
pub exporter: Option<Arc<dyn Exporter>>,
/// Free-space hook for Render Settings ▸ Use Storage Overflow (`None`: never full).
pub storage_quota: Option<Arc<dyn effectcraft_project::render_queue::StorageQuota>>,
/// The running (or finished, not yet polled) render.
pub render_job: Option<render_queue::RenderJob>,
/// The running (or finished, not yet polled) track analysis.
@ -336,6 +333,11 @@ pub struct Session {
/// The JavaScript scripting engine (set by the host that links `effectcraft-script`):
/// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console.
pub script: Option<ScriptRunner>,
/// Loads WebAssembly effect plug-ins (set by the host that links `effectcraft-plugin` with
/// its runtime): `effect.plugins.load`.
pub plugin_loader: Option<PluginLoader>,
/// ScriptUI windows and panels opened by scripts (see [`scriptui`]).
pub script_ui: scriptui::ScriptUi,
/// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown
/// (headless sessions don't query it, see [`Session::memory_tick`]).
pub sys_memory: Option<sysinfo::SysMemory>,
@ -351,6 +353,8 @@ pub struct Session {
/// Finished tasks, newest last (Progress panel, `jobs.list`).
pub job_log: Vec<jobs::JobRecord>,
pub next_task_id: u64,
/// The running Learn tutorial (Home ▸ Learn), see [`learn`].
pub learn: Option<learn::Progress>,
}
/// A script to run (see [`Session::script`]).
@ -367,6 +371,10 @@ pub struct ScriptRequest<'a> {
/// column, file} | null}`.
pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value;
/// Loads one WebAssembly effect plug-in (`bytes`, `source` path) into the effect registry and
/// describes it (`{id, name, category, version, api, params}`).
pub type PluginLoader = fn(&[u8], &str) -> std::result::Result<Value, String>;
impl Default for Session {
fn default() -> Self {
Session {
@ -383,6 +391,7 @@ impl Default for Session {
expr_check: None,
importer: None,
exporter: None,
storage_quota: None,
render_job: None,
track_job: None,
mask_job: None,
@ -405,6 +414,8 @@ impl Default for Session {
autosave: autosave::AutoSaveState::default(),
snapshot: None,
script: None,
plugin_loader: None,
script_ui: scriptui::ScriptUi::default(),
sys_memory: None,
applied_nested_switches: None,
offload: None,
@ -412,6 +423,7 @@ impl Default for Session {
tasks: vec![],
job_log: vec![],
next_task_id: 1,
learn: None,
}
}
}
@ -433,6 +445,7 @@ impl Session {
}
}
let r = (spec.run)(self, &params)?;
self.learn_observe(id, &params);
if spec.journal {
self.journal.push((id.to_string(), params));
if self.journal.len() > 10_000 {
@ -484,15 +497,13 @@ impl Session {
}
let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key;
if !same {
self.history.undo.push((label.to_string(), before));
// Undone steps aren't lost: they stay in the History panel as a branch.
let levels = self.prefs.general.undo_levels.max(1) as usize;
if self.history.undo.len() > levels {
let extra = self.history.undo.len() - levels;
self.history.undo.drain(..extra);
}
self.history.record(label, before, levels);
} else {
self.history.abandon_redo(&before);
}
self.history.merge_key = merge.map(str::to_string);
self.history.redo.clear();
self.project = Arc::new(p);
self.state = st;
self.bump();
@ -776,6 +787,8 @@ mod tests_effects;
#[cfg(test)]
mod tests_essential;
#[cfg(test)]
mod tests_face;
#[cfg(test)]
mod tests_fidelity;
#[cfg(test)]
mod tests_lottie;
@ -826,6 +839,11 @@ pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
}
/// The OpenType feature tags (GSUB / GPOS) of the face a family + style resolves to.
pub fn font_features(family: &str, style: &str) -> Vec<String> {
effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features()
}
/// One row of the Character panel's font menu.
#[derive(Clone, Debug, PartialEq)]
pub struct FontRow {
@ -888,6 +906,8 @@ pub fn font_menu(prefs: &prefs::Prefs) -> Vec<FontRow> {
out
}
#[cfg(test)]
mod tests_history;
#[cfg(test)]
mod tests_paint;
#[cfg(test)]
mod tests_panels;

View File

@ -6,6 +6,12 @@
//! algorithm is `effectcraft_track::mask` (KLT features inside the mask + a RANSAC fit of the
//! chosen motion model), and every frame's motion is applied to the mask's vertices and
//! tangents, so the shape's Bezier structure is kept.
//!
//! The two **Face Tracking** methods use `effectcraft_track::face` instead: the mask (drawn
//! around a face) seeds the tracker, and every frame's face outline becomes the Mask Path
//! (*Outline Only*); *Detailed Features* also keys the facial landmarks into a **Face Track
//! Points** effect on the layer (created on first use), from which Extract & Copy Face
//! Measurements (`track.extractFaceMeasurements`) derives a keyed **Face Measurements** effect.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@ -31,11 +37,22 @@ pub enum MaskMethod {
PositionScaleRotation,
PositionScaleRotationSkew,
Perspective,
/// Face Tracking (Outline Only).
FaceOutline,
/// Face Tracking (Detailed Features).
FaceDetailed,
}
impl MaskMethod {
pub const ALL: [MaskMethod; 5] =
[MaskMethod::Position, MaskMethod::PositionScale, MaskMethod::PositionScaleRotation, MaskMethod::PositionScaleRotationSkew, MaskMethod::Perspective];
pub const ALL: [MaskMethod; 7] = [
MaskMethod::Position,
MaskMethod::PositionScale,
MaskMethod::PositionScaleRotation,
MaskMethod::PositionScaleRotationSkew,
MaskMethod::Perspective,
MaskMethod::FaceOutline,
MaskMethod::FaceDetailed,
];
pub fn label(self) -> &'static str {
match self {
MaskMethod::Position => "Position",
@ -43,8 +60,14 @@ impl MaskMethod {
MaskMethod::PositionScaleRotation => "Position, Scale & Rotation",
MaskMethod::PositionScaleRotationSkew => "Position, Scale, Rotation & Skew",
MaskMethod::Perspective => "Perspective",
MaskMethod::FaceOutline => "Face Tracking (Outline Only)",
MaskMethod::FaceDetailed => "Face Tracking (Detailed Features)",
}
}
/// One of the Face Tracking methods.
pub fn is_face(self) -> bool {
matches!(self, MaskMethod::FaceOutline | MaskMethod::FaceDetailed)
}
pub fn id(self) -> &'static str {
match self {
MaskMethod::Position => "position",
@ -52,6 +75,8 @@ impl MaskMethod {
MaskMethod::PositionScaleRotation => "positionScaleRotation",
MaskMethod::PositionScaleRotationSkew => "positionScaleRotationSkew",
MaskMethod::Perspective => "perspective",
MaskMethod::FaceOutline => "faceOutline",
MaskMethod::FaceDetailed => "faceDetailed",
}
}
pub fn from_name(s: &str) -> Option<MaskMethod> {
@ -67,6 +92,7 @@ impl MaskMethod {
MaskMethod::PositionScaleRotation => Model::Similarity,
MaskMethod::PositionScaleRotationSkew => Model::Affine,
MaskMethod::Perspective => Model::Homography,
MaskMethod::FaceOutline | MaskMethod::FaceDetailed => Model::Similarity,
}
}
}
@ -109,8 +135,29 @@ pub fn transform_path(p: &ShapePath, h: &trk::Homography) -> ShapePath {
}
}
/// One tracked frame: (layer time, comp time, mask path).
type MaskFrame = (Tick, Tick, ShapePath);
/// A closed, smooth mask path through a face outline polygon: `n` vertices with Catmull-Rom
/// tangents. `like` lends its feather points.
pub fn outline_path(poly: &[[f64; 2]], n: usize, like: &ShapePath) -> ShapePath {
let m = poly.len();
let n = n.clamp(3, m.max(3));
let v: Vec<[f64; 2]> = (0..n).map(|i| poly[(i * m / n) % m.max(1)]).collect();
let tan: Vec<[f64; 2]> = (0..n)
.map(|i| {
let (a, b) = (v[(i + n - 1) % n], v[(i + 1) % n]);
[(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0]
})
.collect();
ShapePath {
vertices: v,
in_tangents: tan.iter().map(|t| [-t[0], -t[1]]).collect(),
out_tangents: tan,
closed: true,
feather: if like.len() == n { like.feather.clone() } else { vec![] },
}
}
/// One tracked frame: (layer time, comp time, mask path, face landmarks for Detailed Features).
type MaskFrame = (Tick, Tick, ShapePath, Option<[[f64; 2]; trk::face::N]>);
#[derive(Default)]
pub struct MaskTrackShared {
@ -169,9 +216,12 @@ fn run_work(w: MaskWork, shared: &MaskTrackShared) {
let frame_at = |t: Tick| source_frame(r, &w.project, w.comp, comp, layer, t, w.expr.as_deref());
lock(&shared.state).total = w.times.len().saturating_sub(1) as u64;
let Some(first) = frame_at(w.times[0]) else { return fail("the layer has no pixels to track") };
if w.method.is_face() {
return run_face(&w, shared, first, &frame_at, layer, t0);
}
let mut tracker = trk::mask::MaskTracker::new(w.method.model(), flatten(&w.path, 8), &trk::Frame { img: &first.0, offset: first.1 });
let mut path = w.path.clone();
lock(&shared.frames).push((layer.layer_time(w.times[0]), w.times[0], path.clone()));
lock(&shared.frames).push((layer.layer_time(w.times[0]), w.times[0], path.clone(), None));
let mut next = if w.times.len() > 1 { frame_at(w.times[1]) } else { None };
for k in 1..w.times.len() {
if shared.cancel.load(Ordering::Relaxed) {
@ -190,7 +240,7 @@ fn run_work(w: MaskWork, shared: &MaskTrackShared) {
break;
};
path = transform_path(&path, &step.motion);
lock(&shared.frames).push((layer.layer_time(w.times[k]), w.times[k], path.clone()));
lock(&shared.frames).push((layer.layer_time(w.times[k]), w.times[k], path.clone(), None));
let mut s = lock(&shared.state);
s.done = k as u64;
s.elapsed = t0.elapsed().as_secs_f64();
@ -203,12 +253,99 @@ fn run_work(w: MaskWork, shared: &MaskTrackShared) {
s.finished = true;
}
/// Face tracking: the face outline keys the mask; Detailed Features adds the landmarks.
fn run_face(
w: &MaskWork,
shared: &MaskTrackShared,
first: (Arc<effectcraft_raster::Image>, [f64; 2]),
frame_at: &(dyn Fn(Tick) -> Option<(Arc<effectcraft_raster::Image>, [f64; 2])> + Sync),
layer: &effectcraft_project::Layer,
t0: web_time::Instant,
) {
let detailed = w.method == MaskMethod::FaceDetailed;
let b = trk::mask::bounds(&flatten(&w.path, 8));
let Some((mut tracker, fit)) = trk::face::FaceTracker::new(&trk::Frame { img: &first.0, offset: first.1 }, b) else {
let mut s = lock(&shared.state);
s.error = Some("no face found inside the mask (draw the mask around a face)".into());
s.finished = true;
return;
};
let key = |fit: &trk::face::FaceFit| (outline_path(&fit.outline, 16, &w.path), detailed.then_some(fit.landmarks));
let (path, pts) = key(&fit);
lock(&shared.frames).push((layer.layer_time(w.times[0]), w.times[0], path, pts));
let mut next = if w.times.len() > 1 { frame_at(w.times[1]) } else { None };
for k in 1..w.times.len() {
if shared.cancel.load(Ordering::Relaxed) {
lock(&shared.state).cancelled = true;
break;
}
let Some(cur) = next.take() else {
lock(&shared.state).error = Some(format!("no frame at {:.3} s", w.times[k].seconds()));
break;
};
let (res, nf) =
rayon::join(|| tracker.step(&trk::Frame { img: &cur.0, offset: cur.1 }), || if k + 1 < w.times.len() { frame_at(w.times[k + 1]) } else { None });
next = nf;
let Some(fit) = res else {
lock(&shared.state).error = Some(format!("lost the face at {:.3} s", w.times[k].seconds()));
break;
};
let (path, pts) = key(&fit);
lock(&shared.frames).push((layer.layer_time(w.times[k]), w.times[k], path, pts));
let mut s = lock(&shared.state);
s.done = k as u64;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
s.time = w.times[k].seconds();
crate::offload::report(s.done, s.total);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
/// The layer's Face Track Points effect (created when missing). Returns its uid.
pub(crate) fn face_points_group(p: &mut Project, comp: ItemId, layer: LayerId) -> Option<Uid> {
face_effect(p, comp, layer, effectcraft_effects::face_track::POINTS_ID)
}
/// The layer's effect `id`, applied when missing (data effects of face tracking).
pub(crate) fn face_effect(p: &mut Project, comp: ItemId, layer: LayerId, id: &str) -> Option<Uid> {
let spec = effectcraft_effects::find(id)?;
let l = p.comp(comp)?.layer(layer)?;
if let Some(g) = l.effects().and_then(|fx| fx.groups().find(|g| g.match_id == spec.id)) {
return Some(g.uid);
}
let (w, h) = effectcraft_render::source_size(p, l);
let size = if w == 0 { p.comp(comp).map(|c| [c.width as f64, c.height as f64]).unwrap_or([1920.0, 1080.0]) } else { [w as f64, h as f64] };
let mut next = p.next_id;
let g = effectcraft_effects::instantiate(spec, &mut effectcraft_project::build::Ids(&mut next), spec.name, size);
let uid = g.uid;
let fx = p.comp_mut(comp)?.layer_mut(layer)?.props.sub_mut("effects")?;
fx.children.push(g.into());
p.next_id = next;
Some(uid)
}
fn write_frames(p: &mut Project, job: &MaskTrackJob, frames: &[MaskFrame]) {
let points = if frames.iter().any(|f| f.3.is_some()) { face_points_group(p, job.comp, job.layer) } else { None };
let Some(layer) = p.comp_mut(job.comp).and_then(|c| c.layer_mut(job.layer)) else { return };
let Some(g) = layer.props.find_group_mut(job.mask) else { return };
let Some(pr) = g.get_mut("path") else { return };
for (lt, _, path) in frames {
effectcraft_keyframe::set_key(&mut pr.keys, Keyframe::new(*lt, Value::Path(path.clone())));
if let Some(g) = layer.props.find_group_mut(job.mask)
&& let Some(pr) = g.get_mut("path")
{
for (lt, _, path, _) in frames {
effectcraft_keyframe::set_key(&mut pr.keys, Keyframe::new(*lt, Value::Path(path.clone())));
}
}
if let Some(g) = points.and_then(|u| layer.props.find_group_mut(u)) {
for (lt, _, _, pts) in frames {
let Some(pts) = pts else { continue };
for (i, (id, _, _)) in trk::face::LANDMARKS.iter().enumerate() {
if let Some(pr) = g.get_mut(id) {
effectcraft_keyframe::set_key(&mut pr.keys, Keyframe::new(*lt, Value::Vec2(pts[i])));
}
}
}
}
}
@ -242,14 +379,13 @@ impl Session {
return Err("a track analysis is already running".into());
}
self.poll_mask_track();
if !wait && self.offloads() {
if !wait && self.offloads() && !work.method.is_face() {
let MaskWork { comp, layer, path, method, times, .. } = work;
return self.offload_analysis(crate::offload::WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times });
}
let wait = wait || cfg!(target_arch = "wasm32");
self.history.undo.push(("Track Mask".into(), self.project.clone()));
self.history.redo.clear();
self.history.merge_key = None;
let levels = self.prefs.general.undo_levels as usize;
self.history.record("Track Mask", self.project.clone(), levels);
let shared = Arc::new(MaskTrackShared::default());
let (comp, layer) = (work.comp, work.layer);
let thread = if wait {

View File

@ -71,7 +71,7 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
/// The entries of a `@dynamic:<name>` node, and the disabled placeholder shown when there are
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest
/// first), `view3dShortcuts`, `workspaceShortcuts` and `openViewers` (open compositions other
/// than the active one).
/// than the active one), `scripts` (File ▸ Scripts) and `scriptPanels` (ScriptUI panels).
pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
use serde_json::json;
let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
@ -131,6 +131,19 @@ pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<
.collect(),
None,
),
// File ▸ Scripts: installed and sample scripts; the Window menu's ScriptUI panels.
"scripts" | "scriptPanels" => {
let panels = name == "scriptPanels";
let cmd = if panels { "window.scriptPanel" } else { "file.runScript" };
(
crate::commands::scripts::scripts(s)
.into_iter()
.filter(|e| e.panel == panels)
.map(|e| dyn_entry(e.name.clone(), cmd, json!({"name": e.name})))
.collect(),
None,
)
}
_ => (vec![], None),
}
}
@ -301,6 +314,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
"layer.mask.hideLocked" => Some(s.state.hide_locked_masks),
"view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)),
"view.shareViewOptions" => Some(s.state.share_view_options),
"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.simulateOutput" => {
@ -418,7 +432,7 @@ pub fn parse(text: &str, mac: bool) -> Result<Vec<MenuNode>, String> {
/// Effect ▸ category submenus from the effect registry.
fn effect_categories() -> Vec<MenuNode> {
let mut cats: Vec<(&str, Vec<MenuNode>)> = vec![];
for e in crate::effects::registry() {
for e in crate::effects::all() {
let entry =
MenuNode::Item(MenuEntry { label: e.name.into(), command: "effect.apply".into(), params: serde_json::json!({"effect": e.id}), shortcut: None });
match cats.iter_mut().find(|(c, _)| *c == e.category) {
@ -516,6 +530,10 @@ File
Find Missing Footage | file.findMissing {"what":"footage"}
---
Scripts
@dynamic:scripts
---
Install Script File... | file.installScript
Install ScriptUI Panel... | file.installScriptUIPanel
Run Script File... | file.runScript
---
Create Proxy
@ -907,6 +925,7 @@ Effect
Remove All | effect.removeAll
---
Manage Effects... | effect.manage
Load Effect Plug-in... | effect.plugins.load
---
@effects
Animation
@ -1145,6 +1164,8 @@ Window
Render Queue | window.panel {"panel":"renderQueue"} | Cmd+Alt+0
Timeline | window.panel {"panel":"timeline"}
@dynamic:openViewers
---
@dynamic:scriptPanels
Help
EffectCraft Help... | help.docs {"page":"help"} | F1
Scripting Help... | help.docs {"page":"scripting"}

View File

@ -6,7 +6,9 @@
//! so their blend modes act on the layers below, like Photoshop); type layers become editable
//! text layers; layer masks are baked into the layer's alpha and vector masks become masks;
//! layer effects map to Layer Styles; adjustment layers become adjustment layers with the
//! matching effect (best effort); clipped layers preserve the transparency underneath.
//! matching effect (best effort); clipped layers preserve the transparency underneath. Smart
//! objects whose file is embedded become footage of that file (an embedded Photoshop document's
//! merged image, or the embedded image) placed with the smart object's transform.
use std::sync::Arc;
@ -156,7 +158,7 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> (
missing: false,
sequence: vec![],
color_profile: None,
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty() }),
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None }),
..Default::default()
};
let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
@ -164,6 +166,60 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> (
(id, (w, h))
}
/// Size of an embedded file's image: the smart object's recorded size, else the embedded
/// document's header (Photoshop, PNG).
fn embedded_size(so: &effectcraft_psd::SmartObject, data: &[u8]) -> Option<(u32, u32)> {
if let Some([w, h]) = so.size.filter(|s| s[0] >= 1.0 && s[1] >= 1.0) {
return Some((w.round() as u32, h.round() as u32));
}
if effectcraft_psd::is_psd(data) && data.len() >= 22 {
let h = u32::from_be_bytes([data[14], data[15], data[16], data[17]]);
let w = u32::from_be_bytes([data[18], data[19], data[20], data[21]]);
return Some((w, h));
}
if data.starts_with(b"\x89PNG") && data.len() >= 24 {
let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
return Some((w, h));
}
None
}
/// A smart object layer as footage of its embedded file, placed by the smart object's
/// transform. `None` when the file is not embedded (linked externally) or unreadable.
fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usize, name: &str) -> Option<Layer> {
let pl = &cx.psd.layers[index];
let so = pl.smart_object.clone()?;
let data = cx.psd.linked_data(&so.uuid)?;
let (w, h) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?;
let file = cx.psd.linked.iter().find(|f| f.uuid == so.uuid).map(|f| f.name.clone()).unwrap_or_default();
let f = Footage {
path: cx.path.to_string(),
kind: FootageKind::Still,
width: w,
height: h,
pixel_aspect: 1.0,
frame_rate: cx.rate,
has_video: true,
alpha: AlphaMode::Straight,
loop_count: 1,
codec: if effectcraft_psd::is_psd(data) { "PSD".into() } else { "PSD (embedded image)".into() },
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()) }),
..Default::default()
};
let label = if file.is_empty() { name.to_string() } else { file };
let id = proj.add_item(&format!("{label} (Smart Object)"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
cx.items.push(id);
let mut l = build::layer(proj, comp, name, LayerSource::Footage { item: id }, (w, h), None);
let (c, sx, sy, rot) = so.placement(w as f64, h as f64);
set_vec2(&mut l, "transform/position", c);
set_vec2(&mut l, "transform/scale", [sx * 100.0, sy * 100.0]);
if rot.abs() > 1e-9 {
set_scalar(&mut l, "transform/rotation", rot);
}
Some(l)
}
fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Option<Layer> {
let (index, children) = match n {
Node::Layer(i) => (*i, None),
@ -183,6 +239,8 @@ fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Optio
let mut l = adjustment_layer(proj, comp, &name);
add_adjustment_effect(proj, &mut l, adj, comp, &mut cx.warnings, &name);
l
} else if let Some(l) = pl.smart_object.as_ref().and_then(|_| smart_object_layer(proj, cx, comp, index, &name)) {
l
} else if pl.has_pixels() || pl.fill.is_some() {
let (item, size) = footage_item(proj, cx, index, &name);
let mut l = build::layer(proj, comp, &name, LayerSource::Footage { item }, size, None);

View File

@ -27,6 +27,10 @@ pub struct ExportJob<'a> {
pub accel: Option<&'a dyn effectcraft_render::Accelerator>,
/// Resolved output path (templates expanded except the `#` frame-number run).
pub path: &'a str,
/// Free-space hook (Use Storage Overflow).
pub storage: Option<&'a dyn effectcraft_project::render_queue::StorageQuota>,
/// "#n Comp Name", for the render log.
pub label: String,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
pub nested_switches: bool,
}
@ -41,6 +45,12 @@ pub struct ExportResult {
pub bytes: u64,
pub seconds: f64,
pub audio: bool,
/// The render log written next to the output, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub log: Option<String>,
/// Files that went to storage overflow folders.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub overflow: Vec<String>,
}
/// Encodes render queue items (implemented by the export layer).
@ -129,6 +139,7 @@ struct Work {
expr: Option<Arc<dyn ExprHost>>,
accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
exporter: Arc<dyn Exporter>,
storage: Option<Arc<dyn effectcraft_project::render_queue::StorageQuota>>,
/// Per queue item: one (item with that output module, resolved path) per output module
/// (and per segment, see [`Session::segments`]).
items: Vec<Vec<(RenderQueueItem, String)>>,
@ -199,6 +210,7 @@ pub(crate) fn run_items(
expr: s.expr.clone(),
accel: s.accel.clone(),
exporter,
storage: s.storage_quota.clone(),
items,
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
@ -232,6 +244,8 @@ fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
// first module's file is the item's output.
let mut r: Result<ExportResult, String> = Err("no output module".into());
for (mi, (mitem, path)) in modules.iter().enumerate() {
let name = w.project.item(mitem.comp).map(|i| i.name.clone()).unwrap_or_default();
let index = w.project.render_queue.iter().position(|i| i.id == mitem.id).map_or(0, |i| i + 1);
let job = ExportJob {
project: &w.project,
footage: w.footage.as_ref(),
@ -239,6 +253,8 @@ fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
accel: w.accel.as_deref(),
item: mitem,
path,
storage: w.storage.as_deref(),
label: format!("#{index} {name} (output module {})", mi + 1),
nested_switches: w.nested_switches,
};
let rr = w.exporter.export(&job, &mut |done, total| {
@ -301,8 +317,7 @@ impl Session {
if tpl.is_empty() {
return None;
}
let (w, h) = item.settings.output_size(comp);
let (w, h) = item.output.format.coded_size(w, h);
let (w, h) = item.output.output_size(comp, &item.settings);
let rate = item.settings.rate(comp);
let n = item.settings.frame_count(comp) as i64;
let f0 = item.settings.first_frame(comp);
@ -453,6 +468,7 @@ impl Session {
expr: self.expr.clone(),
accel: self.accel.clone(),
exporter,
storage: self.storage_quota.clone(),
items,
nested_switches: self.prefs.general.switches_affect_nested_comps,
};
@ -552,6 +568,10 @@ impl Session {
} else {
format!("Rendered {} item(s) in {:.1} s", st.items_total, st.elapsed)
};
if self.project.render_prefs.notify {
// Notify: the frontend plays a sound and shows a system notification.
self.events.push(Event::Frontend { command: "renderQueue.notify".into(), params: serde_json::json!({"message": msg, "error": failed > 0}) });
}
self.events.push(Event::Toast { message: msg, error: failed > 0 });
}
for (id, path) in post {

View File

@ -0,0 +1,572 @@
//! ScriptUI: the windows, dialogs and dockable panels scripts build with After Effects'
//! ScriptUI object model (`new Window("dialog", "Title")`, `win.add("button", undefined, "OK")`,
//! `orientation`, `alignChildren`, `onClick`/`onChange`, `show()`, `close()`,
//! `layout.layout()`…).
//!
//! The scripting engine (`effectcraft-script`) owns the live JavaScript objects; after every
//! script step it publishes a serde description of each open window here ([`ScriptWindow`],
//! a [`Widget`] tree with computed [`Widget::bounds`]). Frontends draw that description (the egui
//! UI renders dialogs, palettes and dockable panels from it) and report what the user does as
//! [`ScriptUiEvent`]s through the `scriptui.*` commands, which hand them to the script's
//! handlers ([`ScriptUi::dispatch`]). Agents use the same commands: list the open script
//! windows, read their widget trees, click buttons and set values.
//!
//! [`layout`] is ScriptUI's automatic layout (orientation, alignChildren / alignment, spacing,
//! margins, preferredSize), so every frontend places widgets the same way.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{EngineError, Result, Session};
/// What kind of window a script made.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WindowKind {
/// Modal: the script waits in `show()` until it closes.
#[default]
Dialog,
/// Floating, non-modal.
Palette,
/// A non-modal document window.
Window,
/// A dockable panel (a script from the ScriptUI Panels folder, opened from the Window menu).
Panel,
}
/// ScriptUI control types (the `type` passed to `add()`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WidgetKind {
/// The window itself (the tree's root).
#[default]
Window,
Panel,
Group,
Button,
IconButton,
StaticText,
EditText,
Checkbox,
RadioButton,
Slider,
Scrollbar,
Progressbar,
DropDownList,
ListBox,
TabbedPanel,
Tab,
Image,
/// A type we don't draw (it still lays out as an empty box).
#[serde(other)]
Unknown,
}
impl WidgetKind {
pub fn is_container(self) -> bool {
matches!(self, WidgetKind::Window | WidgetKind::Panel | WidgetKind::Group | WidgetKind::TabbedPanel | WidgetKind::Tab)
}
}
/// One control (or container) of a script window.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Widget {
/// Unique within its window (the window itself is 0).
pub id: u32,
#[serde(rename = "type")]
pub kind: WidgetKind,
/// `properties.name` (agents can address controls by it).
pub name: String,
pub text: String,
/// Slider / scrollbar / progressbar value.
pub value: f64,
pub min: f64,
pub max: f64,
/// Checkbox / radio button state.
pub checked: bool,
/// Drop-down list / list box items.
pub items: Vec<String>,
/// Selected item indices.
pub selection: Vec<usize>,
/// `column` (default for windows and panels), `row` (groups) or `stack`.
pub orientation: String,
/// `[horizontal, vertical]`: left / center / right / fill, top / center / bottom / fill.
pub align_children: Vec<String>,
/// This control's own alignment in its parent (overrides the parent's alignChildren).
pub alignment: Vec<String>,
pub spacing: Option<f64>,
/// `[left, top, right, bottom]`.
pub margins: Option<[f64; 4]>,
pub preferred_size: Option<[f64; 2]>,
/// Bounds set by the script (`[left, top, right, bottom]` relative to the parent).
pub fixed_bounds: Option<[f64; 4]>,
/// Laid-out bounds `[x, y, width, height]` relative to the window's content.
pub bounds: [f64; 4],
pub enabled: bool,
pub visible: bool,
pub help_tip: String,
pub multiline: bool,
pub read_only: bool,
/// Event handlers the script attached (`onClick`, `onChange`, `onChanging`…).
pub handlers: Vec<String>,
/// The active tab of a tabbed panel (child index).
pub active_tab: usize,
pub children: Vec<Widget>,
}
impl Widget {
pub fn find(&self, id: u32) -> Option<&Widget> {
if self.id == id {
return Some(self);
}
self.children.iter().find_map(|c| c.find(id))
}
pub fn find_mut(&mut self, id: u32) -> Option<&mut Widget> {
if self.id == id {
return Some(self);
}
self.children.iter_mut().find_map(|c| c.find_mut(id))
}
/// Every control, depth first.
pub fn walk<'a>(&'a self, out: &mut Vec<&'a Widget>) {
out.push(self);
for c in &self.children {
c.walk(out);
}
}
/// A control by id (number or `"#12"`), `properties.name` or text.
pub fn lookup(&self, key: &Value) -> Option<&Widget> {
let mut all = vec![];
self.walk(&mut all);
match key {
Value::Number(n) => n.as_u64().and_then(|n| self.find(n as u32)),
Value::String(s) => {
if let Some(n) = s.strip_prefix('#').and_then(|n| n.parse::<u32>().ok()) {
return self.find(n);
}
all.iter().find(|w| !w.name.is_empty() && w.name == *s).or_else(|| all.iter().find(|w| w.id != 0 && w.text == *s)).copied()
}
_ => None,
}
}
}
/// An open script window.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ScriptWindow {
/// Unique in the process.
pub id: u32,
/// The script context that owns it (its handlers run there).
pub host: u32,
pub kind: WindowKind,
pub title: String,
/// The script that made it.
pub script: String,
/// Shown and not closed.
pub visible: bool,
/// The script is waiting in this dialog's `show()`.
pub modal: bool,
/// The window and its controls (`root.bounds` is the content size).
pub root: Widget,
}
/// A user (or agent) action on a script window control.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScriptUiEvent {
pub window: u32,
pub widget: u32,
/// `click`, `change`, `changing` or `close`.
pub kind: String,
/// The new value: text, number, bool, or selected index(es); for `close`, the result.
pub value: Value,
}
/// Hands an event to the script that owns the window; returns what the handler reported
/// (`{ok, output, error}`) and updates [`Session::script_ui`].
pub type ScriptUiDispatch = fn(&mut Session, &ScriptUiEvent) -> std::result::Result<Value, String>;
/// The open script windows.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ScriptUi {
pub windows: Vec<ScriptWindow>,
/// Bumped whenever a window changes.
pub revision: u64,
/// The scripting engine's event hand-off (set with [`Session::script`]).
#[serde(skip)]
pub dispatch: Option<ScriptUiDispatch>,
}
impl ScriptUi {
pub fn window(&self, id: u32) -> Option<&ScriptWindow> {
self.windows.iter().find(|w| w.id == id)
}
/// Replace the windows a script host published (laying them out).
pub fn publish(&mut self, host: u32, mut windows: Vec<ScriptWindow>) {
for w in &mut windows {
w.host = host;
layout(w);
}
windows.retain(|w| w.visible);
let mut out: Vec<ScriptWindow> = vec![];
// Keep the order windows first appeared in.
for old in self.windows.drain(..) {
if old.host != host {
out.push(old);
} else if let Some(i) = windows.iter().position(|w| w.id == old.id) {
out.push(windows.remove(i));
}
}
out.extend(windows);
self.windows = out;
self.revision += 1;
}
}
// ---------------------------------------------------------------- layout
/// Average character width and line height of the default dialog font (points).
const CHAR_W: f64 = 7.0;
const LINE_H: f64 = 16.0;
fn text_w(s: &str) -> f64 {
s.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * CHAR_W
}
fn lines(s: &str) -> f64 {
s.lines().count().max(1) as f64
}
fn orientation(w: &Widget) -> &str {
match w.orientation.as_str() {
"" => match w.kind {
WidgetKind::Group => "row",
WidgetKind::TabbedPanel => "stack",
_ => "column",
},
o => o,
}
}
fn margins(w: &Widget) -> [f64; 4] {
w.margins.unwrap_or(match w.kind {
WidgetKind::Window => [15.0; 4],
WidgetKind::Panel => [10.0, 15.0, 10.0, 10.0],
WidgetKind::TabbedPanel => [0.0, 24.0, 0.0, 0.0],
WidgetKind::Tab => [10.0; 4],
_ => [0.0; 4],
})
}
fn spacing(w: &Widget) -> f64 {
w.spacing.unwrap_or(10.0)
}
fn align_children(w: &Widget) -> (String, String) {
let d = match w.kind {
WidgetKind::Group => ("center", "center"),
_ => ("center", "top"),
};
let a = |i: usize, d: &str| w.align_children.get(i).filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| d.to_string());
// A single value applies to the axis it names.
if w.align_children.len() == 1 {
let v = w.align_children[0].clone();
return match v.as_str() {
"top" | "bottom" => (d.0.into(), v),
"fill" if orientation(w) == "row" => (d.0.into(), v),
_ => (v, d.1.into()),
};
}
(a(0, d.0), a(1, d.1))
}
/// Preferred size of a control (its own content; containers include their children).
pub fn preferred(w: &Widget) -> [f64; 2] {
let ps = w.preferred_size.unwrap_or([0.0; 2]);
let content = if w.kind.is_container() {
let kids: Vec<[f64; 2]> = w.children.iter().filter(|c| c.visible).map(preferred).collect();
let m = margins(w);
let sp = spacing(w);
let n = kids.len() as f64;
let (cw, ch) = match orientation(w) {
"row" => (kids.iter().map(|k| k[0]).sum::<f64>() + sp * (n - 1.0).max(0.0), kids.iter().map(|k| k[1]).fold(0.0, f64::max)),
"stack" => (kids.iter().map(|k| k[0]).fold(0.0, f64::max), kids.iter().map(|k| k[1]).fold(0.0, f64::max)),
_ => (kids.iter().map(|k| k[0]).fold(0.0, f64::max), kids.iter().map(|k| k[1]).sum::<f64>() + sp * (n - 1.0).max(0.0)),
};
let title_w = if matches!(w.kind, WidgetKind::Panel) { text_w(&w.text) + 20.0 } else { 0.0 };
[(cw + m[0] + m[2]).max(title_w), ch + m[1] + m[3]]
} else {
match w.kind {
WidgetKind::Button | WidgetKind::IconButton => [(text_w(&w.text) + 24.0).max(80.0), 25.0],
WidgetKind::StaticText => [text_w(&w.text) + 4.0, LINE_H * lines(&w.text)],
WidgetKind::EditText => {
if w.multiline {
[(text_w(&w.text) + 12.0).max(160.0), (LINE_H * lines(&w.text) + 8.0).max(60.0)]
} else {
[(text_w(&w.text) + 12.0).max(40.0), 22.0]
}
}
WidgetKind::Checkbox | WidgetKind::RadioButton => [text_w(&w.text) + 24.0, 18.0],
WidgetKind::Slider | WidgetKind::Scrollbar => [100.0, 22.0],
WidgetKind::Progressbar => [100.0, 10.0],
WidgetKind::DropDownList => [w.items.iter().map(|i| text_w(i)).fold(0.0, f64::max) + 36.0, 22.0],
WidgetKind::ListBox => [(w.items.iter().map(|i| text_w(i)).fold(0.0, f64::max) + 24.0).max(80.0), (w.items.len().clamp(3, 10) as f64 * 18.0 + 4.0)],
_ => [20.0, 20.0],
}
};
[if ps[0] > 0.0 { ps[0] } else { content[0] }, if ps[1] > 0.0 { ps[1] } else { content[1] }]
}
fn place(w: &mut Widget, x: f64, y: f64, width: f64, height: f64) {
w.bounds = [x, y, width, height];
if !w.kind.is_container() {
return;
}
let m = margins(w);
let sp = spacing(w);
let (ah, av) = align_children(w);
let inner = [m[0], m[1], (width - m[0] - m[2]).max(0.0), (height - m[1] - m[3]).max(0.0)];
let orient = orientation(w).to_string();
let mut cursor = 0.0;
for c in w.children.iter_mut() {
if !c.visible {
c.bounds = [0.0; 4];
continue;
}
if let Some(b) = c.fixed_bounds {
let (cx, cy, cw, ch) = (x + b[0], y + b[1], b[2] - b[0], b[3] - b[1]);
place(c, cx, cy, cw, ch);
continue;
}
let pref = preferred(c);
let h_align = c.alignment.first().filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| ah.clone());
let v_align = c.alignment.get(1).filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| av.clone());
let along = |a: &str, free: f64| match a {
"center" => free / 2.0,
"right" | "bottom" => free,
_ => 0.0,
};
let (cx, cy, cw, ch) = match orient.as_str() {
"row" => {
let ch = if v_align == "fill" { inner[3] } else { pref[1] };
let cy = inner[1] + along(&v_align, inner[3] - ch);
let r = (inner[0] + cursor, cy, pref[0], ch);
cursor += pref[0] + sp;
r
}
"stack" => {
let cw = if h_align == "fill" { inner[2] } else { pref[0] };
let ch = if v_align == "fill" { inner[3] } else { pref[1] };
(inner[0] + along(&h_align, inner[2] - cw), inner[1] + along(&v_align, inner[3] - ch), cw, ch)
}
_ => {
let cw = if h_align == "fill" { inner[2] } else { pref[0] };
let cx = inner[0] + along(&h_align, inner[2] - cw);
let r = (cx, inner[1] + cursor, cw, pref[1]);
cursor += pref[1] + sp;
r
}
};
place(c, x + cx, y + cy, cw, ch);
}
// Rows centre their content run as a whole when it is narrower than the row.
if orient == "row" && (ah == "center" || ah == "right") {
let used = (cursor - sp).max(0.0);
let shift = along_shift(&ah, inner[2] - used);
if shift > 0.0 {
for c in w.children.iter_mut().filter(|c| c.visible && c.fixed_bounds.is_none()) {
shift_all(c, shift, 0.0);
}
}
}
// Columns aligned to the bottom / centre move the whole run.
if orient == "column" && (av == "center" || av == "bottom") {
let used = (cursor - sp).max(0.0);
let shift = along_shift(&av, inner[3] - used);
if shift > 0.0 {
for c in w.children.iter_mut().filter(|c| c.visible && c.fixed_bounds.is_none()) {
shift_all(c, 0.0, shift);
}
}
}
}
fn along_shift(a: &str, free: f64) -> f64 {
match a {
"center" => (free / 2.0).max(0.0),
"right" | "bottom" => free.max(0.0),
_ => 0.0,
}
}
fn shift_all(w: &mut Widget, dx: f64, dy: f64) {
w.bounds[0] += dx;
w.bounds[1] += dy;
for c in &mut w.children {
shift_all(c, dx, dy);
}
}
/// ScriptUI automatic layout: size the window to its preferred size (or the size the script
/// gave it) and place every control (`bounds` relative to the window's top-left).
pub fn layout(w: &mut ScriptWindow) {
let pref = preferred(&w.root);
let (width, height) = match w.root.fixed_bounds {
Some(b) if b[2] > b[0] && b[3] > b[1] => (b[2] - b[0], b[3] - b[1]),
_ => (pref[0], pref[1]),
};
place(&mut w.root, 0.0, 0.0, width, height);
}
// ---------------------------------------------------------------- commands
fn window_p(s: &Session, p: &Value, cmd: &str) -> Result<u32> {
let key = p.get("window");
let w = match key {
Some(Value::Number(n)) => n.as_u64().map(|n| n as u32).filter(|n| s.script_ui.window(*n).is_some()),
Some(Value::String(t)) => s.script_ui.windows.iter().find(|w| w.title == *t).map(|w| w.id),
None if s.script_ui.windows.len() == 1 => s.script_ui.windows.first().map(|w| w.id),
_ => None,
};
w.ok_or_else(|| crate::commands::bad(cmd, "no such script window (give `window`: id or title from scriptui.list)"))
}
fn widget_p(s: &Session, win: u32, p: &Value, cmd: &str) -> Result<u32> {
let w = s.script_ui.window(win).ok_or_else(|| crate::commands::bad(cmd, "window closed"))?;
let key = p.get("widget").ok_or_else(|| crate::commands::bad(cmd, "missing `widget` (id, `#id`, properties.name or text)"))?;
w.root.lookup(key).map(|w| w.id).ok_or_else(|| crate::commands::bad(cmd, format!("no control {key} in window {win}")))
}
fn dispatch(s: &mut Session, ev: ScriptUiEvent) -> Result<Value> {
let f = s.script_ui.dispatch.ok_or_else(|| EngineError::Other("scripting is not available in this build".into()))?;
f(s, &ev).map_err(EngineError::Other)
}
/// `scriptui.list`: the open script windows.
pub(crate) fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(
s.script_ui
.windows
.iter()
.map(|w| json!({"window": w.id, "title": w.title, "kind": w.kind, "script": w.script, "modal": w.modal, "size": [w.root.bounds[2], w.root.bounds[3]]}))
.collect::<Vec<_>>()
))
}
/// `scriptui.get`: one window's control tree.
pub(crate) fn get(s: &mut Session, p: &Value) -> Result<Value> {
let id = window_p(s, p, "scriptui.get")?;
Ok(serde_json::to_value(s.script_ui.window(id)).unwrap_or(Value::Null))
}
/// `scriptui.click`: press a button / toggle a checkbox / pick a radio button.
pub(crate) fn click(s: &mut Session, p: &Value) -> Result<Value> {
let c = "scriptui.click";
let window = window_p(s, p, c)?;
let widget = widget_p(s, window, p, c)?;
let w = s.script_ui.window(window).and_then(|w| w.root.find(widget)).cloned().unwrap_or_default();
if !w.enabled {
return Err(crate::commands::bad(c, format!("control {widget} is disabled")));
}
dispatch(s, ScriptUiEvent { window, widget, kind: "click".into(), value: Value::Null })
}
/// `scriptui.set`: set a control's value (edit text, slider, checkbox, list selection) as the
/// user would, firing onChanging / onChange.
pub(crate) fn set(s: &mut Session, p: &Value) -> Result<Value> {
let c = "scriptui.set";
let window = window_p(s, p, c)?;
let widget = widget_p(s, window, p, c)?;
let w = s.script_ui.window(window).and_then(|w| w.root.find(widget)).cloned().unwrap_or_default();
if !w.enabled {
return Err(crate::commands::bad(c, format!("control {widget} is disabled")));
}
let mut value = p.get("value").cloned().ok_or_else(|| crate::commands::bad(c, "missing `value`"))?;
// List selections by item text.
if matches!(w.kind, WidgetKind::DropDownList | WidgetKind::ListBox)
&& let Value::String(t) = &value
{
let i = w.items.iter().position(|it| it == t).ok_or_else(|| crate::commands::bad(c, format!("no item `{t}` (items: {:?})", w.items)))?;
value = json!(i);
}
dispatch(s, ScriptUiEvent { window, widget, kind: "change".into(), value })
}
/// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel).
pub(crate) fn close(s: &mut Session, p: &Value) -> Result<Value> {
let window = window_p(s, p, "scriptui.close")?;
let result = p.get("result").cloned().unwrap_or(json!(2));
dispatch(s, ScriptUiEvent { window, widget: 0, kind: "close".into(), value: result })
}
#[cfg(test)]
mod tests {
use super::*;
fn w(kind: WidgetKind, text: &str) -> Widget {
Widget { kind, text: text.into(), enabled: true, visible: true, ..Default::default() }
}
#[test]
fn column_and_row_layout() {
let mut row = w(WidgetKind::Group, "");
row.id = 1;
let mut ok = w(WidgetKind::Button, "OK");
ok.id = 2;
let mut cancel = w(WidgetKind::Button, "Cancel");
cancel.id = 3;
row.children = vec![ok, cancel];
let mut label = w(WidgetKind::StaticText, "Name:");
label.id = 4;
let mut root = w(WidgetKind::Window, "Dialog");
root.children = vec![label, row];
let mut win = ScriptWindow { id: 1, root, visible: true, ..Default::default() };
layout(&mut win);
let r = &win.root;
// Window: 15 px margins, children stacked with 10 px spacing.
let row = r.find(1).unwrap();
assert_eq!(row.bounds[2], 170.0, "two 80 px buttons + 10 spacing");
assert_eq!(r.bounds[2], 200.0);
assert_eq!(r.find(4).unwrap().bounds[1], 15.0);
assert_eq!(row.bounds[1], 15.0 + 16.0 + 10.0);
// Centred (the default alignChildren of windows).
assert_eq!(row.bounds[0], 15.0);
let label = r.find(4).unwrap();
assert!((label.bounds[0] + label.bounds[2] / 2.0 - 100.0).abs() < 1e-9);
assert_eq!(r.find(3).unwrap().bounds[0], 15.0 + 80.0 + 10.0);
// alignChildren left / fill.
win.root.align_children = vec!["fill".into(), "top".into()];
layout(&mut win);
assert_eq!(win.root.find(4).unwrap().bounds[2], 170.0);
win.root.align_children = vec!["left".into()];
layout(&mut win);
assert_eq!(win.root.find(4).unwrap().bounds[0], 15.0);
// preferredSize wins.
win.root.find_mut(2).unwrap().preferred_size = Some([120.0, 30.0]);
layout(&mut win);
assert_eq!(win.root.find(2).unwrap().bounds[2..], [120.0, 30.0]);
// Lookup by name / text / id.
win.root.find_mut(2).unwrap().name = "ok".into();
assert_eq!(win.root.lookup(&json!("ok")).unwrap().id, 2);
assert_eq!(win.root.lookup(&json!("Cancel")).unwrap().id, 3);
assert_eq!(win.root.lookup(&json!("#4")).unwrap().id, 4);
assert_eq!(win.root.lookup(&json!(1)).unwrap().id, 1);
}
#[test]
fn publish_replaces_a_hosts_windows() {
let mut ui = ScriptUi::default();
let win = |id: u32, visible: bool| ScriptWindow { id, visible, root: w(WidgetKind::Window, ""), ..Default::default() };
ui.publish(7, vec![win(1, true), win(2, true)]);
ui.publish(8, vec![win(3, true)]);
assert_eq!(ui.windows.iter().map(|w| (w.id, w.host)).collect::<Vec<_>>(), [(1, 7), (2, 7), (3, 8)]);
ui.publish(7, vec![win(2, true), win(1, false)]);
assert_eq!(ui.windows.iter().map(|w| w.id).collect::<Vec<_>>(), [2, 3]);
let j = serde_json::to_value(&ui).unwrap();
assert_eq!(j["windows"][0]["root"]["type"], "window");
}
}

View File

@ -36,10 +36,8 @@ impl Session {
}
// Fewer undo levels apply right away.
let levels = self.prefs.general.undo_levels.max(1) as usize;
if self.history.undo.len() > levels {
let extra = self.history.undo.len() - levels;
self.history.undo.drain(..extra);
}
let current = self.project.clone();
self.history.trim(levels, &current);
self.prefs_revision += 1;
}

View File

@ -23,10 +23,8 @@ fn hevc_and_av1_output_module_options() {
assert!(a["outputPath"].as_str().unwrap().ends_with(".mp4"), "{a}");
let om = &s.project.render_queue[0].output;
assert_eq!(om.format, OutputFormat::Hevc);
assert_eq!(
om.codec,
VideoCodecOptions { profile: CodecProfile::Main10, level: Some(41), rate_control: RateControlMode::Quality, quality: 85, keyframe_interval: 30 }
);
assert_eq!(om.codec, VideoCodecOptions { profile: CodecProfile::Main10, level: Some(41), rate_control: RateControlMode::Quality, quality: 85 });
assert_eq!(om.keyframe_interval, 30);
assert!(a["outputModuleSummary"].as_str().unwrap_or_default().contains("Main 10"), "{a}");
let id = a["item"].clone();
s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "av1", "level": null, "rateControl": "bitrate", "bitrate": 3000})).unwrap();

View File

@ -0,0 +1,106 @@
//! Face tracking end to end through the commands, on synthetic face footage generated in-test
//! (a moving, rotating face that opens its mouth and blinks).
use effectcraft_keyframe::{ShapePath, Value as KV};
use effectcraft_track::face::{FaceParams, LANDMARKS, MEASUREMENTS, render_synthetic, synth_landmarks};
use serde_json::json;
use crate::mask_track::MaskMethod;
use crate::tests_track::{H, W, frame_time, setup};
fn face(f: u32) -> FaceParams {
let t = f as f64;
FaceParams {
center: [160.0 + 25.0 * (t * 0.2).sin(), 120.0 + 8.0 * (t * 0.15).cos()],
height: 70.0 * (1.0 + 0.05 * (t * 0.1).sin()),
roll: 12.0 * (t * 0.18).sin(),
yaw: 10.0 * (t * 0.12).sin(),
mouth_open: 0.5 + 0.45 * (t * 0.3).sin(),
eye_open: [0.85, 0.85],
..Default::default()
}
}
#[test]
fn face_tracking_keys_outline_points_and_measurements() {
let (mut s, clip, _) = setup(|f| render_synthetic(W, H, &face(f), 5));
assert_eq!(MaskMethod::from_name("faceDetailed"), Some(MaskMethod::FaceDetailed));
assert_eq!(MaskMethod::from_name("Face Tracking (Outline Only)"), Some(MaskMethod::FaceOutline));
// A rough mask around the face.
let c = face(0).center;
let r = s.execute("mask.new", json!({"layer": clip.0, "vertices": ShapePath::rect(c, 120.0, 160.0).vertices, "closed": true})).unwrap();
let mask = r["mask"].as_u64().unwrap();
s.execute("time.set", json!({"time": 0})).unwrap();
let r = s.execute_checked("track.mask", json!({"layer": clip.0, "mask": mask, "method": "faceDetailed", "direction": "forward", "wait": true})).unwrap();
assert_eq!(r["frames"], 24, "{r}");
assert_eq!(s.state.mask_track_method, MaskMethod::FaceDetailed);
let comp = s.active_comp().unwrap();
let layer = comp.layer(clip).unwrap();
// The mask follows the face outline.
let path = layer.props.find_group(mask).unwrap().get("path").unwrap();
assert_eq!(path.keys.len(), 25);
for f in [0, 12, 24] {
let KV::Path(p) = path.value_at(frame_time(f)) else { panic!() };
let fp = face(f);
let (sn, cs) = fp.roll.to_radians().sin_cos();
for v in &p.vertices {
let d = [(v[0] - fp.center[0]) / fp.height, (v[1] - fp.center[1]) / fp.height];
let (u, w) = (cs * d[0] + sn * d[1], -sn * d[0] + cs * d[1]);
let r = ((u / 0.75).powi(2) + w * w).sqrt();
assert!((r - 1.0).abs() < 0.06, "frame {f}: vertex {v:?} at {r}");
}
}
// Face Track Points: one keyed point per landmark, on the face.
let fx = layer.effects().unwrap();
let pts = fx.groups().find(|g| g.match_id == effectcraft_effects::face_track::POINTS_ID).expect("Face Track Points");
let mut worst: f64 = 0.0;
for f in [0, 6, 12, 18, 24] {
let truth = synth_landmarks(&face(f));
for (i, (id, _, _)) in LANDMARKS.iter().enumerate() {
let pr = pts.get(id).unwrap();
assert_eq!(pr.keys.len(), 25, "{id}");
let KV::Vec2(v) = pr.value_at(frame_time(f)) else { panic!() };
worst = worst.max((v[0] - truth[i][0]).hypot(v[1] - truth[i][1]) / face(f).height);
}
}
assert!(worst < 0.1, "worst landmark error {worst} face heights");
// Extract & Copy Face Measurements.
assert!(s.is_enabled("track.extractFaceMeasurements"));
let r = s.execute_checked("track.extractFaceMeasurements", json!({"layer": clip.0})).unwrap();
assert_eq!(r["frames"], 25);
assert!(r["clipboard"].as_str().unwrap().contains("Mouth Openness"));
assert_eq!(s.state.key_clipboard.len(), MEASUREMENTS.len());
assert!(s.state.clip_is_keys);
let layer = s.active_comp().unwrap().layer(clip).unwrap();
let m = layer.effects().unwrap().groups().find(|g| g.match_id == effectcraft_effects::face_track::MEASUREMENTS_ID).expect("Face Measurements");
let mo = m.get("mouthOpenness").unwrap();
assert_eq!(mo.keys.len(), 25);
// Mouth openness follows the footage: widest open where the generator opens it most.
let open = |f: u32| mo.value_at(frame_time(f)).as_f64();
let (fmax, fmin) = (
(0..25).max_by(|a, b| face(*a).mouth_open.total_cmp(&face(*b).mouth_open)).unwrap(),
(0..25).min_by(|a, b| face(*a).mouth_open.total_cmp(&face(*b).mouth_open)).unwrap(),
);
assert!(open(fmax) > open(fmin) + 20.0, "{} vs {}", open(fmax), open(fmin));
let roll = m.get("headOrientationZ").unwrap();
for f in [0, 10, 20] {
assert!((roll.value_at(frame_time(f)).as_f64() - face(f).roll).abs() < 3.0);
}
// One undo step for the extraction.
assert!(s.undo());
assert!(!s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().any(|g| g.match_id == effectcraft_effects::face_track::MEASUREMENTS_ID));
// Outline Only keys just the mask.
let r = s.execute("mask.new", json!({"layer": clip.0, "vertices": ShapePath::rect(c, 120.0, 160.0).vertices, "closed": true})).unwrap();
let m2 = r["mask"].as_u64().unwrap();
s.execute("time.set", json!({"time": 0})).unwrap();
s.execute_checked("track.mask", json!({"layer": clip.0, "mask": m2, "method": "faceOutline", "direction": "frameForward", "wait": true})).unwrap();
let layer = s.active_comp().unwrap().layer(clip).unwrap();
assert_eq!(layer.props.find_group(m2).unwrap().get("path").unwrap().keys.len(), 2);
// A mask without a face reports it.
let r = s.execute("mask.new", json!({"layer": clip.0, "vertices": ShapePath::rect([20.0, 20.0], 30.0, 30.0).vertices, "closed": true})).unwrap();
let m3 = r["mask"].as_u64().unwrap();
s.execute("time.set", json!({"time": 0})).unwrap();
let _ = s.execute("track.mask", json!({"layer": clip.0, "mask": m3, "method": "faceOutline", "direction": "forward", "wait": true}));
let layer = s.active_comp().unwrap().layer(clip).unwrap();
assert!(layer.props.find_group(m3).unwrap().get("path").unwrap().keys.is_empty());
}

View File

@ -0,0 +1,135 @@
//! Branching history: undo then a new step keeps the undone line as a branch; the tree lists
//! every state; jumping to any state (on any branch) restores it and keeps everything else.
use serde_json::{Value, json};
use crate::Session;
fn names(s: &Session) -> Vec<String> {
s.project.comps().next().map(|(_, c)| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default()
}
fn solid(s: &mut Session, name: &str) {
s.execute("layer.newSolid", json!({"name": name, "color": "#ff0000", "width": 10, "height": 10})).unwrap();
}
fn list(s: &mut Session) -> Vec<Value> {
s.execute("edit.history.list", json!({})).unwrap()["states"].as_array().cloned().unwrap()
}
fn index_of(states: &[Value], label: &str, nth: usize) -> u64 {
states.iter().filter(|n| n["label"] == label).nth(nth).unwrap_or_else(|| panic!("no state {label} in {states:?}"))["index"].as_u64().unwrap()
}
#[test]
fn undo_then_new_step_keeps_a_branch() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap();
solid(&mut s, "A");
solid(&mut s, "B");
solid(&mut s, "C");
// Linear so far: Original, New Composition, 3 solids.
let st = list(&mut s);
assert_eq!(st.len(), 5, "{st:?}");
assert!(st.iter().all(|n| n["depth"] == 0));
assert_eq!(st.last().unwrap()["current"], true);
// Undo twice (B and C undone), then a new step: AE would forget B and C.
assert!(s.undo() && s.undo());
let st = list(&mut s);
assert_eq!(st.iter().filter(|n| n["future"] == true).count(), 2);
solid(&mut s, "D");
assert!(s.history.redo.is_empty(), "the new step clears Redo, as in After Effects");
assert_eq!(s.history.branches.len(), 1);
assert_eq!(names(&s), ["D", "A"]);
let st = list(&mut s);
assert_eq!(st.len(), 6, "the undone states are still listed: {st:?}");
let branch: Vec<&Value> = st.iter().filter(|n| n["depth"] == 1).collect();
assert_eq!(branch.len(), 2, "B and C sit on a branch: {st:?}");
// Jump to C on the old branch.
let c = st.iter().rfind(|n| n["depth"] == 1).unwrap()["index"].as_u64().unwrap();
let r = s.execute("edit.history.goto", json!({"index": c})).unwrap();
assert_eq!(r["label"], "New Solid", "{r}");
assert_eq!(names(&s), ["C", "B", "A"]);
// Now D is the branch, and Undo walks the B/C line.
assert_eq!(s.history.branches.len(), 1);
assert_eq!(s.history.branches[0].steps.len(), 1);
assert!(s.undo());
assert_eq!(names(&s), ["B", "A"]);
assert!(s.redo());
// ...and back to D by id.
let st = list(&mut s);
let d = st.iter().find(|n| n["depth"] == 1).unwrap()["id"].as_str().unwrap().to_string();
s.execute("edit.history.goto", json!({"id": d})).unwrap();
assert_eq!(names(&s), ["D", "A"]);
let st = list(&mut s);
assert_eq!(st.len(), 6);
assert_eq!(st.iter().filter(|n| n["current"] == true).count(), 1);
// The original state.
let root = index_of(&st, "Original", 0);
s.execute("edit.history.goto", json!({"index": root})).unwrap();
assert!(s.project.comps().next().is_none());
assert!(s.history.undo.is_empty());
assert_eq!(list(&mut s).len(), 6, "nothing is lost by jumping");
// Relative steps along the line.
s.execute("edit.history.goto", json!({"steps": 2})).unwrap();
assert_eq!(names(&s), ["A"]);
assert!(s.execute("edit.history.goto", json!({"id": "nope"})).is_err());
assert!(s.execute("edit.history.goto", json!({})).is_err());
}
#[test]
fn branches_of_branches_and_purge() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap();
solid(&mut s, "A");
solid(&mut s, "B");
s.undo();
solid(&mut s, "C"); // branch 1: B
s.undo();
solid(&mut s, "D"); // branch 2: C
let st = list(&mut s);
assert_eq!(st.len(), 6, "{st:?}");
// Go to B, undo to A, make E: D's line becomes a branch, and a branch of the new line.
let b = st.iter().find(|n| n["depth"] == 1 && n["label"] == "New Solid").unwrap()["index"].as_u64().unwrap();
s.execute("edit.history.goto", json!({"index": b})).unwrap();
let which = names(&s);
assert!(which == ["B", "A"] || which == ["C", "A"], "{which:?}");
s.undo();
solid(&mut s, "E");
let st = list(&mut s);
assert_eq!(st.len(), 7, "{st:?}");
// Every state is reachable and restores its own layers.
let mut seen = std::collections::BTreeSet::new();
for i in 0..st.len() {
s.execute("edit.history.goto", json!({"index": i})).unwrap();
seen.insert(names(&s).join(","));
assert_eq!(list(&mut s).len(), 7);
}
for want in ["", "A", "B,A", "C,A", "D,A", "E,A"] {
assert!(seen.contains(want), "{want:?} not reachable: {seen:?}");
}
s.execute("edit.purgeUndo", json!({})).unwrap();
assert_eq!(list(&mut s).len(), 1);
}
#[test]
fn undo_levels_trim_branches() {
let mut s = Session::default();
s.prefs.general.undo_levels = 3;
s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap();
for n in ["A", "B", "C", "D", "E"] {
solid(&mut s, n);
}
assert_eq!(s.history.undo.len(), 3);
s.undo();
s.undo();
solid(&mut s, "F");
// The branch (D, E) grew from a state still kept.
assert_eq!(s.history.branch_states(), 2);
for n in ["G", "H", "I"] {
solid(&mut s, n);
}
// Its parent fell off the end of the undo levels: the branch goes too.
assert_eq!(s.history.branch_states(), 0);
assert_eq!(list(&mut s).len(), 4);
}

View File

@ -476,8 +476,9 @@ fn create_vr_environment_and_extract_cubemap() {
let c = s.project.comp(ItemId(*f)).unwrap();
assert_eq!((c.width, c.height), (64, 64));
assert!(c.layers.iter().any(|l| l.is_camera()));
assert!(c.layers.iter().any(|l| l.name == aname && l.is_3d()), "the scene's layers");
let _ = main;
let nested = c.layers.iter().find(|l| matches!(l.source, effectcraft_project::LayerSource::Comp { item } if item == main)).expect("the scene, nested");
assert!(nested.switches.collapse && nested.is_3d(), "collapsed 3D precomp");
let _ = &aname;
}
let cube = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap();
assert_eq!((cube.width, cube.height, cube.layers.len()), (192, 128, 6));

View File

@ -250,3 +250,56 @@ fn liquify_stroke_adds_the_effect_and_warps() {
assert!(layer(&s, id).effects().unwrap().groups().next().is_none());
assert!(px(&s, 0.0, 55, 50)[3] > 0.99);
}
fn pin_keys(s: &Session, id: u64, pin: u64) -> Vec<(f64, [f64; 2])> {
let l = layer(s, id);
let pos = l.props.find_group(pin).unwrap().get("position").unwrap().clone();
pos.keys.iter().map(|k| (k.time.seconds(), k.value.as_vec2())).collect()
}
#[test]
fn puppet_pin_recording_makes_keys_at_the_frame_rate() {
let (mut s, id) = setup();
let a = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
let pin = a["pin"].as_u64().unwrap();
s.set_time(Tick::from_seconds_f64(0.5));
// A 0.5 s drag sampled at irregular wall-clock times: right 30 px, linear in time.
let samples: Vec<Value> = [0.0, 0.07, 0.2, 0.33, 0.41, 0.5].iter().map(|t| json!([t, 10.0 + 60.0 * t, 30.0])).collect();
let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": samples, "smoothing": 0})).unwrap();
// 0.5 s at 30 fps from 0.5 s: frames 15..=30, one key per frame.
assert_eq!(r["frames"], 16, "{r}");
let keys = pin_keys(&s, id, pin);
let rec: Vec<&(f64, [f64; 2])> = keys.iter().filter(|k| k.0 >= 0.5 - 1e-9).collect();
assert_eq!(rec.len(), 16, "{keys:?}");
for (i, (t, v)) in rec.iter().enumerate() {
assert!((t - (0.5 + i as f64 / 30.0)).abs() < 1e-6, "{t}");
assert!((v[0] - (10.0 + 60.0 * i as f64 / 30.0)).abs() < 1e-6, "{v:?}");
}
assert_eq!(keys.len(), 17, "the pin's first key (0 s) is kept");
assert_eq!(s.time().seconds(), 0.5, "the current time returns to where recording began");
// The recording deforms the layer: by 1 s the left edge has followed the pin 30 px right.
assert!(px(&s, 0.0, 55, 50)[3] > 0.99);
assert!(px(&s, 1.0, 55, 50)[3] < 0.5);
// Speed 200 %: the same drag plays back over twice the frames; recording again replaces
// the keys in the span.
let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": samples, "speed": 200, "smoothing": 0})).unwrap();
assert_eq!(r["frames"], 31);
let keys = pin_keys(&s, id, pin);
assert_eq!(keys.len(), 32);
let k = keys.iter().find(|k| (k.0 - (0.5 + 10.0 / 30.0)).abs() < 1e-6).unwrap();
assert!((k.1[0] - (10.0 + 60.0 * 5.0 / 30.0)).abs() < 1e-6, "{k:?}");
// Smoothing removes the extraneous keys of a straight-line drag.
let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": samples})).unwrap();
assert!(r["keys"].as_u64().unwrap() < 6, "{r}");
// One undo step per recording.
s.undo();
assert_eq!(pin_keys(&s, id, pin).len(), 32);
// Record Options are tool state (serde for agents).
let o = s.execute("puppet.recordOptions", json!({"speed": 50, "smoothing": 0, "useDraftDeformation": true, "showMesh": true})).unwrap();
assert_eq!(o["speed"], 50.0);
assert!(s.state.puppet.record_draft && s.state.puppet.record_show_mesh);
let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": samples})).unwrap();
assert_eq!(r["frames"], 9, "50 %: half the frames");
assert!(s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": []})).is_err());
}

View File

@ -312,6 +312,123 @@ fn pre_render_imports_and_replaces() {
assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. }));
}
#[test]
fn post_render_actions_import_and_set_proxy() {
let (mut s, _) = rq();
let cid = s.active_comp_id().unwrap();
let items_before = s.project.items.len();
// Import: the rendered file becomes a footage item.
let a = s.execute("renderQueue.add", json!({"output": "/tmp/post/a.mov"})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"item": a["item"], "postRenderAction": "import"})).unwrap();
assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::Import);
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(s.project.items.len(), items_before + 1);
assert!(s.project.items.values().any(|i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if f.path == "/tmp/post/a.mov")));
// Set Proxy: the comp gets the render as its proxy.
let b = s.execute("renderQueue.add", json!({"output": "/tmp/post/b.mov"})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"item": b["item"], "postRenderAction": "Set Proxy"})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
let px = s.project.item(cid).unwrap().proxy.clone().expect("proxy set");
assert_eq!(px.footage.path, "/tmp/post/b.mov");
// None: nothing happens.
let n = s.project.items.len();
let c = s.execute("renderQueue.add", json!({"output": "/tmp/post/c.mov"})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"item": c["item"], "postRenderAction": "none"})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(s.project.items.len(), n);
assert!(s.execute("renderQueue.setOutputModule", json!({"item": c["item"], "postRenderAction": "explode"})).is_err());
}
#[test]
fn templates_save_apply_default_and_persist() {
let (mut s, _) = rq();
let t = s.execute("renderQueue.templates", json!({})).unwrap();
let names: Vec<&str> = t["renderSettings"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Best Settings", "Draft Settings", "DV Settings", "Multi-Machine Settings", "Current Settings"]);
assert!(t["outputModules"].as_array().unwrap().iter().any(|m| m["name"] == "High Quality with Alpha"));
assert_eq!(t["defaults"]["renderSettings"]["Movie"], "Best Settings");
// A new item starts from the Movie defaults.
let a = s.execute("renderQueue.add", json!({"output": "/tmp/t/a.mp4"})).unwrap();
assert_eq!(a["renderSettingsSummary"], "Best Settings");
assert_eq!(a["outputModuleSummary"], "H.264 - Match Render Settings - 15 Mbps");
// Edit it: Custom; save templates from it; make them the defaults.
let r = s
.execute(
"renderQueue.setRenderSettings",
json!({"item": a["item"], "fieldRender": "upper", "pulldown": "WSSWW", "colorDepth": 16, "effects": "allOff", "solo": "allOff", "guideLayers": "current", "frameBlending": "offForAll", "motionBlur": "onForChecked", "duration": 0.5, "storageOverflow": false}),
)
.unwrap();
assert_eq!(r["renderSettingsSummary"], "Custom");
assert_eq!(r["settings"]["field_render"], "UpperFirst");
s.execute(
"renderQueue.setOutputModule",
json!({"item": a["item"], "format": "png", "channels": "rgba", "color": "premultiplied", "crop": {"top": 2, "bottom": 2}, "resize": {"width": 320, "height": 200, "lockAspect": false, "quality": "low"}}),
)
.unwrap();
s.execute("renderQueue.saveTemplate", json!({"kind": "renderSettings", "name": "Interlaced 16", "item": a["item"]})).unwrap();
s.execute("renderQueue.saveTemplate", json!({"kind": "outputModule", "name": "Small PNG", "item": a["item"], "params": {"quality": 50}})).unwrap();
s.execute("renderQueue.setTemplateDefault", json!({"kind": "renderSettings", "slot": "movie", "name": "Interlaced 16"})).unwrap();
s.execute("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "movie", "name": "Small PNG"})).unwrap();
assert!(s.execute("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "movie", "name": "Nope"})).is_err());
let b = s.execute("renderQueue.add", json!({"output": "/tmp/t/b_[####].png"})).unwrap();
assert_eq!(b["renderSettingsSummary"], "Interlaced 16");
assert_eq!(b["outputModuleSummary"], "Small PNG");
let it = s.project.render_queue.last().unwrap().clone();
assert_eq!(it.output.resize.width, 320);
assert_eq!(it.output.output, "/tmp/t/b_[####].png");
assert_eq!((b["width"].as_u64(), b["height"].as_u64()), (Some(320), Some(200)));
// Apply built-ins to an item; the output path keeps its name with the new extension.
let r = s.execute("renderQueue.applyTemplate", json!({"item": a["item"], "renderSettings": "DV Settings", "outputModule": "High Quality"})).unwrap();
assert_eq!(r["renderSettingsSummary"], "DV Settings");
assert_eq!(r["outputModuleSummary"], "High Quality");
assert!(r["outputPath"].as_str().unwrap().ends_with("a.mov"), "{r}");
// Persisted with the project.
let p = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
assert_eq!(p.render_templates, s.project.render_templates);
assert_eq!(p.render_queue, s.project.render_queue);
// Delete: saved ones go; built-ins stay. Undo restores.
s.execute("renderQueue.deleteTemplate", json!({"kind": "outputModule", "name": "Small PNG"})).unwrap();
assert!(s.project.render_templates.output_module("Small PNG").is_none());
assert!(s.execute("renderQueue.deleteTemplate", json!({"kind": "renderSettings", "name": "Best Settings"})).is_err());
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.render_templates.output_module("Small PNG").is_some());
}
#[test]
fn save_frame_as_queues_with_the_frame_defaults() {
let (mut s, _) = rq();
s.execute("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "TIFF Sequence with Alpha"})).unwrap();
let r = s.execute("comp.saveFrameAs", json!({"queue": true, "time": 1.0})).unwrap();
assert_eq!(r["queued"], true);
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.format, OutputFormat::TiffSequence);
assert_eq!(it.settings.name, "Current Settings");
let comp = s.project.comp(it.comp).unwrap();
assert_eq!(it.settings.frame_count(comp), 1);
assert_eq!(it.settings.frame_time(comp, 0), Tick::from_seconds_f64(1.0));
}
#[test]
fn log_notify_and_overflow_settings() {
let (mut s, _) = rq();
let a = s.execute("renderQueue.add", json!({"output": "/tmp/n/a.mp4", "log": "plusPerFrameInfo"})).unwrap();
assert_eq!(a["logLabel"], "Plus Per Frame Info");
assert_eq!(a["logPath"], "/tmp/n/a_RenderLog.txt");
s.execute("renderQueue.setLog", json!({"item": a["item"], "log": "plusSettings"})).unwrap();
assert_eq!(s.project.render_queue[0].log, effectcraft_project::render_queue::RenderLog::PlusSettings);
s.execute("renderQueue.setOverflowFolders", json!({"folders": ["/tmp/o1", " ", "/tmp/o2"]})).unwrap();
assert_eq!(s.project.render_prefs.overflow_folders, ["/tmp/o1", "/tmp/o2"]);
assert_eq!(s.execute("renderQueue.setNotify", json!({})).unwrap()["notify"], true);
s.drain_events();
s.execute("renderQueue.render", json!({})).unwrap();
let ev = s.drain_events();
assert!(ev.iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "renderQueue.notify")), "{ev:?}");
s.execute("renderQueue.setNotify", json!({"notify": false})).unwrap();
s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
assert!(!s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "renderQueue.notify")));
}
#[test]
fn save_current_preview_writes_a_movie() {
let (mut s, ex) = rq();

View File

@ -202,3 +202,28 @@ fn edit_state_round_trips_through_serde() {
let back: crate::EditorState = serde_json::from_value(j).unwrap();
assert_eq!(back.text_edit, s.state.text_edit);
}
#[test]
fn opentype_features_per_range_render_and_query() {
let (mut s, t) = session("Office 1/2");
s.execute("layer.setText", json!({"layer": t, "range": [7, 10], "fractions": true, "ss01": true})).unwrap();
let d = doc(&s, t);
assert!(!d.style_at(0).opentype.fractions);
assert!(d.style_at(8).opentype.fractions && d.style_at(8).opentype.stylistic_set(1));
// The laid-out glyphs change (frac turns 1/2 into a fraction).
let plain = effectcraft_text::layout_doc(&TextDoc { text: "1/2".into(), ..Default::default() });
let frac = effectcraft_text::layout_doc(&d.slice(7..10));
let ids = |l: &effectcraft_text::TextLayout| l.glyphs.iter().map(|g| g.gid).collect::<Vec<_>>();
assert_ne!(ids(&plain), ids(&frac));
// Undo restores the uniform style.
s.execute("edit.undo", json!({})).unwrap();
assert!(doc(&s, t).is_uniform());
// Font feature query (font by name, or the layer's).
let f = s.execute("text.fontFeatures", json!({"font": "Noto Serif"})).unwrap();
assert_eq!(f["options"]["smallCaps"], true);
assert!(f["features"].as_array().unwrap().iter().any(|t| t == "onum"));
let f = s.execute("text.fontFeatures", json!({"layer": t})).unwrap();
assert_eq!(f["family"], "Inter");
assert!(f["options"]["stylisticSets"].as_array().unwrap().contains(&json!(1)));
assert_eq!(f["options"]["smallCaps"], false);
}

View File

@ -358,9 +358,8 @@ impl Session {
return self.offload_analysis(crate::offload::WorkerJob::Track { comp, layer, tracker, direction, settings, points, times });
}
let wait = wait || cfg!(target_arch = "wasm32");
self.history.undo.push(("Analyze Track".into(), self.project.clone()));
self.history.redo.clear();
self.history.merge_key = None;
let levels = self.prefs.general.undo_levels as usize;
self.history.record("Analyze Track", self.project.clone(), levels);
let shared = Arc::new(TrackShared::default());
let (comp, layer) = (work.comp, work.layer);
let thread = if wait {

View File

@ -1,5 +1,6 @@
//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG footage →
//! shape layer), Create Shapes from Text and Create Masks from Text (glyph outlines).
//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG, PDF,
//! Illustrator and EPS footage → shape layer), Create Shapes from Text and Create Masks from
//! Text (glyph outlines), and vector files imported as compositions (one layer per file layer).
use effectcraft_keyframe::{Gradient, ShapePath, Value};
use effectcraft_project::build::{self, Ids};
@ -149,6 +150,74 @@ pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec<PropGroup> {
if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items }
}
/// Whether footage is a vector file (SVG / PDF / AI / EPS).
pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
}
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted
/// to one layer when `layer` names it.
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option<Result<Doc, String>> {
if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) {
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
}
effectcraft_pdf::sniff(bytes)?;
Some(
effectcraft_pdf::parse(bytes)
.map(|d| match layer {
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
None => d,
})
.map_err(|e| format!("{path}: {e}")),
)
}
/// Import a PDF / Illustrator / EPS document as a composition the size of its page: one footage
/// item per file layer (each showing only that layer, continuously rasterisable), top layer
/// first. Returns (comp, folder, items).
pub fn import_vector_comp(
proj: &mut Project,
path: &str,
bytes: &[u8],
name: &str,
rate: effectcraft_time::FrameRate,
duration: effectcraft_time::Tick,
) -> Result<(ItemId, ItemId, Vec<ItemId>), String> {
use effectcraft_color::Label;
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer};
let doc = effectcraft_pdf::parse(bytes).map_err(|e| format!("{path}: {e}"))?;
let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF");
let (w, h) = doc.pixel_size();
let file = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder);
let mut comp = Comp::new(w, h, rate, duration);
let mut items = vec![];
let names = effectcraft_pdf::layer_names(&doc);
for (i, lname) in names.iter().enumerate() {
let f = Footage {
path: path.to_string(),
kind: FootageKind::Still,
width: w,
height: h,
pixel_aspect: 1.0,
frame_rate: rate,
has_video: true,
alpha: AlphaMode::Straight,
loop_count: 1,
codec: codec.into(),
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }),
..Default::default()
};
let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f));
items.push(id);
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None);
l.name = comp.unique_layer_name(lname);
comp.layers.insert(0, l);
}
let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(std::sync::Arc::new(comp)));
Ok((cid, folder, items))
}
/// A copy of `src`'s transform group with fresh uids.
fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) {
let Some(tr) = src.props.sub("transform") else { return };

View File

@ -34,3 +34,4 @@ effectcraft-media = { workspace = true }
effectcraft-color = { workspace = true }
filmcraft-opus = { workspace = true }
filmcraft-vp9 = { workspace = true }
effectcraft-keyframe = { workspace = true }

View File

@ -5,12 +5,12 @@
use std::io::Write;
use effectcraft_project::Comp;
use effectcraft_project::render_queue::{Channels, OutputFormat, ProResProfile};
use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile};
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions};
use rayon::prelude::*;
use crate::{ExportError, Job, Report, Result, State, batch_size, render_frame, rgba8, wants_audio};
use crate::{Cx, ExportError, Report, Result, State, batch_size, wants_audio};
fn enc(e: impl std::fmt::Display) -> ExportError {
ExportError::Encode(e.to_string())
@ -209,31 +209,62 @@ enum Audio {
Pcm,
}
const AUDIO_CHANNELS: u32 = 2;
fn deinterleave(buf: &[f32], channels: usize) -> Vec<Vec<f32>> {
(0..channels).map(|c| buf.chunks_exact(channels).map(|p| p[c]).collect()).collect()
}
fn deinterleave(buf: &[f32]) -> [Vec<f32>; 2] {
let l = buf.chunks_exact(2).map(|p| p[0]).collect();
let r = buf.chunks_exact(2).map(|p| p[1]).collect();
[l, r]
/// The comp's audio over `n` samples from `start`, interleaved with the module's channel count
/// (mono = the average of left and right).
pub(crate) fn mix(cx: &Cx, start: effectcraft_time::Tick, n: usize, sr: u32) -> Vec<f32> {
let st = effectcraft_render::audio::mix_comp(&cx.project, cx.footage, cx.expr, cx.comp, start, n, sr);
if cx.output.audio_channels == 1 { st.chunks_exact(2).map(|p| (p[0] + p[1]) * 0.5).collect() } else { st }
}
/// PCM bytes of samples in `fmt` (little or big endian).
pub(crate) fn pcm_bytes(buf: &[f32], fmt: AudioFormat, big_endian: bool) -> Vec<u8> {
let mut out = Vec::with_capacity(buf.len() * 4);
for &s in buf {
let s = s.clamp(-1.0, 1.0);
match fmt {
AudioFormat::S16 => {
let v = (s * 32767.0).round() as i16;
out.extend_from_slice(&if big_endian { v.to_be_bytes() } else { v.to_le_bytes() });
}
AudioFormat::S24 => {
let v = (s * 8_388_607.0).round() as i32;
let b = v.to_le_bytes();
if big_endian {
out.extend_from_slice(&[b[2], b[1], b[0]]);
} else {
out.extend_from_slice(&b[..3]);
}
}
AudioFormat::F32 => out.extend_from_slice(&if big_endian { s.to_be_bytes() } else { s.to_le_bytes() }),
}
}
out
}
// ---------------------------------------------------------------- the movie
type Writer<'f, 's> = Mp4Writer<&'f mut crate::out::Out<'s>>;
pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
pub(crate) fn movie(job: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
let rate = job.settings.rate(comp);
let fmt = job.output.format;
let channels = if fmt == OutputFormat::ProRes { job.output.channels } else { Channels::Rgb };
let channels = match job.output.channels {
Channels::Rgba if fmt != OutputFormat::ProRes => Channels::Rgb,
c => c,
};
let mut venc: Box<dyn VideoEncoder> = match fmt {
OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps)?),
OutputFormat::Hevc => Box::new(crate::hevc_av1::Hevc::new(w, h, rate, job.output)?),
OutputFormat::Av1 => Box::new(crate::hevc_av1::Av1::new(w, h, rate, job.output)?),
_ => Box::new(ProRes::new(w, h, job.output.prores_profile, channels)),
_ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels })),
};
let brand = if fmt == OutputFormat::ProRes { Brand::Mov } else { Brand::Mp4 };
let batch = batch_size();
let render = |k: u64| -> Vec<u8> { rgba8(&render_frame(job, comp, k), comp, channels, w, h) };
let render = |k: u64| -> Vec<u8> { job.pixels(&job.frame(comp, k), comp, channels, w, h) };
// Encode the first batch before creating tracks: encoders finalise their config on frame 1.
let first_end = batch.min(st.total);
@ -245,7 +276,8 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R
drop(first);
st.advance(first_end)?;
let mut file = crate::out::create(job.sink, job.path)?;
let path = job.place(job.path, 1);
let mut file = crate::out::create(job.sink, &path)?;
let opts = WriterOptions::new(brand);
let movie_ts = opts.movie_timescale.max(1) as i128;
let mut mux: Writer = Mp4Writer::new(&mut file, opts).map_err(mux_err)?;
@ -260,15 +292,22 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R
let vt = mux.add_track(vcfg).map_err(mux_err)?;
let sr = job.output.audio_sample_rate.clamp(8_000, 192_000);
let with_audio = wants_audio(job);
let chans = if job.output.audio_channels == 1 { 1u32 } else { 2 };
let afmt = job.output.audio_format;
let mut audio = None;
if with_audio {
let (a, entry, start) = if brand == Brand::Mp4 {
let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, AUDIO_CHANNELS as usize, 320_000)).map_err(enc)?;
let entry = SampleEntry::aac(e.audio_specific_config(), AUDIO_CHANNELS, sr);
let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, chans as usize, 160_000 * chans)).map_err(enc)?;
let entry = SampleEntry::aac(e.audio_specific_config(), chans, sr);
let priming = e.priming_samples() as i64;
(Audio::Aac(Box::new(e)), entry, Some(priming))
} else {
let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: AUDIO_CHANNELS, sample_rate: sr as f64 };
let (bits, float) = match afmt {
AudioFormat::S16 => (16, false),
AudioFormat::S24 => (24, false),
AudioFormat::F32 => (32, true),
};
let pcm = PcmConfig { bits, float, big_endian: false, signed: true, channels: chans, sample_rate: sr as f64 };
(Audio::Pcm, SampleEntry::pcm(pcm), None)
};
let mut c = TrackConfig::new(entry, sr);
@ -293,21 +332,18 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R
}
let n = (end - *cursor) as usize;
let start = Tick(((*cursor as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64);
let buf = effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, start, n, sr);
let buf = mix(job, start, n, sr);
*cursor = end;
match a {
Audio::Aac(e) => {
let planar = deinterleave(&buf);
let planar = deinterleave(&buf, chans as usize);
let refs: Vec<&[f32]> = planar.iter().map(Vec::as_slice).collect();
for au in e.encode(&refs) {
mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?;
}
}
Audio::Pcm => {
let mut pcm = Vec::with_capacity(n * 4);
for s in buf {
pcm.extend_from_slice(&((s.clamp(-1.0, 1.0) * 32767.0).round() as i16).to_le_bytes());
}
let pcm = pcm_bytes(&buf, afmt, false);
mux.write_sample(*at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true }).map_err(mux_err)?;
}
}
@ -339,7 +375,7 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R
let out = mux.finish().map_err(mux_err)?;
out.flush().map_err(mux_err)?;
let bytes = file.finish()?;
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio })
Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio, log: None, overflow: vec![] })
}
#[cfg(test)]

View File

@ -76,7 +76,7 @@ pub(crate) fn hevc_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) ->
RateControlMode::Bitrate => he::RateControl::Bitrate { kbps: om.bitrate_kbps.max(50) },
RateControlMode::Quality => he::RateControl::ConstantQp(o.hevc_qp()),
};
cfg.keyint = o.keyint(rate.as_f64());
cfg.keyint = om.keyint(rate.as_f64());
cfg
}
@ -128,7 +128,7 @@ pub(crate) fn av1_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) ->
RateControlMode::Bitrate => ae::RateControl::Bitrate { kbps: om.bitrate_kbps.max(50) },
RateControlMode::Quality => ae::RateControl::ConstantQ(o.av1_qindex()),
};
cfg.keyint = o.keyint(rate.as_f64());
cfg.keyint = om.keyint(rate.as_f64());
cfg
}
@ -185,7 +185,7 @@ mod tests {
om.codec.level = Some(41);
om.codec.rate_control = RateControlMode::Quality;
om.codec.quality = 100;
om.codec.keyframe_interval = 12;
om.keyframe_interval = 12;
let rate = FrameRate::new(30, 1);
let h = hevc_config(64, 32, rate, &om);
assert_eq!(h.profile, effectcraft_hevcenc::Profile::Main10);
@ -195,7 +195,7 @@ mod tests {
let a = av1_config(64, 32, rate, &om);
assert_eq!((a.bit_depth, a.level_idx, a.keyint), (10, Some(9), 12));
om.codec.rate_control = RateControlMode::Bitrate;
om.codec.keyframe_interval = 0;
om.keyframe_interval = 0;
om.bitrate_kbps = 3000;
let a = av1_config(64, 32, rate, &om);
assert_eq!((a.rate, a.keyint), (effectcraft_av1enc::RateControl::Bitrate { kbps: 3000 }, 60));

View File

@ -2,15 +2,23 @@
//!
//! | Format | Container / files | Video | Alpha | Audio |
//! |---|---|---|---|---|
//! | H.264 | MP4 | FilmCraft H.264 High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (FilmCraft), stereo |
//! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | 16-bit PCM, stereo |
//! | H.264 | MP4 | FilmCraft H.264 High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (FilmCraft), mono/stereo |
//! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | PCM 16/24-bit or 32-bit float, mono/stereo |
//! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — |
//! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — |
//! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — |
//! | WebM | WebM (Matroska) | VP9 profile 0 intra frames (`effectcraft-vp9enc`), 8-bit 4:2:0 | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`), 48 kHz stereo |
//! | HEVC | MP4 (`hvc1`) | `effectcraft-hevcenc` Main / Main 10 4:2:0, I + P slices, bitrate or constant quality, level, key-frame interval | no | AAC-LC (FilmCraft), stereo |
//! | AV1 | MP4 (`av01`), or WebM with the WebM format's AV1 codec | `effectcraft-av1enc` Main 8/10-bit 4:2:0, key + inter frames | no | AAC-LC in MP4, Opus in WebM |
//! | WAV / AIFF | RIFF WAVE / AIFF | — | — | 16-bit PCM stereo (audio only) |
//! | WebM | WebM (Matroska) | VP9 profile 0 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 8-bit 4:2:0; or AV1 (`effectcraft-av1enc`) | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`: SILK / hybrid / CELT by bitrate and application), 48 kHz |
//! | HEVC | MP4 (`hvc1`) | `effectcraft-hevcenc` Main / Main 10 4:2:0, I + P slices, bitrate or constant quality, level, key-frame interval | no | AAC-LC (FilmCraft) |
//! | AV1 | MP4 (`av01`) | `effectcraft-av1enc` Main 8/10-bit 4:2:0, key + inter frames | no | AAC-LC (FilmCraft) |
//! | WAV / AIFF | RIFF WAVE / AIFF | — | — | PCM 16/24-bit (WAV also 32-bit float), mono/stereo (audio only) |
//!
//! Every frame goes through [`pipeline`]: the Render Settings overrides (Effects, Solo Switches,
//! Guide Layers, Frame Blending, Motion Blur, Color Depth), field rendering (two fields half a
//! frame apart woven line by line, with optional 3:2 pulldown from film frames at 4/5 of the
//! rate), then the Output Module's Crop (edges or the region of interest) and Resize. Channels
//! may be RGB, RGB + Alpha (straight or premultiplied) or the alpha matte alone. With Use Storage
//! Overflow and a [`StorageQuota`] hook, files that don't fit go to the overflow folders. A
//! render log (Errors Only / Plus Settings / Plus Per Frame Info) is written next to the output.
//!
//! Frames are rendered in parallel batches (rayon; one batch ≈ one frame per core) and handed to
//! the encoder in order. Progress is reported after each batch through a callback that can cancel
@ -23,20 +31,22 @@
mod encode;
mod hevc_av1;
mod out;
mod pipeline;
mod webm;
mod webm_av1;
use web_time::Instant;
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, RenderQuality, RenderSettings, sequence_path};
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, RenderLog, RenderQuality, RenderSettings, sequence_path};
use effectcraft_project::{Comp, ItemId, Project};
use effectcraft_raster::Image;
use effectcraft_render::{ExprHost, FootageSource, RenderOpts, Renderer};
use effectcraft_time::Tick;
use effectcraft_render::{ExprHost, FootageSource};
use rayon::prelude::*;
pub use effectcraft_project::render_queue;
pub use effectcraft_project::render_queue::StorageQuota;
pub use out::Sink;
pub(crate) use pipeline::Cx;
#[derive(Debug, thiserror::Error)]
pub enum ExportError {
@ -75,6 +85,21 @@ pub struct Job<'a> {
pub sink: Option<&'a Sink>,
/// Settings ▸ General ▸ Switches Affect Nested Comps (see `RenderOpts::nested_switches`).
pub nested_switches: bool,
/// Render log, storage overflow.
pub options: JobOptions<'a>,
}
/// Job options beyond the Render Settings and Output Module.
#[derive(Default)]
pub struct JobOptions<'a> {
/// The render log written next to the output.
pub log: RenderLog,
/// What the log calls the job (e.g. "#2 Main Comp").
pub label: String,
/// Free-space hook for Use Storage Overflow (`None`: never full).
pub storage: Option<&'a dyn StorageQuota>,
/// Overflow folders, in order.
pub overflow: Vec<String>,
}
/// Progress after a batch of frames.
@ -106,6 +131,10 @@ pub struct Report {
pub seconds: f64,
pub bytes: u64,
pub audio: bool,
/// The render log, when one was written.
pub log: Option<String>,
/// Files that went to an overflow folder.
pub overflow: Vec<String>,
}
/// Formats this build can write (all of them; kept as the hook for optional encoders).
@ -113,10 +142,9 @@ pub fn available_formats() -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
/// The size of the written frames for a job (H.264 rounds to even).
/// The size of the written frames for a job (crop, resize; H.264 rounds to even).
pub fn output_size(comp: &Comp, settings: &RenderSettings, output: &OutputModule) -> (u32, u32) {
let (w, h) = settings.output_size(comp);
output.format.coded_size(w, h)
output.output_size(comp, settings)
}
/// Whether the job writes an audio track.
@ -131,19 +159,30 @@ pub fn wants_audio(job: &Job) -> bool {
/// Run the export (blocking). `progress` is called after every batch; returning `false` cancels.
pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<Report> {
let comp = job.project.comp(job.comp).ok_or(ExportError::NoComp)?;
let t0 = Instant::now();
let total = job.settings.frame_count(comp);
let cx = Cx::new(job);
let mut r = export_with(&cx, progress, t0);
let log = cx.write_log(&r, t0.elapsed().as_secs_f64());
if let Ok(rep) = &mut r {
rep.log = log;
rep.overflow = cx.overflowed.lock().unwrap_or_else(|e| e.into_inner()).clone();
}
r
}
fn export_with(cx: &Cx, progress: &mut dyn FnMut(&Progress) -> bool, t0: Instant) -> Result<Report> {
let comp = cx.comp().ok_or(ExportError::NoComp)?;
let total = cx.settings.frame_count(comp);
let mut st = State { t0, total, done: 0, progress };
st.advance(0)?;
let (w, h) = output_size(comp, job.settings, job.output);
let mut report = match job.output.format {
OutputFormat::H264 | OutputFormat::Hevc | OutputFormat::Av1 | OutputFormat::ProRes => encode::movie(job, comp, w, h, &mut st)?,
OutputFormat::Gif => gif_export(job, comp, w, h, &mut st)?,
OutputFormat::WebM => webm::webm(job, comp, w, h, &mut st)?,
OutputFormat::Wav => webm::audio_file(job, comp, false, &mut st)?,
OutputFormat::Aiff => webm::audio_file(job, comp, true, &mut st)?,
f if f.is_sequence() => sequence(job, comp, w, h, &mut st)?,
let (w, h) = output_size(comp, cx.settings, cx.output);
let mut report = match cx.output.format {
OutputFormat::H264 | OutputFormat::Hevc | OutputFormat::Av1 | OutputFormat::ProRes => encode::movie(cx, comp, w, h, &mut st)?,
OutputFormat::Gif => gif_export(cx, comp, w, h, &mut st)?,
OutputFormat::WebM => webm::webm(cx, comp, w, h, &mut st)?,
OutputFormat::Wav => webm::audio_file(cx, comp, false, &mut st)?,
OutputFormat::Aiff => webm::audio_file(cx, comp, true, &mut st)?,
f if f.is_sequence() => sequence(cx, comp, w, h, &mut st)?,
f => return Err(ExportError::Unsupported(f.label().into())),
};
report.seconds = t0.elapsed().as_secs_f64();
@ -176,39 +215,8 @@ pub(crate) fn batch_size() -> u64 {
rayon::current_num_threads().clamp(2, 16) as u64
}
/// Render output frame `i` of the job at the settings' resolution.
pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image {
let t: Tick = job.settings.frame_time(comp, i);
let opts = RenderOpts {
scale: job.settings.resolution.clamp(0.01, 4.0),
motion_blur: job.settings.motion_blur,
guides: false,
draft: job.settings.quality == RenderQuality::Draft,
// Output renders always look through the comp's active camera.
view: None,
backend: effectcraft_render::Backend::Auto,
roi: None,
nested_switches: job.nested_switches,
draft_shadows: true,
proxy: job.settings.proxy_use,
};
let mut r = Renderer::new(job.project, job.footage, opts);
r.expr = job.expr;
r.accel = job.accel;
r.comp_frame(job.comp, t)
}
/// 8-bit RGBA of the requested channels (RGB: over the comp background, opaque; RGBA: straight),
/// cropped/padded to `w`×`h`.
pub(crate) fn rgba8(img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec<u8> {
let px = match channels {
Channels::Rgb => img.to_rgba8_over(comp.background),
Channels::Rgba => img.to_rgba8(),
};
fit(px, img.width, img.height, w, h)
}
fn fit(px: Vec<u8>, iw: u32, ih: u32, w: u32, h: u32) -> Vec<u8> {
/// Crop/pad 8-bit RGBA to `w`×`h` (codec rounding).
pub(crate) fn fit(px: Vec<u8>, iw: u32, ih: u32, w: u32, h: u32) -> Vec<u8> {
if iw == w && ih == h {
return px;
}
@ -225,10 +233,13 @@ fn first_frame_number(job: &Job, comp: &Comp) -> i64 {
job.settings.first_frame(comp)
}
fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
let f0 = first_frame_number(job, comp);
let fmt = job.output.format;
let channels = if fmt.supports_alpha() { job.output.channels } else { Channels::Rgb };
fn sequence(cx: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
let f0 = first_frame_number(cx, comp);
let fmt = cx.output.format;
let channels = match cx.output.channels {
Channels::Rgba if !fmt.supports_alpha() => Channels::Rgb,
c => c,
};
let batch = batch_size();
let mut bytes = 0u64;
let mut i = 0;
@ -237,13 +248,18 @@ fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Re
let written: Vec<Result<u64>> = (i..end)
.into_par_iter()
.map(|k| {
let path = sequence_path(job.path, f0 + k as i64);
if job.settings.skip_existing && out::exists(job.sink, &path) {
let path = sequence_path(cx.path, f0 + k as i64);
if cx.settings.skip_existing && out::exists(cx.sink, &path) {
return Ok(0);
}
let img = render_frame(job, comp, k);
let mut f = out::create(job.sink, &path)?;
write_still(&mut f, fmt, &img, comp, channels, w, h, job.output.quality)?;
let img = cx.frame(comp, k);
// Encode in memory first: the storage hook decides where it fits.
let mut buf = std::io::Cursor::new(Vec::new());
write_still(cx, &mut buf, fmt, &img, comp, channels, w, h, cx.output.quality)?;
let data = buf.into_inner();
let path = cx.place(&path, data.len() as u64);
let mut f = out::create(cx.sink, &path)?;
std::io::Write::write_all(&mut f, &data).map_err(io)?;
f.finish()
})
.collect();
@ -253,11 +269,21 @@ fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Re
st.advance(end - i)?;
i = end;
}
Ok(Report { path: sequence_path(job.path, f0), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false })
Ok(Report { path: sequence_path(cx.path, f0), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false, log: None, overflow: vec![] })
}
#[allow(clippy::too_many_arguments)]
fn write_still(file: &mut out::Out, fmt: OutputFormat, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, quality: u8) -> Result<()> {
fn write_still<W: std::io::Write + std::io::Seek>(
cx: &Cx,
file: &mut W,
fmt: OutputFormat,
img: &Image,
comp: &Comp,
channels: Channels,
w: u32,
h: u32,
quality: u8,
) -> Result<()> {
use image::{ExtendedColorType, ImageEncoder, ImageFormat};
match fmt {
OutputFormat::ExrSequence => {
@ -268,6 +294,10 @@ fn write_still(file: &mut out::Out, fmt: OutputFormat, img: &Image, comp: &Comp,
for x in 0..w {
let p = if x < img.width && y < img.height { img.data[img.idx(x, y)] } else { [0.0; 4] };
let (c, a) = match channels {
Channels::Alpha => {
let a = p[3].clamp(0.0, 1.0);
([lin(a); 3], 1.0)
}
Channels::Rgba => {
let a = p[3].clamp(0.0, 1.0);
let s = |c: f32| if a > 0.0 { lin((c / a).clamp(0.0, 1.0)) * a } else { 0.0 };
@ -286,16 +316,17 @@ fn write_still(file: &mut out::Out, fmt: OutputFormat, img: &Image, comp: &Comp,
image::DynamicImage::ImageRgba32F(buf).write_to(file, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string()))
}
OutputFormat::JpegSequence => {
let px = rgba8(img, comp, Channels::Rgb, w, h);
let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h);
let rgb: Vec<u8> = px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality.clamp(1, 100))
.write_image(&rgb, w, h, ExtendedColorType::Rgb8)
.map_err(|e| ExportError::Encode(e.to_string()))
}
OutputFormat::PngSequence | OutputFormat::TiffSequence => {
let px = rgba8(img, comp, channels, w, h);
let px = cx.pixels(img, comp, channels, w, h);
let (data, ct) = match channels {
Channels::Rgba => (px, ExtendedColorType::Rgba8),
Channels::Alpha => (px.chunks_exact(4).map(|p| p[0]).collect(), ExtendedColorType::L8),
Channels::Rgb => (px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8),
};
if fmt == OutputFormat::PngSequence {
@ -309,11 +340,12 @@ fn write_still(file: &mut out::Out, fmt: OutputFormat, img: &Image, comp: &Comp,
}
}
fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
fn gif_export(job: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
if w > u16::MAX as u32 || h > u16::MAX as u32 {
return Err(ExportError::Unsupported("GIF frames are limited to 65535 px".into()));
}
let mut file = out::create(job.sink, job.path)?;
let path = job.place(job.path, 1);
let mut file = out::create(job.sink, &path)?;
let mut enc = gif::Encoder::new(&mut file, w as u16, h as u16, &[]).map_err(|e| ExportError::Encode(e.to_string()))?;
enc.set_repeat(if job.output.gif_loop { gif::Repeat::Infinite } else { gif::Repeat::Finite(0) }).map_err(|e| ExportError::Encode(e.to_string()))?;
let rate = job.settings.rate(comp);
@ -328,8 +360,8 @@ fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<
let frames: Vec<gif::Frame<'static>> = (i..end)
.into_par_iter()
.map(|k| {
let img = render_frame(job, comp, k);
let mut px = rgba8(&img, comp, job.output.channels, w, h);
let img = job.frame(comp, k);
let mut px = job.pixels(&img, comp, job.output.channels, w, h);
gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10)
})
.collect();
@ -348,7 +380,7 @@ fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<
}
drop(enc);
let bytes = file.finish()?;
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false })
Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false, log: None, overflow: vec![] })
}
#[cfg(test)]

View File

@ -0,0 +1,329 @@
//! The frame pipeline shared by every output format: Render Settings overrides (effects, solo,
//! guide layers, frame blending, motion blur), field rendering with 3:2 pulldown, colour depth,
//! the Output Module's crop and resize, alpha handling, storage overflow and the render log.
use std::sync::Mutex;
use effectcraft_project::render_queue::{
AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path,
};
use effectcraft_project::{Comp, Project};
use effectcraft_raster::Image;
use effectcraft_render::{RenderOpts, Renderer};
use effectcraft_time::Tick;
use web_time::Instant;
use crate::{Job, RenderQuality, out};
/// A job being exported: the job, the project with the Render Settings overrides applied, and
/// per-frame timing for the render log.
pub(crate) struct Cx<'a> {
pub job: &'a Job<'a>,
pub project: Project,
/// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs).
pub frame_times: Option<Mutex<Vec<(u64, f64)>>>,
/// Files written to an overflow folder.
pub overflowed: Mutex<Vec<String>>,
}
impl<'a> std::ops::Deref for Cx<'a> {
type Target = Job<'a>;
fn deref(&self) -> &Job<'a> {
self.job
}
}
/// The project as the Render Settings render it: Effects All On / All Off, Solo Switches All
/// Off, Frame Blending and Motion Blur "On for Checked Layers" (the composition switches on).
pub(crate) fn apply_overrides(p: &mut Project, job: &Job) {
let s = job.settings;
let ids: Vec<_> = p.comps().map(|(i, _)| *i).collect();
for id in ids {
let Some(c) = p.comp_mut(id) else { continue };
match s.frame_blending {
SwitchOverride::Current => {}
SwitchOverride::OnForChecked => c.enable_frame_blending = true,
SwitchOverride::OffForAll => c.enable_frame_blending = false,
}
match s.motion_blur_override() {
SwitchOverride::Current => {}
SwitchOverride::OnForChecked => c.enable_motion_blur = true,
SwitchOverride::OffForAll => c.enable_motion_blur = false,
}
for l in c.layers.iter_mut() {
match s.effects {
EffectsMode::Current => {}
EffectsMode::AllOn => l.switches.effects = true,
EffectsMode::AllOff => l.switches.effects = false,
}
if s.solo == CurrentOrOff::AllOff {
l.switches.solo = false;
}
}
}
}
impl<'a> Cx<'a> {
pub fn new(job: &'a Job<'a>) -> Cx<'a> {
let mut project = job.project.clone();
apply_overrides(&mut project, job);
let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new()));
Cx { job, project, frame_times, overflowed: Mutex::new(vec![]) }
}
pub fn comp(&self) -> Option<&Comp> {
self.project.comp(self.job.comp)
}
fn render_at(&self, t: Tick) -> Image {
let s = self.job.settings;
let opts = RenderOpts {
scale: s.resolution.clamp(0.01, 4.0),
motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll,
guides: s.guide_layers == CurrentOrOff::Current,
draft: s.quality == RenderQuality::Draft,
// Output renders always look through the comp's active camera.
view: None,
backend: effectcraft_render::Backend::Auto,
roi: None,
nested_switches: self.job.nested_switches,
draft_shadows: true,
proxy: s.proxy_use,
};
let mut r = Renderer::new(&self.project, self.job.footage, opts);
r.expr = self.job.expr;
r.accel = self.job.accel;
r.comp_frame(self.job.comp, t)
}
/// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized.
pub fn frame(&self, comp: &Comp, i: u64) -> Image {
let t0 = Instant::now();
let img = match self.job.settings.sample(comp, i) {
FrameSample::Progressive(t) => self.render_at(t),
FrameSample::Fields { first, second, upper_first } => {
let a = self.render_at(first);
if second == first { a } else { interleave(&a, &self.render_at(second), upper_first) }
}
};
let mut img = img;
// Color Depth: renders run at the project's depth; another depth quantises the frame.
let depth = self.job.settings.color_depth;
if depth != ColorDepth::Current
&& let Some(levels) = depth.resolve(self.project.settings.bit_depth).levels()
{
img.quantize(levels);
}
let img = self.crop_resize(img);
if let Some(ft) = &self.frame_times {
ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64()));
}
img
}
/// Output Module ▸ Crop, then Resize.
fn crop_resize(&self, img: Image) -> Image {
let om = self.job.output;
let scale = self.job.settings.resolution.clamp(0.01, 4.0);
let (t, l, b, r) = om.crop.edges(img.width, img.height, scale);
let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) };
if !om.resize.enabled {
return img;
}
let (w, h) = om.frame_size(img.width, img.height, 1.0);
if (w, h) == (img.width, img.height) {
return img;
}
match om.resize.quality {
ResizeQuality::Low => effectcraft_raster::warp::resample(&img, w, h),
ResizeQuality::High => bicubic(&img, w, h),
}
}
/// 8-bit RGBA of the requested channels cropped/padded to `w`×`h` (codec rounding): RGB over
/// the comp background (opaque), RGB + Alpha straight or premultiplied, or the alpha matte.
pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec<u8> {
let px = match channels {
Channels::Rgb => img.to_rgba8_over(comp.background),
Channels::Rgba => match self.job.output.alpha_mode {
AlphaMode::Straight => img.to_rgba8(),
AlphaMode::Premultiplied => premultiplied8(img),
},
Channels::Alpha => img
.data
.iter()
.flat_map(|p| {
let a = (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
[a, a, a, 255]
})
.collect(),
};
crate::fit(px, img.width, img.height, w, h)
}
/// Where a file goes: `path`, or (Use Storage Overflow) the first overflow folder with room
/// for `bytes` when the quota hook says the output volume is full.
pub fn place(&self, path: &str, bytes: u64) -> String {
let Some(q) = self.job.options.storage else { return path.to_string() };
if !self.job.settings.storage_overflow || q.has_room(path, bytes) {
return path.to_string();
}
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
for dir in &self.job.options.overflow {
let p = std::path::Path::new(dir).join(&name).to_string_lossy().to_string();
if q.has_room(&p, bytes) {
self.overflowed.lock().unwrap_or_else(|e| e.into_inner()).push(p.clone());
return p;
}
}
path.to_string()
}
/// Write the render log next to the output, as the Log setting asks. Returns its path.
pub fn write_log(&self, result: &Result<crate::Report, crate::ExportError>, seconds: f64) -> Option<String> {
let level = self.job.options.log;
// Errors Only: a log for failures (a user stop is not an error).
if level == RenderLog::ErrorsOnly && matches!(result, Ok(_) | Err(crate::ExportError::Cancelled)) {
return None;
}
let comp = self.comp();
let path = log_path(self.job.path);
let mut s = String::new();
s.push_str("EffectCraft Render Log\n");
s.push_str(&format!("Item: {}\n", if self.job.options.label.is_empty() { "(render)" } else { &self.job.options.label }));
s.push_str(&format!("Output: {}\n", self.job.path));
match result {
Ok(r) => s.push_str(&format!("Result: Done ({} frames, {}×{}, {} bytes) in {seconds:.2} s\n", r.frames, r.width, r.height, r.bytes)),
Err(e) => s.push_str(&format!("Result: Failed\nError: {e}\n")),
}
let over = self.overflowed.lock().unwrap_or_else(|e| e.into_inner());
if !over.is_empty() {
s.push_str(&format!("Storage overflow: {} file(s) written to overflow folders (first: {})\n", over.len(), over[0]));
}
if level != RenderLog::ErrorsOnly {
s.push_str("\nRender Settings:\n");
for (k, v) in self.job.settings.describe(comp) {
s.push_str(&format!(" {k}: {v}\n"));
}
s.push_str("\nOutput Module:\n");
for (k, v) in self.job.output.describe() {
s.push_str(&format!(" {k}: {v}\n"));
}
}
if let (RenderLog::PlusPerFrameInfo, Some(ft), Some(c)) = (level, &self.frame_times, comp) {
let mut v = ft.lock().unwrap_or_else(|e| e.into_inner()).clone();
v.sort_by_key(|(i, _)| *i);
s.push_str("\nFrames:\n");
let f0 = self.job.settings.first_frame(c);
for (i, secs) in v {
let t = self.job.settings.frame_time(c, i);
s.push_str(&format!(" Frame {} ({:.3} s): rendered in {:.1} ms\n", f0 + i as i64, t.seconds(), secs * 1000.0));
}
}
let mut f = out::create(self.job.sink, &path).ok()?;
std::io::Write::write_all(&mut f, s.as_bytes()).ok()?;
f.finish().ok()?;
Some(path)
}
}
/// Weave two fields: the dominant field's lines (even lines when `upper_first`) from `first`.
pub(crate) fn interleave(first: &Image, second: &Image, upper_first: bool) -> Image {
let mut out = first.clone();
let w = out.width as usize;
for y in 0..out.height as usize {
let from_first = (y % 2 == 0) == upper_first;
if !from_first && y < second.height as usize && second.width as usize == w {
out.data[y * w..(y + 1) * w].copy_from_slice(&second.data[y * w..(y + 1) * w]);
}
}
out
}
/// Remove `t`/`l`/`b`/`r` pixels from the edges (negative values add transparent pixels).
pub(crate) fn crop(img: &Image, t: i32, l: i32, b: i32, r: i32) -> Image {
let w = (img.width as i32 - l - r).max(1) as u32;
let h = (img.height as i32 - t - b).max(1) as u32;
let mut out = Image::new(w, h);
for y in 0..h as i32 {
let sy = y + t;
if sy < 0 || sy >= img.height as i32 {
continue;
}
for x in 0..w as i32 {
let sx = x + l;
if sx >= 0 && sx < img.width as i32 {
let o = out.idx(x as u32, y as u32);
out.data[o] = img.data[img.idx(sx as u32, sy as u32)];
}
}
}
out
}
/// Bicubic resize (Resize Quality: High).
fn bicubic(src: &Image, w: u32, h: u32) -> Image {
// Shrinking a lot: bilinear with its box pre-filter first, then bicubic for the last step.
if src.width > w * 2 && src.height > h * 2 {
let mid = effectcraft_raster::warp::resample(src, w * 2, h * 2);
return bicubic(&mid, w, h);
}
let mut out = Image::new(w, h);
let (sx, sy) = (src.width as f64 / w as f64, src.height as f64 / h as f64);
for y in 0..h {
for x in 0..w {
let o = out.idx(x, y);
// Clamp to the edge so borders stay opaque.
let fx = ((x as f64 + 0.5) * sx).clamp(0.5, src.width as f64 - 0.5);
let fy = ((y as f64 + 0.5) * sy).clamp(0.5, src.height as f64 - 0.5);
let mut p = src.sample_bicubic(fx, fy);
p[3] = p[3].min(1.0);
out.data[o] = p;
}
}
out
}
/// Premultiplied 8-bit RGBA (colour matted with black).
fn premultiplied8(img: &Image) -> Vec<u8> {
img.data
.iter()
.flat_map(|p| {
let a = p[3].clamp(0.0, 1.0);
[0, 1, 2].map(|c| (p[c].clamp(0.0, a) * 255.0 + 0.5) as u8).into_iter().chain([(a * 255.0 + 0.5) as u8])
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interleave_takes_alternate_lines() {
let a = Image::filled(3, 4, [1.0, 0.0, 0.0, 1.0]);
let b = Image::filled(3, 4, [0.0, 0.0, 1.0, 1.0]);
let up = interleave(&a, &b, true);
let lo = interleave(&a, &b, false);
for y in 0..4u32 {
assert_eq!(up.data[up.idx(1, y)][0], if y % 2 == 0 { 1.0 } else { 0.0 }, "upper first: line {y}");
assert_eq!(lo.data[lo.idx(1, y)][0], if y % 2 == 1 { 1.0 } else { 0.0 }, "lower first: line {y}");
}
}
#[test]
fn crop_and_pad() {
let mut img = Image::new(4, 3);
for (i, p) in img.data.iter_mut().enumerate() {
*p = [i as f32, 0.0, 0.0, 1.0];
}
let c = crop(&img, 1, 1, 0, 1);
assert_eq!((c.width, c.height), (2, 2));
assert_eq!(c.data[0][0], 5.0);
let p = crop(&img, -1, 0, 0, 0);
assert_eq!((p.width, p.height), (4, 4));
assert_eq!(p.data[0], [0.0; 4]);
assert_eq!(p.data[4][0], 0.0);
assert_eq!(p.data[5][0], 1.0);
}
}

View File

@ -5,19 +5,22 @@
//! (size patched at the end), Info, Tracks and one Cluster per render batch. Video frames are
//! SimpleBlocks; with alpha (Channels: RGB + Alpha) each frame is a BlockGroup whose
//! BlockAdditional (BlockAddID 1) carries a second VP9 frame coding the alpha channel as luma,
//! with `AlphaMode` 1 on the track, as WebM defines. VP9 comes from `effectcraft-vp9enc` (every
//! frame a key frame), Opus from `effectcraft-opusenc` (CELT, 48 kHz stereo, 20 ms packets,
//! with `AlphaMode` 1 on the track, as WebM defines. VP9 comes from `effectcraft-vp9enc` (key
//! frames every two seconds, inter frames between them: SimpleBlocks carry the key flag, inter
//! BlockGroups a ReferenceBlock; the alpha stream's key frames line up with the colour stream's),
//! Opus from `effectcraft-opusenc` (CELT, 48 kHz stereo, 20 ms packets,
//! `CodecDelay` = pre-skip). WAV (RIFF, 16-bit PCM) and AIFF (big-endian 16-bit, 80-bit
//! extended sample rate) follow their published file layouts.
use std::io::{Seek, SeekFrom, Write};
use effectcraft_project::Comp;
use effectcraft_project::render_queue::Channels;
use effectcraft_project::render_queue::{AudioFormat, Channels};
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use rayon::prelude::*;
use crate::{Job, Report, Result, State, batch_size, io, render_frame, rgba8, wants_audio};
use crate::encode::{mix, pcm_bytes};
use crate::{Cx, Report, Result, State, batch_size, io, wants_audio};
pub(crate) const OPUS_RATE: u32 = 48_000;
@ -78,14 +81,18 @@ fn block(track: u64, rel: i16, flags: u8, data: &[u8]) -> Vec<u8> {
b
}
pub(crate) struct Frame {
pub(crate) ms: i64,
pub(crate) track: u64,
pub(crate) data: Vec<u8>,
pub(crate) alpha: Option<Vec<u8>>,
struct Frame {
ms: i64,
track: u64,
/// A key frame (VP9) or any audio packet.
key: bool,
/// Timestamp of the previous video frame (ReferenceBlock of inter frames).
prev_ms: Option<i64>,
data: Vec<u8>,
alpha: Option<Vec<u8>>,
}
pub(crate) fn cluster(frames: &mut [Frame]) -> Vec<u8> {
fn cluster(frames: &mut [Frame]) -> Vec<u8> {
frames.sort_by_key(|f| (f.ms, f.track));
let base = frames.first().map_or(0, |f| f.ms);
let mut body = vec![];
@ -93,10 +100,15 @@ pub(crate) fn cluster(frames: &mut [Frame]) -> Vec<u8> {
for f in frames.iter() {
let rel = (f.ms - base).clamp(i16::MIN as i64, i16::MAX as i64) as i16;
match &f.alpha {
None => el(&mut body, 0xA3, &block(f.track, rel, 0x80, &f.data)),
None => el(&mut body, 0xA3, &block(f.track, rel, if f.key { 0x80 } else { 0x00 }, &f.data)),
Some(a) => {
let mut g = vec![];
el(&mut g, 0xA1, &block(f.track, rel, 0x00, &f.data));
if let (false, Some(p)) = (f.key, f.prev_ms) {
// ReferenceBlock: a signed timestamp relative to this block (an inter frame).
let d = (p - f.ms).clamp(i16::MIN as i64, -1) as i16;
el(&mut g, 0xFB, &d.to_be_bytes());
}
let mut more = vec![];
el_uint(&mut more, 0xEE, 1);
el(&mut more, 0xA5, a);
@ -112,27 +124,43 @@ pub(crate) fn cluster(frames: &mut [Frame]) -> Vec<u8> {
out
}
/// The Opus encoder of a WebM output (stereo, the module's bitrate and application).
pub(crate) fn opus_encoder(job: &Job) -> effectcraft_opusenc::OpusEncoder {
/// The Opus encoder of a WebM output: the module's channel count, bitrate and application.
pub(crate) fn opus_encoder(job: &Cx) -> effectcraft_opusenc::OpusEncoder {
// TEMP(M13.4): application mapping lands with the SILK encoder.
effectcraft_opusenc::OpusEncoder::new(2, job.output.opus_bitrate_kbps.clamp(6, 510) * 1000)
let channels = if job.output.audio_channels == 1 { 1 } else { 2 };
effectcraft_opusenc::OpusEncoder::new(channels, job.output.opus_bitrate_kbps.clamp(6, 510) * 1000)
}
pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
pub(crate) fn webm(job: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
if job.output.webm_codec == effectcraft_project::render_queue::WebmVideoCodec::Av1 {
return crate::webm_av1::webm_av1(job, comp, w, h, st);
}
let rate = job.settings.rate(comp);
let alpha = job.output.channels == Channels::Rgba;
let colour_channels = if job.output.channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb };
let quality = job.output.quality.clamp(1, 100);
let cfg = effectcraft_vp9enc::EncoderConfig { width: w, height: h, quality, full_range: false };
let fps = rate.as_f64().max(1.0);
let cfg = effectcraft_vp9enc::EncoderConfig {
width: w,
height: h,
quality,
full_range: false,
keyframe_interval: if job.output.keyframe_interval > 0 { job.output.keyframe_interval } else { (fps * 2.0).round().max(1.0) as u32 },
target_kbps: job.output.webm_bitrate.then_some(job.output.bitrate_kbps.max(50)),
frame_rate: fps,
loop_filter: true,
};
let mut color_enc = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone());
let mut alpha_enc = alpha.then(|| effectcraft_vp9enc::Vp9Encoder::new(cfg.clone()));
let with_audio = wants_audio(job);
let opus_channels = if job.output.audio_channels == 1 { 1usize } else { 2 };
let mut opus = with_audio.then(|| opus_encoder(job));
let total = st.total;
let frame_ms = |k: u64| -> i64 { ((k as i128 * 1000 * rate.den as i128 + rate.num as i128 / 2) / rate.num as i128) as i64 };
let duration_ms = frame_ms(total) as f64;
let mut file = crate::out::create(job.sink, job.path)?;
let path = job.place(job.path, 1);
let mut file = crate::out::create(job.sink, &path)?;
// EBML header.
let mut head = vec![];
let mut eb = vec![];
@ -186,7 +214,7 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re
el_uint(&mut a, 0x56BB, 80_000_000);
let mut au = vec![];
el_float(&mut au, 0xB5, OPUS_RATE as f64);
el_uint(&mut au, 0x9F, 2);
el_uint(&mut au, 0x9F, opus_channels as u64);
el(&mut a, 0xE1, &au);
el(&mut tracks, TRACK_ENTRY, &a);
}
@ -205,10 +233,10 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re
if end > cursor {
let n = (end - cursor) as usize;
let start = Tick(((cursor as i128 * TICKS_PER_SECOND as i128) / OPUS_RATE as i128) as i64);
pcm_left.extend(effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, start, n, OPUS_RATE));
pcm_left.extend(mix(job, start, n, OPUS_RATE));
cursor = end;
}
let fs = effectcraft_opusenc::OpusEncoder::FRAME_SIZE * 2;
let fs = effectcraft_opusenc::OpusEncoder::FRAME_SIZE * opus_channels;
if finish && !pcm_left.is_empty() {
// Pad the last packet (pre-skip + padding cover the encoder delay).
let pad = (fs - pcm_left.len() % fs) % fs + fs;
@ -217,7 +245,7 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re
while pcm_left.len() >= fs {
let pkt = enc.encode_float(&pcm_left[..fs]);
pcm_left.drain(..fs);
frames.push(Frame { ms: packets as i64 * 20, track: 2, data: pkt, alpha: None });
frames.push(Frame { ms: packets as i64 * 20, track: 2, key: true, prev_ms: None, data: pkt, alpha: None });
packets += 1;
}
};
@ -226,23 +254,28 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re
let mut i = 0;
while i < total {
let end = (i + batch).min(total);
let encoded: Vec<(Vec<u8>, Option<Vec<u8>>)> = (i..end)
.into_par_iter()
.map(|k| {
let px = rgba8(&render_frame(job, comp, k), comp, if alpha { Channels::Rgba } else { Channels::Rgb }, w, h);
let mut enc = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone());
let (y, u, vv) = effectcraft_vp9enc::rgba_to_yuv420(&px, w, h, false);
let color = enc.encode_yuv420(&y, &u, &vv);
let a = alpha.then(|| {
let mut ea = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone());
let (y, u, vv) = effectcraft_vp9enc::alpha_to_yuv420(&px, w, h);
ea.encode_yuv420(&y, &u, &vv)
});
(color, a)
})
.collect();
let mut frames: Vec<Frame> =
encoded.into_iter().enumerate().map(|(j, (data, alpha))| Frame { ms: frame_ms(i + j as u64), track: 1, data, alpha }).collect();
// Frames render in parallel; the VP9 streams (inter-coded) encode in order, colour and
// alpha side by side.
let pixels: Vec<Vec<u8>> =
(i..end).into_par_iter().map(|k| job.pixels(&job.frame(comp, k), comp, if alpha { Channels::Rgba } else { colour_channels }, w, h)).collect();
let mut frames: Vec<Frame> = Vec::with_capacity(pixels.len());
for (j, px) in pixels.iter().enumerate() {
let (color, a) = rayon::join(
|| {
let (y, u, vv) = effectcraft_vp9enc::rgba_to_yuv420(px, w, h, false);
color_enc.encode_frame(&y, &u, &vv, false)
},
|| {
alpha_enc.as_mut().map(|ea| {
let (y, u, vv) = effectcraft_vp9enc::alpha_to_yuv420(px, w, h);
ea.encode_frame(&y, &u, &vv, false).data
})
},
);
let k = i + j as u64;
frames.push(Frame { ms: frame_ms(k), track: 1, key: color.key, prev_ms: (k > 0).then(|| frame_ms(k - 1)), data: color.data, alpha: a });
}
drop(pixels);
let t_end = if end >= total { span_end } else { job.settings.frame_time(comp, end) };
audio_until(t_end, &mut frames, end >= total);
let c = cluster(&mut frames);
@ -268,7 +301,7 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re
file.write_all(&size).map_err(io)?;
file.seek(SeekFrom::End(0)).map_err(io)?;
let bytes = file.finish()?;
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio })
Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio, log: None, overflow: vec![] })
}
// ---------------------------------------------------------------- audio-only outputs
@ -286,14 +319,25 @@ fn ieee_extended(v: f64) -> [u8; 10] {
out
}
/// WAV or AIFF: the comp's audio over the render span, 16-bit stereo.
pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) -> Result<Report> {
/// WAV or AIFF: the comp's audio over the render span (Audio Output: sample rate, mono/stereo,
/// 16/24-bit integer or 32-bit float; AIFF has no float in plain AIFF, so it writes 24-bit).
pub(crate) fn audio_file(job: &Cx, comp: &Comp, aiff: bool, st: &mut State) -> Result<Report> {
let sr = job.output.audio_sample_rate.clamp(8_000, 192_000);
let channels = if job.output.audio_channels == 1 { 1u16 } else { 2 };
let fmt = match job.output.audio_format {
AudioFormat::F32 if aiff => AudioFormat::S24,
f => f,
};
let bytes_per_sample: u16 = match fmt {
AudioFormat::S16 => 2,
AudioFormat::S24 => 3,
AudioFormat::F32 => 4,
};
let (span_start, span_end) = job.settings.span(comp);
let start = span_start.to_units_floor(sr as i64);
let end = span_end.to_units_floor(sr as i64);
let n = (end - start).max(0) as usize;
let mut pcm = Vec::with_capacity(n * 4);
let mut pcm = Vec::with_capacity(n * (channels * bytes_per_sample) as usize);
// Mix in one-second blocks, reporting progress in frames.
let block = sr as usize;
let mut done = 0usize;
@ -302,10 +346,7 @@ pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) ->
while done < n {
let k = block.min(n - done);
let t = Tick((((start + done as i64) as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64);
for s in effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, t, k, sr) {
let v = (s.clamp(-1.0, 1.0) * 32767.0).round() as i16;
pcm.extend_from_slice(&if aiff { v.to_be_bytes() } else { v.to_le_bytes() });
}
pcm.extend(pcm_bytes(&mix(job, t, k, sr), fmt, aiff));
done += k;
let now = (done as u64 * total_frames / n.max(1) as u64).min(total_frames);
if now > reported && st.total > 0 {
@ -316,13 +357,14 @@ pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) ->
if reported < st.total {
st.advance(st.total - reported)?;
}
let block_align = channels * bytes_per_sample;
let frames = (pcm.len() / block_align as usize) as u32;
let mut out = vec![];
let frames = (pcm.len() / 4) as u32;
if aiff {
let mut comm = vec![];
comm.extend_from_slice(&2u16.to_be_bytes());
comm.extend_from_slice(&channels.to_be_bytes());
comm.extend_from_slice(&frames.to_be_bytes());
comm.extend_from_slice(&16u16.to_be_bytes());
comm.extend_from_slice(&(bytes_per_sample * 8).to_be_bytes());
comm.extend_from_slice(&ieee_extended(sr as f64));
let ssnd_len = 8 + pcm.len();
out.extend_from_slice(b"FORM");
@ -339,24 +381,30 @@ pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) ->
out.push(0);
}
} else {
// WAVE_FORMAT_PCM (1) or WAVE_FORMAT_IEEE_FLOAT (3).
let tag: u16 = if fmt == AudioFormat::F32 { 3 } else { 1 };
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&((36 + pcm.len()) as u32).to_le_bytes());
out.extend_from_slice(&((36 + pcm.len() + pcm.len() % 2) as u32).to_le_bytes());
out.extend_from_slice(b"WAVEfmt ");
out.extend_from_slice(&16u32.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&2u16.to_le_bytes());
out.extend_from_slice(&tag.to_le_bytes());
out.extend_from_slice(&channels.to_le_bytes());
out.extend_from_slice(&sr.to_le_bytes());
out.extend_from_slice(&(sr * 4).to_le_bytes());
out.extend_from_slice(&4u16.to_le_bytes());
out.extend_from_slice(&16u16.to_le_bytes());
out.extend_from_slice(&(sr * block_align as u32).to_le_bytes());
out.extend_from_slice(&block_align.to_le_bytes());
out.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes());
out.extend_from_slice(b"data");
out.extend_from_slice(&(pcm.len() as u32).to_le_bytes());
out.extend_from_slice(&pcm);
if pcm.len() % 2 == 1 {
out.push(0);
}
}
let mut file = crate::out::create(job.sink, job.path)?;
let path = job.place(job.path, out.len() as u64);
let mut file = crate::out::create(job.sink, &path)?;
file.write_all(&out).map_err(io)?;
let bytes = file.finish()?;
Ok(Report { path: job.path.to_string(), frames: 0, width: 0, height: 0, seconds: 0.0, bytes, audio: true })
Ok(Report { path, frames: 0, width: 0, height: 0, seconds: 0.0, bytes, audio: true, log: None, overflow: vec![] })
}
#[cfg(test)]

View File

@ -12,8 +12,9 @@ use effectcraft_time::{TICKS_PER_SECOND, Tick};
use rayon::prelude::*;
use crate::encode::VideoEncoder;
use crate::encode::mix;
use crate::webm::{CLUSTER, EBML, INFO, OPUS_RATE, SEGMENT, TRACK_ENTRY, TRACKS, el, el_float, el_str, el_uint, id_bytes, opus_encoder, vint};
use crate::{Job, Report, Result, State, batch_size, io, render_frame, rgba8, wants_audio};
use crate::{Cx, Report, Result, State, batch_size, io, wants_audio};
struct Block {
ms: i64,
@ -40,16 +41,18 @@ fn cluster(blocks: &mut [Block]) -> Vec<u8> {
out
}
pub(crate) fn webm_av1(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
pub(crate) fn webm_av1(job: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
let rate = job.settings.rate(comp);
let mut venc = crate::hevc_av1::Av1::new(w, h, rate, job.output)?;
let with_audio = wants_audio(job);
let opus_channels = if job.output.audio_channels == 1 { 1usize } else { 2 };
let mut opus = with_audio.then(|| opus_encoder(job));
let total = st.total;
let frame_ms = |k: u64| -> i64 { ((k as i128 * 1000 * rate.den as i128 + rate.num as i128 / 2) / rate.num as i128) as i64 };
let duration_ms = frame_ms(total) as f64;
let mut file = crate::out::create(job.sink, job.path)?;
let path = job.place(job.path, 1);
let mut file = crate::out::create(job.sink, &path)?;
let mut head = vec![];
let mut eb = vec![];
el_uint(&mut eb, 0x4286, 1);
@ -96,7 +99,7 @@ pub(crate) fn webm_av1(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -
el_uint(&mut a, 0x56BB, 80_000_000);
let mut au = vec![];
el_float(&mut au, 0xB5, OPUS_RATE as f64);
el_uint(&mut au, 0x9F, 2);
el_uint(&mut au, 0x9F, opus_channels as u64);
el(&mut a, 0xE1, &au);
el(&mut tracks, TRACK_ENTRY, &a);
}
@ -114,10 +117,10 @@ pub(crate) fn webm_av1(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -
if end > cursor {
let n = (end - cursor) as usize;
let start = Tick(((cursor as i128 * TICKS_PER_SECOND as i128) / OPUS_RATE as i128) as i64);
pcm_left.extend(effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, start, n, OPUS_RATE));
pcm_left.extend(mix(job, start, n, OPUS_RATE));
cursor = end;
}
let fs = effectcraft_opusenc::OpusEncoder::FRAME_SIZE * 2;
let fs = effectcraft_opusenc::OpusEncoder::FRAME_SIZE * opus_channels;
if finish && !pcm_left.is_empty() {
let pad = (fs - pcm_left.len() % fs) % fs + fs;
pcm_left.extend(std::iter::repeat_n(0.0, pad));
@ -144,7 +147,9 @@ pub(crate) fn webm_av1(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -
let mut blocks = vec![];
while i < total {
let end = (i + batch).min(total);
let frames: Vec<Vec<u8>> = (i..end).into_par_iter().map(|k| rgba8(&render_frame(job, comp, k), comp, Channels::Rgb, w, h)).collect();
// RGB, or the alpha matte alone (Channels: Alpha); AV1 in WebM carries no alpha track.
let channels = if job.output.channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb };
let frames: Vec<Vec<u8>> = (i..end).into_par_iter().map(|k| job.pixels(&job.frame(comp, k), comp, channels, w, h)).collect();
for (j, px) in frames.iter().enumerate() {
let k = i + j as u64;
for p in venc.encode(px, k)? {
@ -168,5 +173,5 @@ pub(crate) fn webm_av1(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -
file.write_all(&size).map_err(io)?;
file.seek(SeekFrom::End(0)).map_err(io)?;
let bytes = file.finish()?;
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio })
Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio, log: None, overflow: vec![] })
}

View File

@ -50,7 +50,19 @@ fn project(audio: Option<&Path>) -> (Project, ItemId) {
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
let path = path.to_string_lossy().to_string();
let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None, nested_switches: true };
let job = Job {
project: p,
footage,
expr: None,
accel: None,
comp: cid,
settings: &s,
output: om,
path: &path,
sink: None,
nested_switches: true,
options: Default::default(),
};
let mut last = Progress::default();
let r = export(&job, &mut |pr| {
last = *pr;
@ -135,7 +147,7 @@ fn ffmpeg_check(path: &Path, decoder: Option<&str>, tol: f32) {
fn module(format: OutputFormat, keyint: u32) -> OutputModule {
let mut om = OutputModule::for_format(format);
om.codec.keyframe_interval = keyint;
om.keyframe_interval = keyint;
om.codec.rate_control = RateControlMode::Quality;
om.codec.quality = 80;
om.audio = AudioOutput::Off;

View File

@ -0,0 +1,322 @@
//! Render Settings and Output Module features end to end (through a [`Sink`], so nothing touches
//! the disk): field rendering and 3:2 pulldown, crop / region of interest / resize, alpha-only
//! and premultiplied output, the solo and guide-layer overrides, the render log, storage
//! overflow and PCM audio formats.
use std::sync::{Arc, Mutex};
use effectcraft_color::Label;
use effectcraft_export::render_queue::*;
use effectcraft_export::{Job, JobOptions, Report, Sink, StorageQuota, export};
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
use effectcraft_render::NoFootage;
use effectcraft_time::{FrameRate, Tick};
const W: u32 = 64;
const H: u32 = 48;
/// A comp at `rate`, black, with a white bar 8 px wide sweeping left to right (64 px/s) and a
/// static red block at the bottom left; `extra` adds layers.
fn project(rate: FrameRate, extra: impl FnOnce(&mut Project, &mut Comp)) -> (Project, ItemId) {
let mut p = Project::default();
let mut comp = Comp::new(W, H, rate, Tick::from_seconds_f64(2.0));
comp.background = [0.0, 0.0, 0.0];
let bar = p.add_item("Bar", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 8, height: 32, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Bar", LayerSource::Solid { item: bar }, (8, 32), None);
l.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([0.0, 16.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([64.0, 16.0, 0.0]))];
comp.layers.push(l);
let red = p.add_item("Block", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 16, height: 8, pixel_aspect: 1.0 }));
let mut b = build::layer(&mut p, &comp, "Block", LayerSource::Solid { item: red }, (16, 8), None);
b.props.prop_mut("transform/position").unwrap().value = Value::Vec3([8.0, 44.0, 0.0]);
comp.layers.push(b);
extra(&mut p, &mut comp);
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
type Files = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
fn custom(start: f64, end: f64) -> RenderSettings {
RenderSettings { time_span: TimeSpan::Custom { start: Tick::from_seconds_f64(start), end: Tick::from_seconds_f64(end) }, ..Default::default() }
}
fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str, options: JobOptions, cancel: bool) -> (Result<Report, String>, Files) {
let files: Files = Default::default();
let f = files.clone();
let sink: Box<Sink> = Box::new(move |path: &str, data: Vec<u8>| f.lock().unwrap().push((path.to_string(), data)));
let job = Job {
project: p,
footage: &NoFootage,
expr: None,
accel: None,
comp: cid,
settings: s,
output: om,
path,
sink: Some(&*sink),
options,
nested_switches: true,
};
let r = export(&job, &mut |pr| !(cancel && pr.done > 0)).map_err(|e| e.to_string());
(r, files)
}
fn run(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str) -> Files {
let (r, files) = run_with(p, cid, s, om, path, JobOptions::default(), false);
r.expect("export");
files
}
fn png(files: &Files, i: usize) -> image::RgbaImage {
let f = files.lock().unwrap();
let pngs: Vec<&(String, Vec<u8>)> = f.iter().filter(|(p, _)| p.ends_with(".png")).collect();
let mut pngs = pngs;
pngs.sort_by(|a, b| a.0.cmp(&b.0));
image::load_from_memory(&pngs[i].1).expect("png").to_rgba8()
}
fn row(img: &image::RgbaImage, y: u32) -> Vec<u8> {
(0..img.width()).flat_map(|x| img.get_pixel(x, y).0).collect()
}
#[test]
fn field_render_interleaves_two_times() {
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let om = OutputModule::for_format(OutputFormat::PngSequence);
// Frame 10 at 25 fps with fields: 0.40 s and 0.42 s.
for (fr, upper) in [(FieldRender::UpperFirst, true), (FieldRender::LowerFirst, false)] {
let s = RenderSettings { field_render: fr, ..custom(0.4, 0.44) };
let fields = png(&run(&p, cid, &s, &om, "/f/field_[#####].png"), 0);
// Progressive references at 50 fps: frames 20 (0.40 s) and 21 (0.42 s).
let r = RenderSettings { frame_rate: Some(FrameRate::new(50, 1)), ..custom(0.4, 0.44) };
let refs = run(&p, cid, &r, &om, "/f/ref_[#####].png");
let (a, b) = (png(&refs, 0), png(&refs, 1));
assert_ne!(row(&a, 10), row(&b, 10), "the bar moved between the fields");
for y in 0..H {
let first = (y % 2 == 0) == upper;
assert_eq!(row(&fields, y), row(if first { &a } else { &b }, y), "{fr:?}: line {y}");
}
}
}
#[test]
fn pulldown_follows_the_cadence() {
let ntsc = FrameRate::new(30000, 1001);
let (p, cid) = project(ntsc, |_, _| {});
let om = OutputModule::for_format(OutputFormat::PngSequence);
let five = 5.0 * 1001.0 / 30000.0;
let s = RenderSettings { field_render: FieldRender::UpperFirst, pulldown: Pulldown::Wssww, ..custom(0.0, five) };
let out = run(&p, cid, &s, &om, "/p/pd_[#####].png");
assert_eq!(out.lock().unwrap().len(), 5);
// Film frames at 23.976 fps.
let r = RenderSettings { frame_rate: Some(FrameRate::new(24000, 1001)), ..custom(0.0, 0.2) };
let film = run(&p, cid, &r, &om, "/p/film_[#####].png");
let mut pattern = String::new();
for k in 0..5 {
let (a, b) = Pulldown::Wssww.fields(k as u64).unwrap();
pattern.push(if a == b { 'W' } else { 'S' });
let img = png(&out, k);
let (fa, fb) = (png(&film, a as usize), png(&film, b as usize));
for y in 0..H {
assert_eq!(row(&img, y), row(if y % 2 == 0 { &fa } else { &fb }, y), "frame {k} line {y}");
}
}
assert_eq!(pattern, "WSSWW");
}
#[test]
fn crop_roi_and_resize() {
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let s = custom(0.4, 0.44);
let full = png(&run(&p, cid, &s, &OutputModule::for_format(OutputFormat::PngSequence), "/c/full_[#####].png"), 0);
let mut om = OutputModule::for_format(OutputFormat::PngSequence);
om.crop = Crop { enabled: true, top: 4, left: 10, bottom: 6, right: 2, ..Default::default() };
let c = png(&run(&p, cid, &s, &om, "/c/crop_[#####].png"), 0);
assert_eq!((c.width(), c.height()), (52, 38));
for (x, y) in [(0, 0), (20, 11), (51, 37), (3, 30)] {
assert_eq!(c.get_pixel(x, y), full.get_pixel(x + 10, y + 4), "({x}, {y})");
}
// Region of interest at half resolution.
om.crop = Crop { enabled: true, use_roi: true, roi: Some([16.0, 8.0, 32.0, 32.0]), ..Default::default() };
let half = RenderSettings { resolution: 0.5, ..s.clone() };
let r = png(&run(&p, cid, &half, &om, "/c/roi_[#####].png"), 0);
assert_eq!((r.width(), r.height()), (16, 16));
// Resize (fixed size, low and high quality) after the crop.
om.crop = Crop::default();
for q in [ResizeQuality::Low, ResizeQuality::High] {
om.resize = Resize { enabled: true, width: 32, height: 20, lock_aspect: false, quality: q };
let z = png(&run(&p, cid, &s, &om, "/c/resize_[#####].png"), 0);
assert_eq!((z.width(), z.height()), (32, 20));
// The red block (bottom left) survives the resize.
let px = z.get_pixel(4, 18).0;
assert!(px[0] > 150 && px[1] < 80, "{q:?}: {px:?}");
}
om.resize.lock_aspect = true;
let z = png(&run(&p, cid, &s, &om, "/c/aspect_[#####].png"), 0);
assert_eq!((z.width(), z.height()), (32, 24));
}
#[test]
fn alpha_only_and_premultiplied() {
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
// A half-transparent green layer over the right half.
let g = p.add_item("Green", Label::Green, None, ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 32, height: 48, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "Green", LayerSource::Solid { item: g }, (32, 48), None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([48.0, 24.0, 0.0]);
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0);
comp.layers.insert(0, l);
});
let s = custom(0.0, 0.04);
let mut om = OutputModule::for_format(OutputFormat::PngSequence);
om.channels = Channels::Alpha;
let a = png(&run(&p, cid, &s, &om, "/a/alpha_[#####].png"), 0);
assert_eq!(a.get_pixel(60, 2).0[0], 128, "half-opaque green");
assert_eq!(a.get_pixel(20, 2).0[0], 0, "transparent");
assert_eq!(a.get_pixel(8, 44).0[0], 255, "opaque block");
om.channels = Channels::Rgba;
let straight = png(&run(&p, cid, &s, &om, "/a/s_[#####].png"), 0);
om.alpha_mode = AlphaMode::Premultiplied;
let premul = png(&run(&p, cid, &s, &om, "/a/p_[#####].png"), 0);
assert_eq!(straight.get_pixel(60, 2).0, [0, 255, 0, 128]);
assert_eq!(premul.get_pixel(60, 2).0, [0, 128, 0, 128]);
}
#[test]
fn solo_and_guide_layer_overrides() {
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
let g = p.add_item("Guide", Label::Green, None, ItemKind::Solid(Solid { color: [0.0, 0.0, 1.0], width: 8, height: 8, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "Guide", LayerSource::Solid { item: g }, (8, 8), None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([56.0, 44.0, 0.0]);
l.switches.guide = true;
comp.layers.insert(0, l);
// Solo the moving bar.
comp.layers[1].switches.solo = true;
});
let om = OutputModule::for_format(OutputFormat::PngSequence);
let s = custom(0.4, 0.44);
let cur = png(&run(&p, cid, &s, &om, "/s/cur_[#####].png"), 0);
assert_eq!(cur.get_pixel(8, 44).0[0], 0, "solo: the red block is hidden");
assert_eq!(cur.get_pixel(56, 44).0[2], 0, "guide layers off by default");
let off = RenderSettings { solo: CurrentOrOff::AllOff, guide_layers: CurrentOrOff::Current, ..s.clone() };
let all = png(&run(&p, cid, &off, &om, "/s/off_[#####].png"), 0);
assert_eq!(all.get_pixel(8, 44).0[0], 255, "Solo Switches: All Off");
// (with solo off the guide renders too: Guide Layers: Current Settings)
assert_eq!(all.get_pixel(56, 44).0[2], 255);
}
#[test]
fn render_logs() {
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let om = OutputModule::for_format(OutputFormat::PngSequence);
let s = RenderSettings { field_render: FieldRender::UpperFirst, name: "DV-ish".into(), ..custom(0.0, 0.2) };
let log_of = |files: &Files| {
files.lock().unwrap().iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(p, d)| (p.clone(), String::from_utf8(d.clone()).unwrap()))
};
// Errors Only + success: no log.
let (r, files) = run_with(&p, cid, &s, &om, "/l/a_[#####].png", JobOptions::default(), false);
assert!(r.unwrap().log.is_none());
assert!(log_of(&files).is_none());
// Plus Per Frame Info: settings and one line per frame.
let opts = JobOptions { log: RenderLog::PlusPerFrameInfo, label: "#1 Comp".into(), ..Default::default() };
let (r, files) = run_with(&p, cid, &s, &om, "/l/b_[#####].png", opts, false);
let r = r.unwrap();
let (path, text) = log_of(&files).expect("log");
assert_eq!(r.log.as_deref(), Some(path.as_str()));
assert_eq!(path, "/l/b_RenderLog.txt");
assert!(text.contains("Item: #1 Comp") && text.contains("Result: Done (5 frames"), "{text}");
assert!(text.contains("Field Render: Upper Field First") && text.contains("Template: DV-ish") && text.contains("Format: PNG Sequence"), "{text}");
assert_eq!(text.lines().filter(|l| l.contains("rendered in")).count(), 5, "{text}");
assert!(text.contains("Frame 0 (0.000 s): rendered in") && text.contains("Frame 4 (0.160 s)"), "{text}");
// Plus Settings: no per-frame lines.
let opts = JobOptions { log: RenderLog::PlusSettings, ..Default::default() };
let (_, files) = run_with(&p, cid, &s, &om, "/l/c.gif", opts, false);
let (_, text) = log_of(&files).expect("log");
assert!(text.contains("Render Settings:") && !text.contains("rendered in"), "{text}");
// Errors Only + failure (the composition is gone): the error is logged.
let gif = OutputModule::for_format(OutputFormat::Gif);
let (r, files) = run_with(&p, ItemId(424_242), &s, &gif, "/l/d.gif", JobOptions::default(), false);
assert!(r.is_err());
let (path, text) = log_of(&files).expect("error log");
assert_eq!(path, "/l/d_RenderLog.txt");
assert!(text.contains("Result: Failed") && text.contains("Error: composition not found") && !text.contains("Render Settings:"), "{text}");
// A user stop is not an error: no log.
let (r, files) = run_with(&p, cid, &s, &gif, "/l/e.gif", JobOptions::default(), true);
assert!(r.is_err());
assert!(log_of(&files).is_none());
}
/// The primary folder holds `limit` bytes; overflow folders are unlimited.
struct Quota {
limit: u64,
used: Mutex<u64>,
}
impl StorageQuota for Quota {
fn has_room(&self, path: &str, bytes: u64) -> bool {
if !path.starts_with("/primary/") {
return true;
}
let mut u = self.used.lock().unwrap();
if *u + bytes > self.limit {
return false;
}
*u += bytes;
true
}
}
#[test]
fn storage_overflow_moves_files_to_the_overflow_folder() {
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let om = OutputModule::for_format(OutputFormat::PngSequence);
let s = RenderSettings { skip_existing: false, ..custom(0.0, 0.4) };
let q = Quota { limit: 1, used: Mutex::new(0) };
let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], log: RenderLog::PlusSettings, ..Default::default() };
let (r, files) = run_with(&p, cid, &s, &om, "/primary/seq_[#####].png", opts, false);
let r = r.unwrap();
assert_eq!(r.overflow.len(), 10, "{:?}", r.overflow);
let f = files.lock().unwrap();
assert_eq!(f.iter().filter(|(p, _)| p.starts_with("/overflow/seq_") && p.ends_with(".png")).count(), 10);
let log = f.iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(_, d)| String::from_utf8_lossy(d).to_string()).unwrap();
assert!(log.contains("Storage overflow: 10 file(s)"), "{log}");
drop(f);
// Use Storage Overflow off: everything stays in place.
let off = RenderSettings { storage_overflow: false, ..s };
let q = Quota { limit: 1, used: Mutex::new(0) };
let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], ..Default::default() };
let (r, files) = run_with(&p, cid, &off, &om, "/primary/seq_[#####].png", opts, false);
assert!(r.unwrap().overflow.is_empty());
assert!(files.lock().unwrap().iter().all(|(p, _)| p.starts_with("/primary/")));
}
#[test]
fn wav_and_aiff_channels_and_formats() {
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let s = custom(0.0, 0.5);
let mut om = OutputModule::for_format(OutputFormat::Wav);
om.audio_channels = 1;
om.audio_format = AudioFormat::S24;
om.audio_sample_rate = 44_100;
let files = run(&p, cid, &s, &om, "/w/a.wav");
let d = files.lock().unwrap()[0].1.clone();
assert_eq!(&d[..4], b"RIFF");
let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
let u32_at = |o: usize| u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]);
assert_eq!((u16_at(20), u16_at(22), u32_at(24), u16_at(32), u16_at(34)), (1, 1, 44_100, 3, 24));
assert_eq!(u32_at(40), 22_050 * 3, "half a second of mono 24-bit");
om.audio_channels = 2;
om.audio_format = AudioFormat::F32;
let files = run(&p, cid, &s, &om, "/w/b.wav");
let d = files.lock().unwrap()[0].1.clone();
let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
assert_eq!((u16_at(20), u16_at(22), u16_at(32), u16_at(34)), (3, 2, 8, 32), "IEEE float stereo");
// AIFF has no float: 24-bit.
let mut aiff = OutputModule::for_format(OutputFormat::Aiff);
aiff.audio_format = AudioFormat::F32;
let files = run(&p, cid, &s, &aiff, "/w/c.aif");
let d = files.lock().unwrap()[0].1.clone();
assert_eq!(&d[8..12], b"AIFF");
assert_eq!(u16::from_be_bytes([d[26], d[27]]), 24, "sample size");
}

View File

@ -55,7 +55,19 @@ fn settings() -> RenderSettings {
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
let s = settings();
let path = path.to_string_lossy().to_string();
let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None, nested_switches: true };
let job = Job {
project: p,
footage,
expr: None,
accel: None,
comp: cid,
settings: &s,
output: om,
path: &path,
sink: None,
options: Default::default(),
nested_switches: true,
};
let mut last = Progress::default();
let mut calls = 0;
let r = export(&job, &mut |pr| {
@ -167,8 +179,19 @@ fn h264_odd_size_is_cropped_even() {
let comp = p.comp(cid).expect("comp");
assert_eq!(s.output_size(comp).0 % 2, 1);
let path = d.join("odd.mp4").to_string_lossy().to_string();
let job =
Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None, nested_switches: true };
let job = Job {
project: &p,
footage: &NoFootage,
expr: None,
accel: None,
comp: cid,
settings: &s,
output: &om,
path: &path,
sink: None,
options: Default::default(),
nested_switches: true,
};
let r = export(&job, &mut |_| true).expect("export");
assert_eq!((r.width % 2, r.height % 2), (0, 0));
}
@ -288,8 +311,19 @@ fn cancel_stops_early() {
let om = OutputModule::for_format(OutputFormat::PngSequence);
let s = settings();
let path = d.join("c_[#####].png").to_string_lossy().to_string();
let job =
Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None, nested_switches: true };
let job = Job {
project: &p,
footage: &NoFootage,
expr: None,
accel: None,
comp: cid,
settings: &s,
output: &om,
path: &path,
sink: None,
options: Default::default(),
nested_switches: true,
};
let r = export(&job, &mut |_| false);
assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled)));
let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0);

View File

@ -61,7 +61,7 @@ fn lossless_is_exact_for_odd_sizes() {
for (w, h) in [(1, 1), (33, 17), (64, 64), (200, 120), (7, 70)] {
for kind in 0..3 {
let (y, u, v) = source(w, h, kind);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false });
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false, ..Default::default() });
let f = e.encode_yuv420(&y, &u, &v);
let p = decode(&f);
assert_eq!((p.width, p.height), (w as u32, h as u32));
@ -80,7 +80,7 @@ fn lossy_quality_and_size() {
let (y, u, v) = source(w, h, kind);
let mut last = 0usize;
for q in [30u8, 60, 80, 95] {
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: q, full_range: false });
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: q, full_range: false, ..Default::default() });
let f = e.encode_yuv420(&y, &u, &v);
let p = decode(&f);
let got = crop(&plane_u8(&p.y), p.y_stride, w, h);
@ -103,7 +103,7 @@ fn wide_frames_use_tile_columns() {
// 4160 px > 64 superblocks: two tile columns are required.
let (w, h) = (4160, 16);
let (y, u, v) = source(w, h, 0);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 90, full_range: false });
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 90, full_range: false, ..Default::default() });
let f = e.encode_yuv420(&y, &u, &v);
let p = decode(&f);
assert!(psnr(&crop(&plane_u8(&p.y), p.y_stride, w, h), &y) > 35.0);
@ -113,7 +113,7 @@ fn wide_frames_use_tile_columns() {
fn ffmpeg_decodes_our_stream() {
let (w, h) = (96, 64);
let (y, u, v) = source(w, h, 0);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false });
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false, ..Default::default() });
let frame = e.encode_yuv420(&y, &u, &v);
// IVF container (one frame).
let mut ivf = Vec::new();
@ -147,3 +147,176 @@ fn ffmpeg_decodes_our_stream() {
assert_eq!(&out.stdout[..w * h], &y[..], "ffmpeg luma matches (lossless)");
assert_eq!(&out.stdout[w * h..w * h + u.len()], &u[..]);
}
// ---------------------------------------------------------------- inter frames
/// Frame `t` of a moving sequence: a diagonal gradient with a sinusoidal texture and bright
/// squares, all translating by a fractional amount per frame (`kind` 1 adds noise that changes
/// every frame).
fn moving(w: usize, h: usize, t: usize, kind: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let (dx, dy) = (1.75 * t as f64, 0.6 * t as f64);
let mut s = 1234u32 + t as u32 * 77;
let mut rnd = || {
s = s.wrapping_mul(1664525).wrapping_add(1013904223);
(s >> 28) as f64
};
let lum = |x: f64, y: f64| -> f64 {
let g = 40.0 + 120.0 * (x / w as f64) + 50.0 * (y / h as f64);
let tex = 18.0 * ((x * 0.31).sin() * (y * 0.23).cos());
let sq = if (x - 20.0).rem_euclid(64.0) < 18.0 && (y - 10.0).rem_euclid(48.0) < 14.0 { 45.0 } else { 0.0 };
g + tex + sq
};
let mut y = vec![0u8; w * h];
for yy in 0..h {
for x in 0..w {
let n = if kind == 1 { rnd() - 7.5 } else { 0.0 };
y[yy * w + x] = (lum(x as f64 - dx, yy as f64 - dy) + n).round().clamp(0.0, 255.0) as u8;
}
}
let u: Vec<u8> = (0..cw * ch).map(|i| (100.0 + 40.0 * (((i % cw) as f64 * 2.0 - dx) / w as f64)).round() as u8).collect();
let v: Vec<u8> = (0..cw * ch).map(|i| (150.0 - 30.0 * (((i / cw) as f64 * 2.0 - dy) / h as f64)).round() as u8).collect();
(y, u, v)
}
fn planes_of(p: &filmcraft_vp9::Picture, w: usize, h: usize) -> [Vec<u8>; 3] {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
[crop(&plane_u8(&p.y), p.y_stride, w, h), crop(&plane_u8(&p.u), p.uv_stride, cw, ch), crop(&plane_u8(&p.v), p.uv_stride, cw, ch)]
}
/// Encode `n` frames and decode them with one decoder: every decoded frame must equal the
/// encoder's reconstruction. Returns (total bytes, mean luma PSNR vs the source).
fn run_sequence(cfg: EncoderConfig, n: usize, kind: u32) -> (usize, f64) {
let (w, h) = (cfg.width as usize, cfg.height as usize);
let mut e = Vp9Encoder::new(cfg);
let mut d = Decoder::new();
let (mut bytes, mut db) = (0usize, 0.0);
for t in 0..n {
let (y, u, v) = moving(w, h, t, kind);
let f = e.encode_frame(&y, &u, &v, false);
bytes += f.data.len();
let kind_s = if f.key { "key" } else { "inter" };
let mut pics = d.decode(&f.data, t as i64).unwrap_or_else(|err| panic!("{w}x{h} frame {t} ({kind_s}): {err:?}"));
assert_eq!(pics.len(), 1);
let got = planes_of(&pics.remove(0), w, h);
let rec = e.reconstruction().expect("reconstruction");
for k in 0..3 {
if got[k] != rec[k] {
let first = got[k].iter().zip(&rec[k]).position(|(a, b)| a != b).unwrap_or(0);
let pw = if k == 0 { w } else { w.div_ceil(2) };
panic!("{w}x{h} frame {t} ({kind_s}, q{} lf{}): plane {k} differs first at ({}, {})", f.q_idx, f.filter_level, first % pw, first / pw);
}
}
db += psnr(&got[0], &y);
}
(bytes, db / n as f64)
}
#[test]
fn inter_frames_decode_exactly() {
for (w, h) in [(64, 64), (96, 72), (33, 17), (130, 66)] {
for (q, kind) in [(30u8, 0u32), (70, 1), (92, 0), (100, 0)] {
let cfg = EncoderConfig { width: w as u32, height: h as u32, quality: q, keyframe_interval: 6, ..Default::default() };
run_sequence(cfg, 8, kind);
}
}
}
#[test]
fn inter_frames_with_tile_columns_decode_exactly() {
let cfg = EncoderConfig { width: 4160, height: 24, quality: 80, keyframe_interval: 0, ..Default::default() };
run_sequence(cfg, 3, 0);
}
#[test]
fn inter_psnr_rises_with_bitrate() {
let (w, h) = (160, 96);
let mut last = (0usize, 0.0f64);
let mut rows = vec![];
for kbps in [60u32, 150, 400, 1200] {
let cfg = EncoderConfig { width: w, height: h, quality: 60, target_kbps: Some(kbps), frame_rate: 30.0, keyframe_interval: 30, ..Default::default() };
let (bytes, db) = run_sequence(cfg, 20, 0);
rows.push(format!("{kbps} kbps target: {:.0} kbps actual, {db:.2} dB", bytes as f64 * 8.0 * 30.0 / 20.0 / 1000.0));
assert!(bytes > last.0 && db > last.1, "{rows:#?}");
last = (bytes, db);
}
eprintln!("{rows:#?}");
}
#[test]
fn inter_frames_are_much_smaller_than_intra() {
let (w, h) = (192u32, 108u32);
let mut report = vec![];
for q in [50u8, 75, 90] {
let intra = EncoderConfig { width: w, height: h, quality: q, keyframe_interval: 1, ..Default::default() };
let inter = EncoderConfig { keyframe_interval: 0, ..intra.clone() };
let (bi, pi) = run_sequence(intra, 24, 0);
let (bp, pp) = run_sequence(inter, 24, 0);
report.push(format!("q{q}: intra {bi} B {pi:.2} dB, inter {bp} B {pp:.2} dB, ratio {:.2}", bi as f64 / bp as f64));
assert!(pp > pi - 1.0, "{report:#?}");
assert!(bi as f64 >= 3.0 * bp as f64, "{report:#?}");
}
eprintln!("{report:#?}");
}
#[test]
fn ffmpeg_decodes_inter_frames_like_the_encoder() {
let (w, h) = (96usize, 64usize);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 75, keyframe_interval: 5, ..Default::default() });
let n = 7;
let mut ivf = Vec::new();
ivf.extend_from_slice(b"DKIF");
ivf.extend_from_slice(&0u16.to_le_bytes());
ivf.extend_from_slice(&32u16.to_le_bytes());
ivf.extend_from_slice(b"VP90");
ivf.extend_from_slice(&(w as u16).to_le_bytes());
ivf.extend_from_slice(&(h as u16).to_le_bytes());
ivf.extend_from_slice(&30u32.to_le_bytes());
ivf.extend_from_slice(&1u32.to_le_bytes());
ivf.extend_from_slice(&(n as u32).to_le_bytes());
ivf.extend_from_slice(&0u32.to_le_bytes());
let mut recon = vec![];
for t in 0..n {
let (y, u, v) = moving(w, h, t, 0);
let f = e.encode_frame(&y, &u, &v, false);
ivf.extend_from_slice(&(f.data.len() as u32).to_le_bytes());
ivf.extend_from_slice(&(t as u64).to_le_bytes());
ivf.extend_from_slice(&f.data);
recon.push(e.reconstruction().expect("recon"));
}
let dir = std::env::temp_dir().join(format!("effectcraft-vp9-inter-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.ivf");
std::fs::write(&path, &ivf).unwrap();
let out = std::process::Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-i"])
.arg(&path)
.args(["-f", "rawvideo", "-pix_fmt", "yuv420p", "-"])
.output();
let Ok(out) = out else {
eprintln!("ffmpeg not found: skipping oracle");
return;
};
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let fsz = w * h + 2 * w.div_ceil(2) * h.div_ceil(2);
assert_eq!(out.stdout.len(), fsz * n);
for (t, r) in recon.iter().enumerate() {
let frame = &out.stdout[t * fsz..(t + 1) * fsz];
assert_eq!(&frame[..w * h], &r[0][..], "ffmpeg luma of frame {t}");
assert_eq!(&frame[w * h..w * h + r[1].len()], &r[1][..], "ffmpeg U of frame {t}");
}
}
/// Encoder speed on 720p (run with `--ignored --nocapture`).
#[test]
#[ignore]
fn bench_720p() {
let (w, h) = (1280usize, 720usize);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 80, keyframe_interval: 0, ..Default::default() });
for t in 0..6 {
let (y, u, v) = moving(w, h, t, 0);
let t0 = std::time::Instant::now();
let f = e.encode_frame(&y, &u, &v, false);
eprintln!("frame {t} ({}): {} B in {:.0} ms", if f.key { "key" } else { "inter" }, f.data.len(), t0.elapsed().as_secs_f64() * 1000.0);
}
}

View File

@ -16,10 +16,17 @@ effectcraft-media = { workspace = true }
effectcraft-expr = { workspace = true }
effectcraft-script = { workspace = true }
effectcraft-export = { workspace = true }
effectcraft-plugin = { workspace = true }
# WebAssembly effect plug-ins run in wasmi on native builds.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
effectcraft-plugin = { workspace = true, features = ["wasm"] }
[dev-dependencies]
serde_json = { workspace = true }
effectcraft-psd = { workspace = true }
effectcraft-svg = { workspace = true }
effectcraft-pdf = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-time = { workspace = true }
rayon = { workspace = true }

View File

@ -40,10 +40,26 @@ impl Exporter for FileExporter {
output: &job.item.output,
path: job.path,
sink: self.sink.as_deref(),
options: effectcraft_export::JobOptions {
log: job.item.log,
label: job.label.clone(),
storage: job.storage,
overflow: job.project.render_prefs.overflow_folders.clone(),
},
nested_switches: job.nested_switches,
};
match effectcraft_export::export(&j, &mut |p| progress(p.done, p.total)) {
Ok(r) => Ok(ExportResult { path: r.path, frames: r.frames, width: r.width, height: r.height, bytes: r.bytes, seconds: r.seconds, audio: r.audio }),
Ok(r) => Ok(ExportResult {
path: r.path,
frames: r.frames,
width: r.width,
height: r.height,
bytes: r.bytes,
seconds: r.seconds,
audio: r.audio,
log: r.log,
overflow: r.overflow,
}),
Err(effectcraft_export::ExportError::Cancelled) => Err(effectcraft_engine::render_queue::CANCELLED.into()),
Err(e) => Err(e.to_string()),
}
@ -59,6 +75,8 @@ pub fn session() -> Session {
expr: Some(Arc::new(effectcraft_expr::Expressions)),
expr_check: Some(effectcraft_expr::check_syntax),
script: Some(effectcraft_script::runner),
script_ui: effectcraft_engine::scriptui::ScriptUi { dispatch: Some(effectcraft_script::dispatch_ui), ..Default::default() },
plugin_loader: effectcraft_plugin::wasm_available().then_some(effectcraft_plugin::loader as effectcraft_engine::PluginLoader),
..Default::default()
}
}

View File

@ -0,0 +1,74 @@
//! Deterministic rendering (better than After Effects): the same project renders bit-identical
//! frames every time and whatever the number of render threads. Renders the demo comps (and a
//! comp of seeded-random effects: particles, grain, noise, wiggle expressions, Shatter) twice
//! on one thread and on several, and compares every float of every pixel.
use effectcraft_engine::Session;
use effectcraft_engine::project::{ItemId, Project};
use effectcraft_engine::render::{RenderOpts, Renderer};
use effectcraft_engine::time::Tick;
use serde_json::json;
fn render(s: &Session, p: &Project, comp: ItemId, t: f64) -> Vec<u32> {
let mut r = Renderer::new(p, s.footage.as_ref(), RenderOpts { scale: 0.25, ..Default::default() });
r.expr = s.expr.as_deref();
// No layer cache: every render computes everything.
let img = r.comp_frame(comp, Tick::from_seconds_f64(t));
img.data.iter().flat_map(|px| px.iter().map(|c| c.to_bits())).collect()
}
fn pool(n: usize) -> rayon::ThreadPool {
rayon::ThreadPoolBuilder::new().num_threads(n).build().expect("thread pool")
}
/// A comp of effects that draw random numbers (all seeded per instance).
fn random_comp(s: &mut Session) -> ItemId {
s.execute("comp.new", json!({"name": "Random", "width": 480, "height": 270, "frameRate": 24, "duration": 4})).unwrap();
let cid = s.active_comp_id().unwrap();
let solid = |s: &mut Session, name: &str, color: &str| {
s.execute("layer.newSolid", json!({"name": name, "color": color, "width": 480, "height": 270})).unwrap()["layer"].as_u64().unwrap()
};
let a = solid(s, "Particles", "#000000");
for fx in ["ec.sim.ccparticleworld", "ec.noise.addgrain"] {
s.execute("effect.apply", json!({"layer": a, "effect": fx})).unwrap();
}
let b = solid(s, "Noise", "#336699");
for fx in ["ec.noise.fractal", "ec.sim.ccsnowfall", "ec.noise.noisehls"] {
s.execute("effect.apply", json!({"layer": b, "effect": fx})).unwrap();
}
s.execute("prop.setExpression", json!({"layer": b, "path": "transform/position", "expression": "wiggle(3, 40)"})).unwrap();
s.execute("prop.setExpression", json!({"layer": b, "path": "transform/opacity", "expression": "random(40, 90)"})).unwrap();
let c = solid(s, "Shatter", "#cc8844");
s.execute("layer.setTransform", json!({"layer": c, "prop": "scale", "value": [50, 50]})).unwrap();
s.execute("effect.apply", json!({"layer": c, "effect": "ec.sim.shatter"})).unwrap();
cid
}
#[test]
fn renders_are_bit_identical_across_runs_and_thread_counts() {
let mut s = effectcraft_host::session();
s.replace_project(effectcraft_engine::demo::demo_project(), None);
let random = random_comp(&mut s);
let p = (*s.project).clone();
let mut comps: Vec<(ItemId, f64)> = p.comps().map(|(id, c)| (*id, c.duration.seconds())).collect();
comps.sort_by_key(|c| c.0);
assert!(comps.len() >= 3, "demo comps + the random comp");
assert!(comps.iter().any(|c| c.0 == random));
let (one, many) = (pool(1), pool(6));
let mut frames = 0;
for (comp, dur) in comps {
for t in [0.0, dur * 0.37, dur * 0.81] {
let a = one.install(|| render(&s, &p, comp, t));
let b = many.install(|| render(&s, &p, comp, t));
let c = many.install(|| render(&s, &p, comp, t));
let d = one.install(|| render(&s, &p, comp, t));
let name = &p.item(comp).unwrap().name;
assert!(!a.is_empty());
assert!(a == b, "{name} @ {t:.2}s: 1 thread vs 6 threads differ");
assert!(b == c, "{name} @ {t:.2}s: two 6-thread renders differ");
assert!(a == d, "{name} @ {t:.2}s: two 1-thread renders differ");
frames += 1;
}
}
assert!(frames >= 9);
}

View File

@ -0,0 +1,106 @@
//! Effect plug-ins in a full session: a Rust plug-in registered through the trait and the
//! example WebAssembly plug-in (examples/plugins/posterize-bands) loaded with
//! `effect.plugins.load`, both applied by id like built-ins and rendered.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::effects::plugin::{EffectPlugin, PluginFrame, PluginManifest, PluginParam, PluginParamKind, PluginParams, register_plugin};
use effectcraft_engine::time::Tick;
use serde_json::json;
struct Swap {
m: PluginManifest,
}
impl EffectPlugin for Swap {
fn manifest(&self) -> &PluginManifest {
&self.m
}
fn render(&self, frame: &mut PluginFrame, params: &PluginParams, _time: f64) -> Result<(), String> {
if params.b("on") {
for p in frame.pixels.iter_mut() {
p.swap(0, 2);
}
}
Ok(())
}
}
fn setup() -> (Session, u64) {
let mut s = effectcraft_host::session();
s.execute("comp.new", json!({"name": "P", "width": 40, "height": 20, "duration": 1})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": [1.0, 0.5, 0.0], "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap();
(s, l)
}
fn px(s: &Session) -> [f32; 4] {
let cid = s.active_comp_id().unwrap();
s.render(cid, Tick::ZERO, Default::default()).get(20, 10)
}
#[test]
fn rust_plugins_apply_by_id() {
let m = PluginManifest {
api: 1,
id: "org.test.swap".into(),
name: "Swap Red Blue".into(),
category: "Channel".into(),
version: "1".into(),
author: String::new(),
description: String::new(),
params: vec![PluginParam { id: "on".into(), name: "On".into(), kind: PluginParamKind::Checkbox { default: true } }],
};
register_plugin(Arc::new(Swap { m })).unwrap();
let (mut s, l) = setup();
s.execute("effect.apply", json!({"layer": l, "effect": "org.test.swap"})).unwrap();
let p = px(&s);
assert!(p[0] < 0.01 && (p[1] - 0.5).abs() < 0.01 && p[2] > 0.99, "{p:?}");
// Its parameters are ordinary properties: set (and animate) them like any effect's.
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/on", "value": false})).unwrap();
let p = px(&s);
assert!(p[0] > 0.99 && p[2] < 0.01, "{p:?}");
let list = s.execute("effect.plugins.list", json!({})).unwrap();
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == "org.test.swap" && p["category"] == "Channel"));
assert_eq!(list["api"], 1);
assert!(s.execute("effect.list", json!({"filter": "swap red"})).unwrap().as_array().unwrap().len() == 1);
}
/// Build the example plug-in (skipped when the wasm32 target isn't installed).
fn build_example() -> Option<std::path::PathBuf> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/plugins/posterize-bands");
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("../../../target")).join("plugin-example");
let target = if target.is_absolute() { target } else { root.join("../../..").join(target) };
let st = std::process::Command::new(env!("CARGO"))
.current_dir(&root)
.args(["build", "--release", "--target", "wasm32-unknown-unknown", "--target-dir"])
.arg(&target)
.status()
.ok()?;
if !st.success() {
eprintln!("skipping: the example plug-in didn't build (rustup target add wasm32-unknown-unknown)");
return None;
}
Some(target.join("wasm32-unknown-unknown/release/posterize_bands.wasm"))
}
#[test]
fn example_wasm_plugin_loads_and_renders() {
let Some(wasm) = build_example() else { return };
let (mut s, l) = setup();
let r = s.execute("effect.plugins.load", json!({"path": wasm.to_string_lossy()})).unwrap();
assert_eq!(r["loaded"][0]["id"], "org.effectcraft.example.posterize-bands", "{r}");
s.execute("effect.apply", json!({"layer": l, "effect": "Posterize Bands"})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/mix", "value": 0})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/levels", "value": 2})).unwrap();
// Luminance of (1, .5, 0) is ~0.57: with 2 levels it bands to 1, scaling the colour by 1/y
// (8 bpc: red clips at 1).
let p = px(&s);
let y = 0.2126 + 0.7152 * 0.5;
assert!((p[1] - 0.5 / y as f32).abs() < 0.01 && p[0] > 0.99 && p[2] < 0.01, "{p:?}");
// Invert: band 0.
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/invert", "value": true})).unwrap();
assert_eq!(px(&s)[..3], [0.0, 0.0, 0.0]);
let list = s.execute("effect.plugins.list", json!({})).unwrap();
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["source"].as_str().unwrap_or("").ends_with(".wasm")));
}

View File

@ -184,3 +184,153 @@ fn svg_footage_continuously_rasterizes() {
assert!(sharp < 0.004, "continuously rasterised diff {sharp}");
assert!(sharp < soft, "sharper than the upscaled pixels ({sharp} vs {soft})");
}
/// A two-layer Illustrator-style PDF (optional-content layers "Sky" and "Art"): a gradient sky,
/// a red curved shape with a dark outline, and a clipped green bar.
fn layered_pdf() -> Vec<u8> {
let content = "/OC /L0 BDC\n/Pattern cs /P0 scn 0 0 120 80 re f\nEMC\n\
/OC /L1 BDC\n1 0 0 rg 0.1 0.1 0.1 RG 3 w 20 20 m 20 60 l 60 70 100 60 v 100 20 l h B\n\
q 30 0 40 80 re W n 0 0.8 0 rg 0 35 120 10 re f Q\nEMC\n";
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>";
let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 0 80] /Function << /FunctionType 2 /Domain [0 1] /C0 [0.9 0.9 1] /C1 [0.2 0.4 0.9] /N 1 >> /Extend [true true] >> >>";
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, page.into(), None),
(4, "<< >>".into(), Some(content.as_bytes().to_vec())),
(5, "<< /Type /OCG /Name (Sky) >>".into(), None),
(6, "<< /Type /OCG /Name (Art) >>".into(), None),
(7, pattern.into(), None),
],
1,
)
}
#[test]
fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() {
let bytes = layered_pdf();
let doc = effectcraft_pdf::parse(&bytes).unwrap();
let (w, h) = doc.pixel_size();
assert_eq!((w, h), (120, 80));
let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0);
for name in ["art.pdf", "art.ai"] {
let path = tmp(name);
std::fs::write(&path, &bytes).unwrap();
let mut s = effectcraft_host::session();
let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let item = r["items"][0].as_u64().unwrap();
let effectcraft_project::ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!() };
assert_eq!(f.codec, if name.ends_with(".ai") { "AI" } else { "PDF" });
assert_eq!((f.width, f.height), (120, 80));
s.execute("comp.new", json!({"name": "V", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap();
let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
let t = effectcraft_time::Tick::ZERO;
let foot = s.render(cid, t, RenderOpts::default());
assert!(mean_diff(&foot, &direct) < 1.0 / 255.0, "{name}: footage = rasterised page");
// Continuously Rasterize at 300 %.
for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([300, 300]))] {
s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap();
}
let want = effectcraft_svg::rasterize(&doc, w, h, 3.0);
let soft = mean_diff(&s.render(cid, t, RenderOpts::default()), &want);
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap();
let sharp = mean_diff(&s.render(cid, t, RenderOpts::default()), &want);
assert!(sharp < 0.004 && sharp < soft, "{name}: continuously rasterised {sharp} vs {soft}");
s.execute("prop.set", json!({"layer": lid, "path": "transform/scale", "value": [100, 100]})).unwrap();
// Create Shapes from Vector Layer.
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap();
let sid = r["layers"][0].as_u64().unwrap();
let c = s.active_comp().unwrap();
assert_eq!(c.layers[0].id.0, sid);
let shapes = s.render(cid, t, RenderOpts::default());
// Clipping groups are not shape-layer features: the clipped bar (rows 33–47) draws in
// full; everything else matches.
let rows = |img: &Image| Image {
width: w,
height: h - 15,
data: img.data.iter().enumerate().filter(|(i, _)| !(33..48).contains(&(*i as u32 / w))).map(|(_, p)| *p).collect(),
};
let d = mean_diff(&rows(&shapes), &rows(&direct));
assert!(d < 0.01, "{name}: shapes vs page mean diff {d}");
let fill = shapes.get(60, 30);
assert!(fill[0] > 0.9 && fill[1] < 0.1, "red shape {fill:?}");
}
// Import As: Composition — one layer per file layer, top first, together like the page.
let path = tmp("layers.ai");
std::fs::write(&path, &bytes).unwrap();
let mut s = effectcraft_host::session();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
let cid = ItemId(r["comps"][0].as_u64().unwrap());
let comp = s.project.comp(cid).unwrap();
let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, vec!["Art", "Sky"]);
assert_eq!((comp.width, comp.height), (120, 80));
let art = comp.layers[0].id.0;
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
let d = mean_diff(&img, &direct);
assert!(d < 1.0 / 255.0, "layered comp vs page {d}");
// Each layer's footage shows only its layer.
s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap();
let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
assert_eq!(sky.get(5, 5), img.get(5, 5));
let p = sky.get(60, 30);
assert!(p[2] > p[0], "only sky under the art {p:?}");
}
#[test]
fn psd_smart_object_imports_its_embedded_document() {
// The embedded document: red left half, green right half (20×10).
let mut inner = WDoc::new(20, 10);
inner.layers =
vec![WLayer::solid("Red", Rect::new(0, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]), WLayer::solid("Green", Rect::new(10, 0, 10, 10), [0.0, 1.0, 0.0, 1.0])];
let inner_bytes = write(&inner);
let mut d = WDoc::new(64, 48);
// Placed at 2× and turned a quarter clockwise: the content's top-left corner at (40, 4).
let quad = [[40.0, 4.0], [40.0, 44.0], [20.0, 44.0], [20.0, 4.0]];
d.layers = vec![
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
// Placeholder pixels (grey) that the embedded file replaces.
WLayer::solid("Placed", Rect::new(20, 4, 20, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block("so-1", quad, [20.0, 10.0])),
];
d.linked = vec![("so-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes)];
let path = tmp("smart.psd");
std::fs::write(&path, write(&d)).unwrap();
let mut s = effectcraft_host::session();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let cid = ItemId(r["comps"][0].as_u64().unwrap());
let comp = s.project.comp(cid).unwrap();
let placed = comp.layers.iter().find(|l| l.name == "Placed").unwrap();
let effectcraft_project::LayerSource::Footage { item } = placed.source else { panic!("footage layer") };
let effectcraft_project::ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() };
assert_eq!((f.width, f.height), (20, 10));
assert_eq!(f.layer.as_ref().unwrap().embedded.as_deref(), Some("so-1"));
assert!(s.project.item(item).unwrap().name.contains("inner.psd"));
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
// Rotated a quarter clockwise: the red half is on top (rows 4–24), green below.
let (red, green) = (img.get(30, 14), img.get(30, 34));
assert!(red[0] > 0.95 && red[1] < 0.05, "{red:?}");
assert!(green[1] > 0.95 && green[0] < 0.05, "{green:?}");
assert!(img.get(10, 24)[0] < 0.01 && img.get(10, 24)[3] > 0.99, "background outside");
}
#[test]
fn eps_footage_imports() {
let eps =
b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n1 0 0 setrgbcolor 0 0 20 20 rectfill 0 0 1 setrgbcolor newpath 30 10 8 0 360 arc fill\n%%EOF\n";
let path = tmp("shape.eps");
std::fs::write(&path, eps).unwrap();
let mut s = effectcraft_host::session();
let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let item = r["items"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "E", "width": 40, "height": 20, "frameRate": 30, "duration": 1})).unwrap();
s.execute_checked("layer.addItem", json!({"item": item})).unwrap();
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
assert!(img.get(10, 10)[0] > 0.99 && img.get(30, 10)[2] > 0.99, "{:?} {:?}", img.get(10, 10), img.get(30, 10));
}

View File

@ -13,7 +13,7 @@ pub mod value;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun};
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun};
pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value};
/// Temporal interpolation on one side of a keyframe.

View File

@ -143,6 +143,125 @@ fn yes() -> bool {
true
}
/// Figure style (OpenType `lnum` / `onum`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FigureStyle {
/// The font's default figures.
#[default]
Default,
Lining,
OldStyle,
}
/// Figure spacing (OpenType `pnum` / `tnum`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FigureWidth {
#[default]
Default,
Proportional,
Tabular,
}
/// OpenType layout features of a run of characters (Character panel ▸ OpenType). Kerning,
/// standard ligatures, small caps and superscript / subscript have their own [`CharStyle`]
/// fields (they use the font's `smcp` / `sups` / `subs` glyphs when it has them and synthesize
/// them otherwise).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct OpenType {
/// `dlig`.
pub discretionary_ligatures: bool,
/// `calt` (on by default, as in the font).
pub contextual_alternates: bool,
/// `salt`.
pub stylistic_alternates: bool,
/// Stylistic sets: bit `n - 1` turns on `ssNN` (n = 1…20).
pub stylistic_sets: u32,
/// `swsh`.
pub swash: bool,
/// `titl`.
pub titling: bool,
/// `ordn`.
pub ordinals: bool,
/// `frac`.
pub fractions: bool,
/// All Small Caps: capitals become small capitals too (`c2sc` with `smcp`).
pub all_small_caps: bool,
pub figure_style: FigureStyle,
pub figure_width: FigureWidth,
}
impl Default for OpenType {
fn default() -> Self {
OpenType {
discretionary_ligatures: false,
contextual_alternates: true,
stylistic_alternates: false,
stylistic_sets: 0,
swash: false,
titling: false,
ordinals: false,
fractions: false,
all_small_caps: false,
figure_style: FigureStyle::Default,
figure_width: FigureWidth::Default,
}
}
}
impl OpenType {
pub fn is_default(&self) -> bool {
*self == OpenType::default()
}
/// Whether stylistic set `n` (1…20) is on.
pub fn stylistic_set(&self, n: u32) -> bool {
(1..=20).contains(&n) && self.stylistic_sets & (1 << (n - 1)) != 0
}
pub fn set_stylistic_set(&mut self, n: u32, on: bool) {
if (1..=20).contains(&n) {
if on {
self.stylistic_sets |= 1 << (n - 1);
} else {
self.stylistic_sets &= !(1 << (n - 1));
}
}
}
/// The OpenType feature settings these options ask for (tag, value), beyond the font's
/// defaults: e.g. `[("dlig", 1), ("ss02", 1), ("calt", 0)]`.
pub fn feature_settings(&self) -> Vec<([u8; 4], u32)> {
let mut v = Vec::new();
for (tag, f) in [
(b"dlig", self.discretionary_ligatures),
(b"salt", self.stylistic_alternates),
(b"swsh", self.swash),
(b"titl", self.titling),
(b"ordn", self.ordinals),
(b"frac", self.fractions),
(b"c2sc", self.all_small_caps),
(b"smcp", self.all_small_caps),
(b"lnum", self.figure_style == FigureStyle::Lining),
(b"onum", self.figure_style == FigureStyle::OldStyle),
(b"pnum", self.figure_width == FigureWidth::Proportional),
(b"tnum", self.figure_width == FigureWidth::Tabular),
] {
if f {
v.push((*tag, 1));
}
}
if !self.contextual_alternates {
v.push((*b"calt", 0));
}
for n in 1..=20u32 {
if self.stylistic_set(n) {
let d = format!("ss{n:02}");
let b = d.as_bytes();
v.push(([b[0], b[1], b[2], b[3]], 1));
}
}
v
}
}
/// The formatting of a run of characters (Character panel).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@ -177,6 +296,9 @@ pub struct CharStyle {
/// Vertical type: keep Roman (half-width) characters upright instead of turning them on
/// their side (Character panel menu ▸ Standard Vertical Roman Alignment).
pub vertical_roman_upright: bool,
/// OpenType features (stylistic sets, figures, fractions…).
#[serde(skip_serializing_if = "OpenType::is_default")]
pub opentype: OpenType,
}
impl Default for CharStyle {
@ -258,6 +380,9 @@ pub struct TextDoc {
/// Base Tate-Chu-Yoko and Standard Vertical Roman Alignment (see [`CharStyle`]).
pub tate_chu_yoko: bool,
pub vertical_roman_upright: bool,
/// Base OpenType features (see [`CharStyle::opentype`]).
#[serde(skip_serializing_if = "OpenType::is_default")]
pub opentype: OpenType,
pub indent_left: f64,
pub indent_right: f64,
pub indent_first: f64,
@ -309,6 +434,7 @@ impl Default for TextDoc {
ligatures: true,
tate_chu_yoko: false,
vertical_roman_upright: false,
opentype: OpenType::default(),
indent_left: 0.0,
indent_right: 0.0,
indent_first: 0.0,
@ -395,6 +521,7 @@ impl TextDoc {
ligatures: self.ligatures,
tate_chu_yoko: self.tate_chu_yoko,
vertical_roman_upright: self.vertical_roman_upright,
opentype: self.opentype,
}
}
@ -422,6 +549,7 @@ impl TextDoc {
self.ligatures = s.ligatures;
self.tate_chu_yoko = s.tate_chu_yoko;
self.vertical_roman_upright = s.vertical_roman_upright;
self.opentype = s.opentype;
}
/// The base (first paragraph's) settings.
@ -777,6 +905,9 @@ fn color_json(v: &J) -> Option<[f32; 4]> {
fn num(key: &str, v: &J) -> Result<f64, String> {
v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| format!("{key}: expected a number"))
}
fn norm(v: &J) -> Option<String> {
v.as_str().map(|s| s.to_ascii_lowercase().replace(['-', ' ', '_'], ""))
}
fn flag(key: &str, v: &J) -> Result<bool, String> {
v.as_bool().or_else(|| v.as_f64().map(|x| x != 0.0)).ok_or_else(|| format!("{key}: expected a boolean"))
}
@ -808,6 +939,18 @@ pub const CHAR_ATTRS: &[&str] = &[
"ligatures",
"tateChuYoko",
"verticalRomanUpright",
"discretionaryLigatures",
"contextualAlternates",
"stylisticAlternates",
"stylisticSets",
"swash",
"titling",
"ordinals",
"fractions",
"allSmallCaps",
"figureStyle",
"figureWidth",
"figures",
];
/// Paragraph attribute keys of `layer.setText`.
@ -850,6 +993,7 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result<bool, Stri
s.all_caps = flag(key, v)?;
if s.all_caps {
s.small_caps = false;
s.opentype.all_small_caps = false;
}
}
"smallCaps" => {
@ -869,6 +1013,65 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result<bool, Stri
"ligatures" => s.ligatures = flag(key, v)?,
"tateChuYoko" => s.tate_chu_yoko = flag(key, v)?,
"verticalRomanUpright" => s.vertical_roman_upright = flag(key, v)?,
"discretionaryLigatures" => s.opentype.discretionary_ligatures = flag(key, v)?,
"contextualAlternates" => s.opentype.contextual_alternates = flag(key, v)?,
"stylisticAlternates" => s.opentype.stylistic_alternates = flag(key, v)?,
"swash" => s.opentype.swash = flag(key, v)?,
"titling" => s.opentype.titling = flag(key, v)?,
"ordinals" => s.opentype.ordinals = flag(key, v)?,
"fractions" => s.opentype.fractions = flag(key, v)?,
"allSmallCaps" => {
s.opentype.all_small_caps = flag(key, v)?;
if s.opentype.all_small_caps {
s.all_caps = false;
}
}
"stylisticSets" => {
s.opentype.stylistic_sets = match v {
J::Array(a) => {
let mut m = 0u32;
for n in a {
let n = n.as_u64().filter(|n| (1..=20).contains(n)).ok_or("stylisticSets: expected set numbers 1–20")?;
m |= 1 << (n - 1);
}
m
}
J::Number(_) => (num(key, v)? as u64 & 0xF_FFFF) as u32,
_ => return Err("stylisticSets: expected [1, 2, …] or a bit mask".into()),
}
}
"figureStyle" => {
s.opentype.figure_style = match norm(v).as_deref() {
Some("default") => FigureStyle::Default,
Some("lining") => FigureStyle::Lining,
Some("oldstyle") => FigureStyle::OldStyle,
_ => return Err("figureStyle: expected default|lining|oldStyle".into()),
}
}
"figureWidth" => {
s.opentype.figure_width = match norm(v).as_deref() {
Some("default") => FigureWidth::Default,
Some("proportional") => FigureWidth::Proportional,
Some("tabular") => FigureWidth::Tabular,
_ => return Err("figureWidth: expected default|proportional|tabular".into()),
}
}
"figures" => {
let (st, w) = match norm(v).as_deref() {
Some("default") => (FigureStyle::Default, FigureWidth::Default),
Some("tabularlining") => (FigureStyle::Lining, FigureWidth::Tabular),
Some("proportionallining") => (FigureStyle::Lining, FigureWidth::Proportional),
Some("tabularoldstyle") => (FigureStyle::OldStyle, FigureWidth::Tabular),
Some("proportionaloldstyle") => (FigureStyle::OldStyle, FigureWidth::Proportional),
_ => return Err("figures: expected default|tabularLining|proportionalLining|tabularOldstyle|proportionalOldstyle".into()),
};
s.opentype.figure_style = st;
s.opentype.figure_width = w;
}
k if k.len() == 4 && k.starts_with("ss") && k[2..].parse::<u32>().is_ok_and(|n| (1..=20).contains(&n)) => {
let n = k[2..].parse::<u32>().unwrap_or(0);
s.opentype.set_stylistic_set(n, flag(key, v)?);
}
_ => return Ok(false),
}
Ok(true)
@ -915,6 +1118,13 @@ pub fn char_style_json(s: &CharStyle) -> J {
"hScale": s.h_scale, "vScale": s.v_scale, "tsume": s.tsume, "fauxBold": s.faux_bold, "fauxItalic": s.faux_italic,
"allCaps": s.all_caps, "smallCaps": s.small_caps, "baseline": s.baseline.key(), "kerning": s.kerning.to_json(),
"ligatures": s.ligatures, "tateChuYoko": s.tate_chu_yoko, "verticalRomanUpright": s.vertical_roman_upright,
"discretionaryLigatures": s.opentype.discretionary_ligatures, "contextualAlternates": s.opentype.contextual_alternates,
"stylisticAlternates": s.opentype.stylistic_alternates,
"stylisticSets": (1..=20u32).filter(|n| s.opentype.stylistic_set(*n)).collect::<Vec<_>>(),
"swash": s.opentype.swash, "titling": s.opentype.titling, "ordinals": s.opentype.ordinals, "fractions": s.opentype.fractions,
"allSmallCaps": s.opentype.all_small_caps,
"figureStyle": match s.opentype.figure_style { FigureStyle::Default => "default", FigureStyle::Lining => "lining", FigureStyle::OldStyle => "oldStyle" },
"figureWidth": match s.opentype.figure_width { FigureWidth::Default => "default", FigureWidth::Proportional => "proportional", FigureWidth::Tabular => "tabular" },
})
}
@ -1127,4 +1337,37 @@ mod tests {
assert_eq!(word_at(t, 8), 7..10);
assert_eq!(word_at(t, 6), 6..7);
}
#[test]
fn opentype_attributes_per_range_and_serde() {
let mut d = TextDoc::plain("Office 1/2 1st");
// Untouched documents serialize without an OpenType block.
assert!(!serde_json::to_string(&d).unwrap().contains("opentype"));
d.set_attr("ss02", &J::Bool(true), Some(0..6)).unwrap();
d.set_attr("fractions", &J::Bool(true), Some(7..10)).unwrap();
d.set_attr("figures", &J::from("tabularOldstyle"), None).unwrap();
assert!(d.style_at(0).opentype.stylistic_set(2));
assert!(!d.style_at(8).opentype.stylistic_set(2));
assert!(d.style_at(8).opentype.fractions);
assert_eq!(d.style_at(12).opentype.figure_width, FigureWidth::Tabular);
assert!(d.set_attr("stylisticSets", &serde_json::json!([1, 20]), Some(0..1)).unwrap());
assert_eq!(d.style_at(0).opentype.stylistic_sets, 1 | (1 << 19));
assert!(d.set_attr("stylisticSets", &serde_json::json!([21]), None).is_err());
let f = d.style_at(8).opentype.feature_settings();
assert!(f.contains(&(*b"frac", 1)) && f.contains(&(*b"onum", 1)) && f.contains(&(*b"tnum", 1)));
let mut s = d.style_at(0);
s.opentype.contextual_alternates = false;
assert!(s.opentype.feature_settings().contains(&(*b"calt", 0)));
// Round trip, and the JSON form names the attributes as layer.setText does.
let back: TextDoc = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
assert_eq!(back, d);
let j = char_style_json(&d.style_at(0));
assert_eq!(j["stylisticSets"], serde_json::json!([1, 20]));
assert_eq!(j["figureStyle"], "oldStyle");
// All Small Caps and All Caps exclude each other.
let mut s = CharStyle::default();
apply_char_attr(&mut s, "allCaps", &J::Bool(true)).unwrap();
apply_char_attr(&mut s, "allSmallCaps", &J::Bool(true)).unwrap();
assert!(!s.all_caps && s.opentype.all_small_caps);
}
}

View File

@ -16,6 +16,7 @@ effectcraft-raster = { workspace = true }
effectcraft-render = { workspace = true }
effectcraft-psd = { workspace = true }
effectcraft-svg = { workspace = true }
effectcraft-pdf = { workspace = true }
effectcraft-model = { workspace = true }
filmcraft-codecs = { workspace = true }
filmcraft-media = { workspace = true }

View File

@ -1,5 +1,7 @@
//! Layered and vector stills: Photoshop documents (`effectcraft-psd`: the merged image, or one
//! layer for footage imported as a composition) and SVG (`effectcraft-svg`, rasterised).
//! layer for footage imported as a composition) and vector files rasterised at any scale: SVG
//! (`effectcraft-svg`), PDF / Illustrator / EPS (`effectcraft-pdf`; one layer of the file for
//! footage imported as a composition).
use effectcraft_project::{AlphaMode, Footage};
use effectcraft_raster::Image;
@ -11,7 +13,29 @@ fn is_svg(path: &str, bytes: &[u8]) -> bool {
path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes)
}
/// Footage for a Photoshop or SVG file (`None` for other formats).
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted
/// to the footage's layer when it names one.
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option<std::result::Result<effectcraft_svg::Doc, String>> {
if is_svg(path, bytes) {
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
}
effectcraft_pdf::sniff(bytes)?;
Some(
effectcraft_pdf::parse(bytes)
.map(|d| match layer {
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
None => d,
})
.map_err(|e| format!("{path}: {e}")),
)
}
fn is_pdf_like(path: &str, bytes: &[u8]) -> bool {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF"))
}
/// Footage for a Photoshop or vector file (`None` for other formats).
pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result<Option<Footage>> {
if effectcraft_psd::is_psd(bytes) {
let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
@ -28,6 +52,14 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result<Option<Footage>> {
f.alpha = AlphaMode::Straight;
return Ok(Some(f));
}
if is_pdf_like(path, bytes) {
let doc = effectcraft_pdf::parse(bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
let (w, h) = doc.pixel_size();
let mut f = crate::probe::still_footage(path, w, h, image::ImageFormat::Png, true);
f.codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF").into();
f.alpha = AlphaMode::Straight;
return Ok(Some(f));
}
Ok(None)
}
@ -35,6 +67,17 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result<Option<Footage>> {
pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -> Result<Option<Image>> {
if effectcraft_psd::is_psd(bytes) {
let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
// A smart object's embedded file: an embedded document's merged image, or an image.
if let Some(uuid) = footage.layer.as_ref().and_then(|l| l.embedded.as_deref()) {
let data = psd.linked_data(uuid).ok_or_else(|| MediaError::Decode(format!("{path}: no embedded file {uuid}")))?;
if effectcraft_psd::is_psd(data) {
let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
let px = inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
}
let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
return Ok(Some(crate::convert::dynamic_to_image(&img, op)));
}
let px = match &footage.layer {
Some(l) => psd.layer_pixels(l.index as usize, !l.layer_size),
None => psd.composite(),
@ -42,8 +85,8 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
.map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
}
if is_svg(path, bytes) {
let doc = effectcraft_svg::parse(bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
if is_svg(path, bytes) || is_pdf_like(path, bytes) {
let doc = vector_doc(path, bytes, footage.layer.as_ref()).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?;
let (w, h) = doc.pixel_size();
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
// The rasteriser produces premultiplied pixels already.
@ -53,9 +96,9 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
Ok(None)
}
/// An SVG rasterised at `scale` × its pixel size (Continuously Rasterize).
pub(crate) fn rasterize_svg(bytes: &[u8], scale: f64) -> Option<Image> {
let doc = effectcraft_svg::parse(bytes).ok()?;
/// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize).
pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, scale: f64) -> Option<Image> {
let doc = vector_doc(path, bytes, layer)?.ok()?;
let (w, h) = doc.pixel_size();
let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, 16384), ((h as f64 * scale).ceil() as u32).clamp(1, 16384));
Some(effectcraft_svg::rasterize(&doc, sw, sh, scale))

View File

@ -16,6 +16,7 @@
mod convert;
pub mod exr_channels;
mod layered;
pub use layered::vector_doc;
mod pool;
mod probe;
@ -47,7 +48,7 @@ impl From<filmcraft_media::MediaError> for MediaError {
}
/// File extensions recognised as stills.
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "psd", "psb", "svg"];
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "psd", "psb", "svg", "pdf", "ai", "eps"];
/// File extensions recognised as audio-only files.
pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS;
/// File extensions recognised as movies.

View File

@ -488,9 +488,10 @@ impl Inner {
let i = Self::frame_index(footage.frame_rate, t, footage.sequence.len() as i64, footage.loop_count);
Loc { key: key(&footage.sequence[i as usize], 0), media_t: None }
}
// A layer of a layered still is keyed by its layer index and size mode.
// A layer of a layered still is keyed by its layer index and size mode (smart objects
// by their embedded file).
FootageKind::Still if footage.layer.is_some() => {
let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64);
let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64 + if l.embedded.is_some() { 1 << 40 } else { 0 });
Loc { key: key(&footage.path, l), media_t: None }
}
FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None },
@ -621,7 +622,7 @@ impl FootageSource for MediaPool {
return None;
}
let bytes = self.inner.read(&footage.path).ok()?;
crate::layered::rasterize_svg(&bytes, scale).map(Arc::new)
crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), scale).map(Arc::new)
}
fn aux(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {

15
crates/pdf/Cargo.toml Normal file
View File

@ -0,0 +1,15 @@
[package]
name = "effectcraft-pdf"
description = "Vector footage import: PDF and PDF-compatible Illustrator (.ai) pages and EPS (a PostScript subset) parsed into the SVG render tree (paths, fills, strokes, axial / radial shadings, clipping, optional-content layers)"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-svg = { workspace = true }
kurbo = { workspace = true }
miniz_oxide = "0.8"

48
crates/pdf/README.md Normal file
View File

@ -0,0 +1,48 @@
# effectcraft-pdf
Vector footage from PDF files, PDF-compatible Adobe Illustrator files (`.ai`, which are PDF
documents) and Encapsulated PostScript (`.eps`). Files parse into the same render tree as SVG
(`effectcraft_svg::Doc`), so they rasterise at any scale (Continuously Rasterize), convert to shape
layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as compositions with one
layer per file layer (File ▸ Import ▸ Composition).
Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file
structure, filters, content streams, graphics state, colour spaces, functions, shadings and
optional content; the **PostScript Language Reference, third edition** and the **Encapsulated
PostScript File Format Specification 3.0** for EPS. No third-party PDF/PostScript code was read;
test files are generated in code ([`write`](src/write.rs) and the tests).
## What is read
- **Structure:** indirect objects found by scanning (damaged or incremental cross-reference
sections do not matter), object streams, the page tree with inherited resources, MediaBox /
CropBox and `/Rotate`. Filters: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85,
RunLength. Encrypted files are refused.
- **Content:** `q`/`Q`, `cm`, line width / cap / join / miter / dash, ExtGState (`CA`, `ca`, `LW`,
`LC`, `LJ`, `ML`, `D`), all path construction and painting operators (non-zero and even-odd),
clipping (`W`, `W*`) as clipping groups, colour in DeviceGray / RGB / CMYK, ICCBased (by
component count), CalGray / CalRGB, Lab, Indexed, Separation and DeviceN (tint transforms of
function types 0, 2 and 3), axial and radial shadings as pattern fills and through `sh`, form
XObjects (matrix, bounding-box clip, nested resources).
- **Layers:** top-level optional-content marked sequences (`/OC … BDC`, Illustrator's layers)
become the document's layers, named after their OCG; content outside them is gathered into
layers "Layer N".
- **EPS:** a PostScript interpreter for the subset exported vector EPS uses: operand and
dictionary stacks, procedures and `def`/`bind`/`load`/`where`, arithmetic, comparisons,
`if`/`ifelse`/`for`/`repeat`/`loop`/`forall`, arrays and strings, `gsave`/`grestore`,
`save`/`restore`, matrices (`concat`, `translate`, `scale`, `rotate`, `transform` …), path
construction including `arc`/`arcn` and relative operators, `fill`/`eofill`/`stroke`,
`clip`/`eoclip`, `rectfill`/`rectstroke`/`rectclip`, gray / RGB / CMYK / HSB colour. DOS EPS
binary headers and `%%HiResBoundingBox` / `%%BoundingBox` are honoured; `%%BeginData` /
`%%BeginBinary` / preview sections are skipped.
## Limitations
- Text is not drawn (embedded fonts are not read); images, tiling patterns, mesh shadings
(types 4–7), PostScript calculator functions, soft masks and blend modes are skipped. What a
document skipped is listed in `Doc::skipped`.
- Illustrator EPS files whose drawing depends on Adobe's procedure-set resources (defined in
the file through constructs beyond this subset) and Level 3 `shfill` gradients in EPS draw
only what the subset understands; save such artwork as PDF-compatible `.ai` or PDF instead.
- Shape-layer conversion has no clipping: clipped artwork converts unclipped.
- Only the first page of a multi-page PDF is imported.

93
crates/pdf/src/build.rs Normal file
View File

@ -0,0 +1,93 @@
//! The output side shared by the PDF and PostScript interpreters: an open-group stack (clipping
//! groups, optional-content layers) that collects shapes into the SVG render tree.
use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape, Stroke};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Plain,
Clip,
Layer,
}
pub(crate) struct Builder {
stack: Vec<(Group, Kind)>,
/// Which of the finished top-level nodes are optional-content layers.
pub top_layers: Vec<bool>,
pub skipped: Vec<String>,
pub shapes: usize,
}
pub(crate) fn group(name: &str) -> Group {
Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![] }
}
impl Builder {
pub fn new() -> Builder {
Builder { stack: vec![(group("page"), Kind::Plain)], top_layers: vec![], skipped: vec![], shapes: 0 }
}
pub fn depth(&self) -> usize {
self.stack.len()
}
pub fn skip(&mut self, what: &str) {
if !self.skipped.iter().any(|s| s == what) {
self.skipped.push(what.to_string());
}
}
fn add(&mut self, n: Node, layer: bool) {
if self.stack.len() == 1 {
self.top_layers.push(layer);
}
if let Some((g, _)) = self.stack.last_mut() {
g.children.push(n);
}
}
/// Close every group opened above `depth`.
pub fn close_to(&mut self, depth: usize) {
while self.stack.len() > depth.max(1) {
let Some((g, k)) = self.stack.pop() else { break };
// Empty clipping groups draw nothing.
if g.children.is_empty() {
continue;
}
self.add(Node::Group(g), k == Kind::Layer);
}
}
/// Children drawn from now on are clipped by `path` (page space) until the group closes.
pub fn clip(&mut self, path: BezPath, rule: FillRule) {
let mut g = group("Clip Group");
g.clip.push((path, rule));
self.stack.push((g, Kind::Clip));
}
/// Open an optional-content layer (or a named group).
pub fn open(&mut self, name: &str, layer: bool) {
self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain }));
}
pub fn fill_stroke(&mut self, path: BezPath, transform: Affine, fill: Option<(Paint, f64, FillRule)>, stroke: Option<Stroke>) {
if path.elements().is_empty() || (fill.is_none() && stroke.is_none()) {
return;
}
self.shapes += 1;
let (f, fo, rule) = match fill {
Some((p, o, r)) => (Some(p), o, r),
None => (None, 1.0, FillRule::NonZero),
};
let s = Shape { name: "Path".into(), geom: Geom::Path(path), transform, fill: f, fill_opacity: fo, fill_rule: rule, stroke, opacity: 1.0 };
self.add(Node::Shape(s), false);
}
/// The finished top-level nodes and their layer flags.
pub fn finish(mut self) -> (Vec<Node>, Vec<bool>, Vec<String>) {
self.close_to(1);
let root = self.stack.pop().map(|(g, _)| g.children).unwrap_or_default();
let flags = self.top_layers;
(root, flags, self.skipped)
}
}

288
crates/pdf/src/color.rs Normal file
View File

@ -0,0 +1,288 @@
//! Colour spaces (ISO 32000-1 §8.6) reduced to sRGB, and functions (§7.10: sampled,
//! exponential and stitching; PostScript calculator functions are not evaluated).
use crate::object::{Dict, File, Obj, decode_stream};
#[derive(Clone, Debug, PartialEq)]
pub enum Cs {
Gray,
Rgb,
Cmyk,
/// CIE L*a*b* (approximated).
Lab,
Indexed {
base: Box<Cs>,
hival: usize,
lookup: Vec<u8>,
},
/// Separation / DeviceN: components → alternate space through a tint transform.
Tint {
n: usize,
alt: Box<Cs>,
func: Func,
},
Pattern,
}
impl Cs {
pub fn components(&self) -> usize {
match self {
Cs::Gray | Cs::Indexed { .. } => 1,
Cs::Rgb | Cs::Lab => 3,
Cs::Cmyk => 4,
Cs::Tint { n, .. } => *n,
Cs::Pattern => 0,
}
}
/// The initial colour of the space (black, or tint 1).
pub fn initial(&self) -> Vec<f64> {
match self {
Cs::Cmyk => vec![0.0, 0.0, 0.0, 1.0],
Cs::Tint { n, .. } => vec![1.0; *n],
Cs::Lab => vec![0.0, 0.0, 0.0],
c => vec![0.0; c.components()],
}
}
pub fn to_rgb(&self, c: &[f64]) -> [f64; 3] {
let g = |i: usize| c.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0);
match self {
Cs::Gray => [g(0); 3],
Cs::Rgb | Cs::Pattern => [g(0), g(1), g(2)],
Cs::Cmyk => {
let k = g(3);
[(1.0 - g(0)) * (1.0 - k), (1.0 - g(1)) * (1.0 - k), (1.0 - g(2)) * (1.0 - k)]
}
Cs::Lab => lab_to_rgb(c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0), c.get(2).copied().unwrap_or(0.0)),
Cs::Indexed { base, hival, lookup } => {
let i = (c.first().copied().unwrap_or(0.0).round().max(0.0) as usize).min(*hival);
let n = base.components();
let v: Vec<f64> = (0..n).map(|k| lookup.get(i * n + k).copied().unwrap_or(0) as f64 / 255.0).collect();
base.to_rgb(&v)
}
Cs::Tint { alt, func, .. } => alt.to_rgb(&func.eval(c)),
}
}
}
fn lab_to_rgb(l: f64, a: f64, b: f64) -> [f64; 3] {
let fy = (l + 16.0) / 116.0;
let fx = fy + a / 500.0;
let fz = fy - b / 200.0;
let f = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) };
let (x, y, z) = (0.9505 * f(fx), f(fy), 1.089 * f(fz));
let lin = [3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.204 * y + 1.057 * z];
lin.map(|v| {
let v = v.clamp(0.0, 1.0);
if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
})
}
/// Read a colour space (a name, or an array such as `[/ICCBased 5 0 R]`); named resources are
/// looked up in `res` (`/ColorSpace`).
pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
let o = file.resolve(o);
match o {
Obj::Name(n) => match n.as_str() {
"DeviceGray" | "G" | "CalGray" => Cs::Gray,
"DeviceRGB" | "RGB" | "CalRGB" => Cs::Rgb,
"DeviceCMYK" | "CMYK" => Cs::Cmyk,
"Pattern" => Cs::Pattern,
other => match res.and_then(|r| file.get_dict(r, "ColorSpace")).and_then(|cs| file.get(cs, other)) {
Some(x) if !matches!(x, Obj::Name(m) if m == other) => color_space(file, x, None),
_ => Cs::Gray,
},
},
Obj::Array(a) => {
let head = a.first().map(|h| file.resolve(h)).and_then(Obj::name).unwrap_or("");
match head {
"ICCBased" => {
let n = a.get(1).map(|s| file.resolve(s)).and_then(Obj::dict).and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize;
match n {
1 => Cs::Gray,
4 => Cs::Cmyk,
_ => Cs::Rgb,
}
}
"CalRGB" => Cs::Rgb,
"CalGray" => Cs::Gray,
"Lab" => Cs::Lab,
"Pattern" => Cs::Pattern,
"Indexed" | "I" => {
let base = a.get(1).map(|b| color_space(file, b, res)).unwrap_or(Cs::Rgb);
let hival = a.get(2).and_then(|h| file.resolve(h).num()).unwrap_or(0.0) as usize;
let lookup = match a.get(3).map(|l| file.resolve(l)) {
Some(Obj::Str(s)) => s.clone(),
Some(Obj::Stream(d, raw)) => decode_stream(file, d, raw).unwrap_or_default(),
_ => vec![],
};
Cs::Indexed { base: Box::new(base), hival, lookup }
}
"Separation" | "DeviceN" => {
let n = if head == "Separation" { 1 } else { a.get(1).map(|x| file.resolve(x)).and_then(Obj::array).map_or(1, <[Obj]>::len) };
let alt = a.get(2).map(|b| color_space(file, b, res)).unwrap_or(Cs::Gray);
let func = a.get(3).map(|f| Func::read(file, f)).unwrap_or(Func::Unsupported);
// An unreadable tint transform: tint 1 = black, 0 = white.
if func == Func::Unsupported {
return Cs::Tint { n, alt: Box::new(Cs::Gray), func: Func::Exp { domain: [0.0, 1.0], c0: vec![1.0], c1: vec![0.0], n: 1.0 } };
}
Cs::Tint { n, alt: Box::new(alt), func }
}
_ => Cs::Rgb,
}
}
_ => Cs::Gray,
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Func {
/// Type 2.
Exp {
domain: [f64; 2],
c0: Vec<f64>,
c1: Vec<f64>,
n: f64,
},
/// Type 3.
Stitch {
domain: [f64; 2],
funcs: Vec<Func>,
bounds: Vec<f64>,
encode: Vec<f64>,
},
/// Type 0 with one input.
Sampled {
domain: [f64; 2],
size: usize,
outputs: usize,
encode: [f64; 2],
decode: Vec<f64>,
samples: Vec<f64>,
},
/// An array of one-output functions.
Array(Vec<Func>),
Unsupported,
}
impl Func {
pub fn read(file: &File, o: &Obj) -> Func {
let o = file.resolve(o);
if let Obj::Array(a) = o {
return Func::Array(a.iter().map(|f| Func::read(file, f)).collect());
}
let Some(d) = o.dict() else { return Func::Unsupported };
let dom = file.get(d, "Domain").map(|x| file.nums(x)).unwrap_or_default();
let domain = [dom.first().copied().unwrap_or(0.0), dom.get(1).copied().unwrap_or(1.0)];
match file.get_num(d, "FunctionType").unwrap_or(-1.0) as i32 {
2 => Func::Exp {
domain,
c0: file.get(d, "C0").map(|x| file.nums(x)).unwrap_or_else(|| vec![0.0]),
c1: file.get(d, "C1").map(|x| file.nums(x)).unwrap_or_else(|| vec![1.0]),
n: file.get_num(d, "N").unwrap_or(1.0),
},
3 => Func::Stitch {
domain,
funcs: file.get(d, "Functions").and_then(Obj::array).map(|a| a.iter().map(|f| Func::read(file, f)).collect()).unwrap_or_default(),
bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(),
encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(),
},
0 => {
let Obj::Stream(sd, raw) = o else { return Func::Unsupported };
let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported };
let size = file.get(d, "Size").map(|x| file.nums(x)).and_then(|v| v.first().copied()).unwrap_or(0.0) as usize;
let bps = file.get_num(d, "BitsPerSample").unwrap_or(8.0) as usize;
let range = file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default();
let outputs = range.len() / 2;
if size == 0 || outputs == 0 || !matches!(bps, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) {
return Func::Unsupported;
}
let enc = file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default();
let encode = [enc.first().copied().unwrap_or(0.0), enc.get(1).copied().unwrap_or(size as f64 - 1.0)];
let decode = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= outputs * 2).unwrap_or_else(|| range.clone());
let max = ((1u64 << bps) - 1) as f64;
let mut samples = Vec::with_capacity(size * outputs);
let mut bit = 0usize;
for _ in 0..size * outputs {
let mut v = 0u64;
for _ in 0..bps {
let byte = data.get(bit / 8).copied().unwrap_or(0);
v = (v << 1) | ((byte >> (7 - bit % 8)) & 1) as u64;
bit += 1;
}
samples.push(v as f64 / max);
}
Func::Sampled { domain, size, outputs, encode, decode, samples }
}
_ => Func::Unsupported,
}
}
/// Evaluate at the first input (the functions used by shadings and tints take one input
/// in practice; extra inputs of DeviceN tints are averaged in).
pub fn eval(&self, input: &[f64]) -> Vec<f64> {
let t = input.first().copied().unwrap_or(0.0);
self.eval1(t)
}
pub fn eval1(&self, t: f64) -> Vec<f64> {
match self {
Func::Exp { domain, c0, c1, n } => {
let t = t.clamp(domain[0].min(domain[1]), domain[0].max(domain[1]));
let x = if *n == 1.0 { t } else { t.max(0.0).powf(*n) };
(0..c0.len().max(c1.len()))
.map(|i| c0.get(i).copied().unwrap_or(0.0) + x * (c1.get(i).copied().unwrap_or(1.0) - c0.get(i).copied().unwrap_or(0.0)))
.collect()
}
Func::Stitch { domain, funcs, bounds, encode } => {
if funcs.is_empty() {
return vec![0.0];
}
let t = t.clamp(domain[0], domain[1]);
let k = bounds.iter().take_while(|b| t >= **b).count().min(funcs.len() - 1);
let lo = if k == 0 { domain[0] } else { bounds[k - 1] };
let hi = bounds.get(k).copied().unwrap_or(domain[1]);
let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0));
let u = if hi > lo { e0 + (t - lo) / (hi - lo) * (e1 - e0) } else { e0 };
funcs[k].eval1(u)
}
Func::Sampled { domain, size, outputs, encode, decode, samples } => {
let t = t.clamp(domain[0], domain[1]);
let e = if domain[1] > domain[0] { encode[0] + (t - domain[0]) / (domain[1] - domain[0]) * (encode[1] - encode[0]) } else { encode[0] };
let e = e.clamp(0.0, *size as f64 - 1.0);
let (i0, f) = (e.floor() as usize, e.fract());
let i1 = (i0 + 1).min(size - 1);
(0..*outputs)
.map(|o| {
let s = samples[i0 * outputs + o] * (1.0 - f) + samples[i1 * outputs + o] * f;
let (d0, d1) = (decode.get(2 * o).copied().unwrap_or(0.0), decode.get(2 * o + 1).copied().unwrap_or(1.0));
d0 + s * (d1 - d0)
})
.collect()
}
Func::Array(fs) => fs.iter().map(|f| f.eval1(t).first().copied().unwrap_or(0.0)).collect(),
Func::Unsupported => vec![0.5],
}
}
/// Input values worth sampling exactly (stitching bounds).
pub fn breakpoints(&self) -> Vec<f64> {
match self {
Func::Stitch { bounds, .. } => bounds.clone(),
_ => vec![],
}
}
}
/// PDF text string → Rust string (UTF-16BE with a byte-order mark, else PDFDocEncoding ≈ Latin-1).
pub fn text_string(b: &[u8]) -> String {
if b.starts_with(&[0xFE, 0xFF]) {
let u: Vec<u16> = b[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
return String::from_utf16_lossy(&u);
}
if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return String::from_utf8_lossy(rest).into_owned();
}
b.iter().map(|&c| c as char).collect()
}

185
crates/pdf/src/lib.rs Normal file
View File

@ -0,0 +1,185 @@
//! Vector footage: PDF, PDF-compatible Illustrator files (`.ai`) and Encapsulated PostScript
//! (see README.md for what is read and the specifications used).
//!
//! Files parse into the same render tree as SVG ([`effectcraft_svg::Doc`]): document pixels are
//! PostScript points (1/72 in), y down, so footage rasterises at any scale (Continuously
//! Rasterize) and converts to shape layers (Create Shapes from Vector Layer). The root group's
//! children are the document's **layers**: one group per top-level optional-content group
//! (Illustrator layers), with content outside them gathered into groups of its own.
mod build;
mod color;
mod object;
mod page;
mod ps;
pub mod write;
pub use effectcraft_svg::Doc;
use effectcraft_svg::{Affine, Group, Node};
#[derive(Debug, Clone, PartialEq)]
pub enum Error {
NotVector,
Encrypted,
NoPages,
PageOutOfRange(usize),
NoBoundingBox,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::NotVector => write!(f, "not a PDF, Illustrator or EPS file"),
Error::Encrypted => write!(f, "encrypted PDF files are not supported"),
Error::NoPages => write!(f, "the PDF has no pages"),
Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n + 1),
Error::NoBoundingBox => write!(f, "the EPS file has no %%BoundingBox"),
}
}
}
impl std::error::Error for Error {}
/// File extensions read as vector footage by this crate.
pub const EXTENSIONS: &[&str] = &["pdf", "ai", "eps", "epsf", "epsi"];
/// The format of a file, by its first bytes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
Pdf,
Eps,
}
/// Sniff a PDF (`%PDF-` near the start, as PDF-compatible `.ai` files are) or an EPS file.
pub fn sniff(bytes: &[u8]) -> Option<Format> {
let head = &bytes[..bytes.len().min(1024)];
if head.windows(5).any(|w| w == b"%PDF-") {
return Some(Format::Pdf);
}
if bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) || bytes.starts_with(b"%!PS") {
return Some(Format::Eps);
}
None
}
/// A footage codec name for a sniffed file ("PDF", "AI", "EPS").
pub fn codec(path: &str, bytes: &[u8]) -> Option<&'static str> {
let f = sniff(bytes)?;
let ai = path.to_ascii_lowercase().ends_with(".ai");
Some(match (f, ai) {
(_, true) => "AI",
(Format::Pdf, false) => "PDF",
(Format::Eps, false) => "EPS",
})
}
/// Parse the first page of a PDF / `.ai`, or an EPS file.
pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
parse_page(bytes, 0)
}
/// Parse page `index` of a PDF (EPS files have one page).
pub fn parse_page(bytes: &[u8], index: usize) -> Result<Doc, Error> {
match sniff(bytes) {
Some(Format::Pdf) => parse_pdf(bytes, index),
Some(Format::Eps) => parse_eps(bytes),
None => Err(Error::NotVector),
}
}
/// Number of pages (1 for EPS).
pub fn page_count(bytes: &[u8]) -> usize {
match sniff(bytes) {
Some(Format::Pdf) => page::pages(&object::File::parse(bytes)).len(),
Some(Format::Eps) => 1,
None => 0,
}
}
/// Build the document: `root` children become layer groups (see the crate docs).
fn finish(nodes: Vec<Node>, flags: Vec<bool>, skipped: Vec<String>, width: f64, height: f64, to_doc: Affine) -> Doc {
let mut layers: Vec<Node> = vec![];
let mut loose: Vec<Node> = vec![];
let flush = |loose: &mut Vec<Node>, layers: &mut Vec<Node>| {
if !loose.is_empty() {
let mut g = build::group("");
g.children = std::mem::take(loose);
layers.push(Node::Group(g));
}
};
for (n, is_layer) in nodes.into_iter().zip(flags.into_iter().chain(std::iter::repeat(false))) {
if is_layer {
flush(&mut loose, &mut layers);
layers.push(n);
} else {
loose.push(n);
}
}
flush(&mut loose, &mut layers);
// Name unnamed layers "Layer N" (bottom first, as Illustrator numbers them).
for (i, l) in layers.iter_mut().enumerate() {
if let Node::Group(g) = l
&& g.name.is_empty()
{
g.name = format!("Layer {}", i + 1);
}
}
Doc { width, height, root: Group { name: "page".into(), transform: to_doc, opacity: 1.0, children: layers, clip: vec![] }, skipped }
}
fn parse_pdf(bytes: &[u8], index: usize) -> Result<Doc, Error> {
let file = object::File::parse(bytes);
if file.encrypted() {
return Err(Error::Encrypted);
}
let pages = page::pages(&file);
if pages.is_empty() {
return Err(Error::NoPages);
}
let pg = pages.get(index).ok_or(Error::PageOutOfRange(index))?;
let [x0, y0, x1, y1] = pg.bbox;
let (w, h) = (x1 - x0, y1 - y0);
// Default user space (y up) → document pixels (y down), with the page's /Rotate.
let (to_doc, dw, dh) = match pg.rotate {
90 => (Affine::new([0.0, 1.0, 1.0, 0.0, -y0, -x0]), h, w),
180 => (Affine::new([-1.0, 0.0, 0.0, 1.0, x1, -y0]), w, h),
270 => (Affine::new([0.0, -1.0, -1.0, 0.0, y1, x1]), h, w),
_ => (Affine::new([1.0, 0.0, 0.0, -1.0, -x0, y1]), w, h),
};
let content = page::page_content(&file, pg);
let mut it = page::Interp::new(&file, pg.bbox);
it.run(&content, &pg.resources, Affine::IDENTITY, 0);
let (nodes, flags, skipped) = it.b.finish();
Ok(finish(nodes, flags, skipped, dw.max(1.0), dh.max(1.0), to_doc))
}
fn parse_eps(bytes: &[u8]) -> Result<Doc, Error> {
let (ps, bb) = ps::eps_parts(bytes).ok_or(Error::NoBoundingBox)?;
let mut it = ps::Interp::new(bb);
it.run(ps);
let (nodes, flags, skipped) = it.b.finish();
let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -bb[0], bb[3]]);
Ok(finish(nodes, flags, skipped, (bb[2] - bb[0]).max(1.0), (bb[3] - bb[1]).max(1.0), to_doc))
}
/// The document's layer names (the root's children), bottom first.
pub fn layer_names(doc: &Doc) -> Vec<String> {
doc.root
.children
.iter()
.map(|n| match n {
Node::Group(g) => g.name.clone(),
Node::Shape(s) => s.name.clone(),
})
.collect()
}
/// The document with only layer `index` (for one layer of a file imported as a composition).
pub fn layer_doc(doc: &Doc, index: usize) -> Doc {
let mut d = doc.clone();
d.root.children = doc.root.children.get(index).cloned().into_iter().collect();
d
}
#[cfg(test)]
mod tests;

708
crates/pdf/src/object.rs Normal file
View File

@ -0,0 +1,708 @@
//! PDF objects (ISO 32000-1 §7.2–7.5): the lexer, the object model, stream filters and the
//! indirect-object table.
//!
//! The table is built by scanning the file for `n g obj` headers (later definitions win, as
//! incremental updates do) and by unpacking object streams, so damaged or rewritten
//! cross-reference sections do not matter.
use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq)]
pub enum Obj {
Null,
Bool(bool),
Num(f64),
Name(String),
Str(Vec<u8>),
Array(Vec<Obj>),
Dict(Dict),
Ref(u32, u16),
/// A stream: its dictionary and raw (still encoded) data.
Stream(Dict, Vec<u8>),
/// A content-stream operator (or any bare keyword).
Op(String),
}
pub type Dict = HashMap<String, Obj>;
impl Obj {
pub fn num(&self) -> Option<f64> {
match self {
Obj::Num(n) => Some(*n),
_ => None,
}
}
pub fn name(&self) -> Option<&str> {
match self {
Obj::Name(n) => Some(n),
_ => None,
}
}
pub fn array(&self) -> Option<&[Obj]> {
match self {
Obj::Array(a) => Some(a),
_ => None,
}
}
pub fn dict(&self) -> Option<&Dict> {
match self {
Obj::Dict(d) | Obj::Stream(d, _) => Some(d),
_ => None,
}
}
}
pub fn is_white(c: u8) -> bool {
matches!(c, 0 | 9 | 10 | 12 | 13 | 32)
}
pub fn is_delim(c: u8) -> bool {
matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
}
/// A byte lexer producing PDF objects (arrays and dictionaries nested).
pub struct Lexer<'a> {
pub data: &'a [u8],
pub pos: usize,
}
impl<'a> Lexer<'a> {
pub fn new(data: &'a [u8], pos: usize) -> Self {
Lexer { data, pos }
}
pub fn skip_ws(&mut self) {
while self.pos < self.data.len() {
let c = self.data[self.pos];
if is_white(c) {
self.pos += 1;
} else if c == b'%' {
while self.pos < self.data.len() && !matches!(self.data[self.pos], b'\n' | b'\r') {
self.pos += 1;
}
} else {
break;
}
}
}
fn word(&mut self) -> &'a [u8] {
let s = self.pos;
while self.pos < self.data.len() && !is_white(self.data[self.pos]) && !is_delim(self.data[self.pos]) {
self.pos += 1;
}
&self.data[s..self.pos]
}
/// The next object, `None` at the end. `]` and `>>` come back as `Op`s.
pub fn next(&mut self) -> Option<Obj> {
self.next_depth(0)
}
fn next_depth(&mut self, depth: usize) -> Option<Obj> {
self.skip_ws();
let c = *self.data.get(self.pos)?;
match c {
b'/' => {
self.pos += 1;
let w = self.word();
Some(Obj::Name(decode_name(w)))
}
b'(' => Some(Obj::Str(self.literal_string())),
b'<' if self.data.get(self.pos + 1) == Some(&b'<') => {
self.pos += 2;
let mut d = Dict::new();
loop {
if depth > 64 {
return Some(Obj::Null);
}
let k = self.next_depth(depth + 1);
match k {
None => break,
Some(Obj::Op(o)) if o == ">>" => break,
Some(Obj::Name(k)) => {
let v = self.next_depth(depth + 1).unwrap_or(Obj::Null);
let v = self.maybe_ref(v);
d.insert(k, v);
}
Some(_) => {}
}
}
Some(Obj::Dict(d))
}
b'<' => {
self.pos += 1;
let mut hex = vec![];
while self.pos < self.data.len() && self.data[self.pos] != b'>' {
let h = self.data[self.pos];
if h.is_ascii_hexdigit() {
hex.push(h);
}
self.pos += 1;
}
self.pos += 1;
if hex.len() % 2 == 1 {
hex.push(b'0');
}
Some(Obj::Str(hex.chunks(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect()))
}
b'>' if self.data.get(self.pos + 1) == Some(&b'>') => {
self.pos += 2;
Some(Obj::Op(">>".into()))
}
b'[' => {
self.pos += 1;
let mut a = vec![];
loop {
if depth > 64 {
return Some(Obj::Null);
}
match self.next_depth(depth + 1) {
None => break,
Some(Obj::Op(o)) if o == "]" => break,
Some(v) => {
let v = self.maybe_ref(v);
a.push(v);
}
}
}
Some(Obj::Array(a))
}
b']' | b'{' | b'}' | b')' | b'>' => {
self.pos += 1;
Some(Obj::Op((c as char).to_string()))
}
_ => {
let w = self.word();
if w.is_empty() {
self.pos += 1;
return Some(Obj::Op(String::new()));
}
Some(match w {
b"true" => Obj::Bool(true),
b"false" => Obj::Bool(false),
b"null" => Obj::Null,
_ => match parse_num(w) {
Some(n) => Obj::Num(n),
None => Obj::Op(String::from_utf8_lossy(w).into_owned()),
},
})
}
}
}
/// `n g R` after a number inside arrays and dictionaries.
fn maybe_ref(&mut self, v: Obj) -> Obj {
let Obj::Num(n) = v else { return v };
if n.fract() != 0.0 || n < 0.0 {
return v;
}
let save = self.pos;
self.skip_ws();
let gp = self.pos;
let g = self.word();
if let Some(gn) = parse_num(g).filter(|g| g.fract() == 0.0 && *g >= 0.0) {
self.skip_ws();
if self.data.get(self.pos) == Some(&b'R') && self.data.get(self.pos + 1).is_none_or(|c| is_white(*c) || is_delim(*c)) {
self.pos += 1;
return Obj::Ref(n as u32, gn as u16);
}
}
let _ = gp;
self.pos = save;
v
}
fn literal_string(&mut self) -> Vec<u8> {
self.pos += 1;
let mut out = vec![];
let mut depth = 1;
while self.pos < self.data.len() {
let c = self.data[self.pos];
self.pos += 1;
match c {
b'\\' => {
let Some(&e) = self.data.get(self.pos) else { break };
self.pos += 1;
match e {
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
b'b' => out.push(8),
b'f' => out.push(12),
b'\r' => {
if self.data.get(self.pos) == Some(&b'\n') {
self.pos += 1;
}
}
b'\n' => {}
b'0'..=b'7' => {
let mut v = (e - b'0') as u32;
for _ in 0..2 {
match self.data.get(self.pos) {
Some(d @ b'0'..=b'7') => {
v = v * 8 + (d - b'0') as u32;
self.pos += 1;
}
_ => break,
}
}
out.push(v as u8);
}
other => out.push(other),
}
}
b'(' => {
depth += 1;
out.push(c);
}
b')' => {
depth -= 1;
if depth == 0 {
break;
}
out.push(c);
}
_ => out.push(c),
}
}
out
}
}
fn hexv(c: u8) -> u8 {
match c {
b'0'..=b'9' => c - b'0',
b'a'..=b'f' => c - b'a' + 10,
b'A'..=b'F' => c - b'A' + 10,
_ => 0,
}
}
fn decode_name(w: &[u8]) -> String {
let mut out = vec![];
let mut i = 0;
while i < w.len() {
if w[i] == b'#' && w.get(i + 1).is_some_and(u8::is_ascii_hexdigit) && w.get(i + 2).is_some_and(u8::is_ascii_hexdigit) {
out.push((hexv(w[i + 1]) << 4) | hexv(w[i + 2]));
i += 3;
} else {
out.push(w[i]);
i += 1;
}
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn parse_num(w: &[u8]) -> Option<f64> {
if w.is_empty() || !w.iter().all(|c| c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.')) || !w.iter().any(u8::is_ascii_digit) {
return None;
}
let s = std::str::from_utf8(w).ok()?;
// "--5" and similar malformed numbers: take what parses.
s.parse::<f64>().ok().or_else(|| s.trim_start_matches(['+', '-']).parse::<f64>().ok().map(|v| if s.starts_with('-') { -v } else { v }))
}
// ------------------------------------------------------------------ filters
/// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT,
/// JBIG2) give `None`.
pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option<Vec<u8>> {
let filters: Vec<String> = match d.get("Filter").map(|f| file.resolve(f)) {
None => vec![],
Some(Obj::Name(n)) => vec![n.clone()],
Some(Obj::Array(a)) => a.iter().filter_map(|f| file.resolve(f).name().map(str::to_string)).collect(),
_ => vec![],
};
let parms: Vec<Option<Dict>> = match d.get("DecodeParms").or_else(|| d.get("DP")).map(|p| file.resolve(p)) {
Some(Obj::Dict(p)) => vec![Some(p.clone())],
Some(Obj::Array(a)) => a.iter().map(|p| file.resolve(p).dict().cloned()).collect(),
_ => vec![],
};
let mut data = raw.to_vec();
for (i, f) in filters.iter().enumerate() {
data = match f.as_str() {
"FlateDecode" | "Fl" => inflate(&data)?,
"ASCIIHexDecode" | "AHx" => ascii_hex(&data),
"ASCII85Decode" | "A85" => ascii85(&data),
"LZWDecode" | "LZW" => {
lzw(&data, parms.get(i).and_then(|p| p.as_ref()).and_then(|p| p.get("EarlyChange")).and_then(Obj::num).unwrap_or(1.0) != 0.0)
}
"RunLengthDecode" | "RL" => run_length(&data),
_ => return None,
};
if let Some(Some(p)) = parms.get(i)
&& matches!(f.as_str(), "FlateDecode" | "Fl" | "LZWDecode" | "LZW")
{
data = predict(&data, p);
}
}
Some(data)
}
pub fn inflate(data: &[u8]) -> Option<Vec<u8>> {
miniz_oxide::inflate::decompress_to_vec_zlib(data).ok().or_else(|| miniz_oxide::inflate::decompress_to_vec(data).ok()).or_else(|| {
// Truncated streams: keep what inflates.
let mut d = miniz_oxide::inflate::stream::InflateState::new_boxed(miniz_oxide::DataFormat::Zlib);
let mut out = vec![0u8; data.len() * 8 + 1024];
let r = miniz_oxide::inflate::stream::inflate(&mut d, data, &mut out, miniz_oxide::MZFlush::Finish);
(r.bytes_written > 0).then(|| out[..r.bytes_written].to_vec())
})
}
fn ascii_hex(data: &[u8]) -> Vec<u8> {
let mut hex: Vec<u8> = data.iter().copied().take_while(|c| *c != b'>').filter(u8::is_ascii_hexdigit).collect();
if hex.len() % 2 == 1 {
hex.push(b'0');
}
hex.chunks(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect()
}
pub fn ascii85(data: &[u8]) -> Vec<u8> {
let mut out = vec![];
let mut group = [0u32; 5];
let mut n = 0;
let mut i = 0;
if data.starts_with(b"<~") {
i = 2;
}
while i < data.len() {
let c = data[i];
i += 1;
match c {
b'~' => break,
b'z' if n == 0 => out.extend_from_slice(&[0; 4]),
b'!'..=b'u' => {
group[n] = (c - b'!') as u32;
n += 1;
if n == 5 {
let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d));
out.extend_from_slice(&v.to_be_bytes());
n = 0;
}
}
_ => {}
}
}
if n > 1 {
for g in group.iter_mut().skip(n) {
*g = 84;
}
let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d));
out.extend_from_slice(&v.to_be_bytes()[..n - 1]);
}
out
}
fn lzw(data: &[u8], early: bool) -> Vec<u8> {
let mut out = vec![];
let mut table: Vec<Vec<u8>> = (0..=255u16).map(|b| vec![b as u8]).collect();
table.push(vec![]);
table.push(vec![]);
let (mut bits, mut acc, mut nbits) = (9u32, 0u32, 0u32);
let mut prev: Option<Vec<u8>> = None;
for &b in data {
acc = (acc << 8) | b as u32;
nbits += 8;
while nbits >= bits {
let code = ((acc >> (nbits - bits)) & ((1 << bits) - 1)) as usize;
nbits -= bits;
match code {
256 => {
table.truncate(258);
bits = 9;
prev = None;
continue;
}
257 => return out,
_ => {}
}
let entry = if code < table.len() {
table[code].clone()
} else if let Some(p) = &prev {
let mut e = p.clone();
e.push(p[0]);
e
} else {
return out;
};
out.extend_from_slice(&entry);
if let Some(p) = prev {
let mut e = p;
e.push(entry[0]);
table.push(e);
}
prev = Some(entry);
let limit = table.len() + usize::from(early);
if limit >= (1 << bits) && bits < 12 {
bits += 1;
}
}
}
out
}
fn run_length(data: &[u8]) -> Vec<u8> {
let mut out = vec![];
let mut i = 0;
while i < data.len() {
let l = data[i] as usize;
i += 1;
if l < 128 {
out.extend_from_slice(&data[i.min(data.len())..(i + l + 1).min(data.len())]);
i += l + 1;
} else if l > 128 {
if let Some(&b) = data.get(i) {
out.extend(std::iter::repeat_n(b, 257 - l));
}
i += 1;
} else {
break;
}
}
out
}
/// PNG / TIFF predictors (`/Predictor`, `/Colors`, `/BitsPerComponent`, `/Columns`).
fn predict(data: &[u8], p: &Dict) -> Vec<u8> {
let g = |k: &str, d: f64| p.get(k).and_then(Obj::num).unwrap_or(d) as usize;
let pred = g("Predictor", 1.0);
if pred < 10 {
return data.to_vec();
}
let bpp = (g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8).max(1);
let row = (g("Columns", 1.0) * g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8);
let mut out = Vec::with_capacity(data.len());
let mut prev = vec![0u8; row];
for chunk in data.chunks(row + 1) {
if chunk.len() < row + 1 {
break;
}
let ft = chunk[0];
let mut cur = chunk[1..].to_vec();
for i in 0..row {
let a = if i >= bpp { cur[i - bpp] as i32 } else { 0 };
let b = prev[i] as i32;
let c = if i >= bpp { prev[i - bpp] as i32 } else { 0 };
let add = match ft {
1 => a,
2 => b,
3 => (a + b) / 2,
4 => {
let pp = a + b - c;
let (pa, pb, pc) = ((pp - a).abs(), (pp - b).abs(), (pp - c).abs());
if pa <= pb && pa <= pc {
a
} else if pb <= pc {
b
} else {
c
}
}
_ => 0,
};
cur[i] = (cur[i] as i32 + add) as u8;
}
out.extend_from_slice(&cur);
prev = cur;
}
out
}
// ------------------------------------------------------------------ the file
/// Every indirect object of a PDF file, plus the trailer dictionaries found.
pub struct File {
pub objects: HashMap<u32, Obj>,
pub trailers: Vec<Dict>,
}
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
if from >= hay.len() || needle.is_empty() {
return None;
}
hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
}
impl File {
pub fn parse(data: &[u8]) -> File {
let mut f = File { objects: HashMap::new(), trailers: vec![] };
let mut i = 0;
while let Some(p) = find(data, b"obj", i) {
i = p + 3;
if p >= 3 && &data[p - 3..p] == b"end" {
continue;
}
if data.get(p + 3).is_some_and(|c| !is_white(*c) && !is_delim(*c)) {
continue;
}
// Walk back over "num gen ".
let mut q = p;
let back_ws = |q: &mut usize| {
let s = *q;
while *q > 0 && is_white(data[*q - 1]) {
*q -= 1;
}
*q < s
};
let back_digits = |q: &mut usize| {
let e = *q;
while *q > 0 && data[*q - 1].is_ascii_digit() {
*q -= 1;
}
(*q < e).then(|| std::str::from_utf8(&data[*q..e]).ok()?.parse::<u32>().ok()).flatten()
};
if !back_ws(&mut q) {
continue;
}
let Some(_gen) = back_digits(&mut q) else { continue };
if !back_ws(&mut q) {
continue;
}
let Some(num) = back_digits(&mut q) else { continue };
if q > 0 && !is_white(data[q - 1]) && !is_delim(data[q - 1]) {
continue;
}
let mut lx = Lexer::new(data, p + 3);
let Some(obj) = lx.next() else { continue };
let obj = lx.maybe_ref(obj);
let obj = match obj {
Obj::Dict(d) => {
let save = lx.pos;
lx.skip_ws();
if data[lx.pos..].starts_with(b"stream") {
let mut s = lx.pos + 6;
if data.get(s) == Some(&b'\r') {
s += 1;
}
if data.get(s) == Some(&b'\n') {
s += 1;
}
let declared = d.get("Length").and_then(Obj::num).map(|l| l as usize);
let end = match declared.filter(|l| {
s + l <= data.len() && {
let mut k = s + l;
while k < data.len() && is_white(data[k]) {
k += 1;
}
data[k..].starts_with(b"endstream")
}
}) {
Some(l) => s + l,
None => {
let mut e = find(data, b"endstream", s).unwrap_or(data.len());
if e > s && data[e - 1] == b'\n' {
e -= 1;
}
if e > s && data[e - 1] == b'\r' {
e -= 1;
}
e
}
};
i = end;
Obj::Stream(d, data[s..end].to_vec())
} else {
lx.pos = save;
Obj::Dict(d)
}
}
o => o,
};
if let Obj::Stream(d, _) = &obj
&& d.get("Type").and_then(Obj::name) == Some("XRef")
{
f.trailers.push(d.clone());
}
f.objects.insert(num, obj);
}
// Trailer dictionaries of classic cross-reference sections.
let mut i = 0;
while let Some(p) = find(data, b"trailer", i) {
i = p + 7;
let mut lx = Lexer::new(data, i);
if let Some(Obj::Dict(d)) = lx.next() {
f.trailers.push(d);
}
}
// Object streams.
let streams: Vec<(Dict, Vec<u8>)> = f
.objects
.values()
.filter_map(|o| match o {
Obj::Stream(d, raw) if d.get("Type").and_then(Obj::name) == Some("ObjStm") => Some((d.clone(), raw.clone())),
_ => None,
})
.collect();
for (d, raw) in streams {
let Some(dec) = decode_stream(&f, &d, &raw) else { continue };
let n = d.get("N").and_then(Obj::num).unwrap_or(0.0) as usize;
let first = d.get("First").and_then(Obj::num).unwrap_or(0.0) as usize;
let mut lx = Lexer::new(&dec, 0);
let mut heads = vec![];
for _ in 0..n.min(100_000) {
let (Some(Obj::Num(num)), Some(Obj::Num(off))) = (lx.next(), lx.next()) else { break };
heads.push((num as u32, off as usize));
}
for (num, off) in heads {
if f.objects.contains_key(&num) || first + off >= dec.len() {
continue;
}
let mut lx = Lexer::new(&dec, first + off);
if let Some(o) = lx.next() {
let o = lx.maybe_ref(o);
f.objects.insert(num, o);
}
}
}
f
}
/// Follow references (bounded).
pub fn resolve<'a>(&'a self, o: &'a Obj) -> &'a Obj {
let mut o = o;
for _ in 0..32 {
match o {
Obj::Ref(n, _) => match self.objects.get(n) {
Some(x) => o = x,
None => return &Obj::Null,
},
_ => return o,
}
}
&Obj::Null
}
pub fn get<'a>(&'a self, d: &'a Dict, key: &str) -> Option<&'a Obj> {
d.get(key).map(|o| self.resolve(o)).filter(|o| !matches!(o, Obj::Null))
}
pub fn get_dict<'a>(&'a self, d: &'a Dict, key: &str) -> Option<&'a Dict> {
self.get(d, key)?.dict()
}
pub fn get_num(&self, d: &Dict, key: &str) -> Option<f64> {
self.get(d, key)?.num()
}
/// Numbers of an array (references resolved).
pub fn nums(&self, o: &Obj) -> Vec<f64> {
self.resolve(o).array().map(|a| a.iter().filter_map(|x| self.resolve(x).num()).collect()).unwrap_or_default()
}
/// The document catalog.
pub fn catalog(&self) -> Option<&Dict> {
for t in self.trailers.iter().rev() {
if let Some(Obj::Dict(d)) = t.get("Root").map(|r| self.resolve(r)) {
return Some(d);
}
}
let mut cats: Vec<(&u32, &Obj)> =
self.objects.iter().filter(|(_, o)| matches!(o, Obj::Dict(d) if d.get("Type").and_then(Obj::name) == Some("Catalog"))).collect();
cats.sort_by_key(|(n, _)| **n);
cats.last().and_then(|(_, o)| o.dict())
}
pub fn encrypted(&self) -> bool {
self.trailers.iter().any(|t| t.contains_key("Encrypt"))
}
}

573
crates/pdf/src/page.rs Normal file
View File

@ -0,0 +1,573 @@
//! Pages and content streams (ISO 32000-1 §7.7.3, §8): the page tree, the graphics state,
//! path construction and painting, clipping, colour, shading patterns and `sh`, form XObjects
//! and optional-content marked sequences. Text, images and tiling patterns are skipped (listed
//! in [`effectcraft_svg::Doc::skipped`]).
use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Gradient, GradientKind, Join, Paint, Spread, Stroke};
use kurbo::Point;
use crate::build::Builder;
use crate::color::{Cs, Func, color_space, text_string};
use crate::object::{Dict, File, Lexer, Obj, decode_stream};
/// A page: its dictionary with inherited attributes resolved.
pub struct Page {
pub dict: Dict,
pub resources: Dict,
/// CropBox (else MediaBox) `[x0, y0, x1, y1]`.
pub bbox: [f64; 4],
pub rotate: i32,
}
/// The pages in order.
pub fn pages(file: &File) -> Vec<Page> {
let mut out = vec![];
let Some(cat) = file.catalog() else { return out };
let Some(root) = file.get_dict(cat, "Pages") else { return out };
fn walk(file: &File, node: &Dict, inherited: (Option<Dict>, Option<Vec<f64>>, Option<Vec<f64>>, i32), out: &mut Vec<Page>, depth: usize) {
if depth > 32 || out.len() > 10_000 {
return;
}
let res = file.get_dict(node, "Resources").cloned().or(inherited.0);
let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.1);
let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.2);
let rot = file.get_num(node, "Rotate").map(|r| r as i32).unwrap_or(inherited.3);
match file.get(node, "Kids").and_then(Obj::array) {
Some(kids) if file.get(node, "Type").and_then(Obj::name) != Some("Page") => {
for k in kids {
if let Some(d) = file.resolve(k).dict() {
walk(file, d, (res.clone(), media.clone(), crop.clone(), rot), out, depth + 1);
}
}
}
_ => {
let b = crop.or(media).unwrap_or_else(|| vec![0.0, 0.0, 612.0, 792.0]);
let bbox = [b[0].min(b[2]), b[1].min(b[3]), b[0].max(b[2]), b[1].max(b[3])];
out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, rotate: rot.rem_euclid(360) });
}
}
}
walk(file, root, (None, None, None, 0), &mut out, 0);
out
}
/// The page's content streams, decoded and joined.
pub fn page_content(file: &File, page: &Page) -> Vec<u8> {
let mut out = vec![];
let parts: Vec<&Obj> = match page.dict.get("Contents").map(|c| file.resolve(c)) {
Some(Obj::Array(a)) => a.iter().map(|x| file.resolve(x)).collect(),
Some(o) => vec![o],
None => vec![],
};
for p in parts {
if let Obj::Stream(d, raw) = p
&& let Some(data) = decode_stream(file, d, raw)
{
out.extend_from_slice(&data);
out.push(b'\n');
}
}
out
}
#[derive(Clone)]
enum Fill {
Color([f64; 3]),
Paint(Paint),
None,
}
#[derive(Clone)]
struct GState {
ctm: Affine,
fill_cs: Cs,
stroke_cs: Cs,
fill: Fill,
stroke: Fill,
lw: f64,
cap: Cap,
join: Join,
miter: f64,
dash: Option<(Vec<f64>, f64)>,
fill_alpha: f64,
stroke_alpha: f64,
}
impl Default for GState {
fn default() -> Self {
GState {
ctm: Affine::IDENTITY,
fill_cs: Cs::Gray,
stroke_cs: Cs::Gray,
fill: Fill::Color([0.0; 3]),
stroke: Fill::Color([0.0; 3]),
lw: 1.0,
cap: Cap::Butt,
join: Join::Miter,
miter: 10.0,
dash: None,
fill_alpha: 1.0,
stroke_alpha: 1.0,
}
}
}
pub(crate) struct Interp<'a> {
file: &'a File,
pub b: Builder,
gs: GState,
saved: Vec<(GState, usize)>,
path: BezPath,
cur: Option<Point>,
start: Option<Point>,
pending_clip: Option<FillRule>,
marked: Vec<Option<usize>>,
/// The page box in default user space (for `sh`).
page_box: [f64; 4],
budget: usize,
}
fn affine(v: &[f64]) -> Affine {
if v.len() < 6 {
return Affine::IDENTITY;
}
Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]])
}
impl<'a> Interp<'a> {
pub fn new(file: &'a File, page_box: [f64; 4]) -> Self {
Interp {
file,
b: Builder::new(),
gs: GState::default(),
saved: vec![],
path: BezPath::new(),
cur: None,
start: None,
pending_clip: None,
marked: vec![],
page_box,
budget: 20_000_000,
}
}
/// Run a content stream with resources `res`; `pattern_base` maps pattern space to the page
/// (the CTM where the stream's default coordinate system starts).
pub fn run(&mut self, content: &[u8], res: &Dict, pattern_base: Affine, depth: usize) {
let mut lx = Lexer::new(content, 0);
let mut args: Vec<Obj> = Vec::with_capacity(8);
let mut in_text = false;
while let Some(o) = lx.next() {
let Obj::Op(op) = o else {
if args.len() < 64 {
args.push(o);
}
continue;
};
if self.budget == 0 {
self.b.skip("content (operation limit)");
return;
}
self.budget -= 1;
if in_text {
if op == "ET" {
in_text = false;
}
args.clear();
continue;
}
let n = |i: usize| args.get(i).and_then(Obj::num).unwrap_or(0.0);
let nums = || args.iter().filter_map(Obj::num).collect::<Vec<f64>>();
match op.as_str() {
"q" => self.saved.push((self.gs.clone(), self.b.depth())),
"Q" => {
if let Some((g, d)) = self.saved.pop() {
self.gs = g;
self.b.close_to(d);
}
}
"cm" => self.gs.ctm *= affine(&nums()),
"w" => self.gs.lw = n(0),
"J" => self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(n(0) as usize).min(2)],
"j" => self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(n(0) as usize).min(2)],
"M" => self.gs.miter = n(0),
"d" => {
let arr = args.first().map(|a| self.file.nums(a)).unwrap_or_default();
self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then(|| (arr, n(1)));
}
"gs" => {
if let Some(name) = args.first().and_then(Obj::name)
&& let Some(g) = self.file.get_dict(res, "ExtGState").and_then(|e| self.file.get_dict(e, name))
{
self.ext_gstate(g);
}
}
// Path construction.
"m" => {
let p = Point::new(n(0), n(1));
self.path.move_to(p);
self.cur = Some(p);
self.start = Some(p);
}
"l" => {
let p = Point::new(n(0), n(1));
self.ensure_start();
self.path.line_to(p);
self.cur = Some(p);
}
"c" => {
self.ensure_start();
let p = Point::new(n(4), n(5));
self.path.curve_to(Point::new(n(0), n(1)), Point::new(n(2), n(3)), p);
self.cur = Some(p);
}
"v" => {
self.ensure_start();
let c0 = self.cur.unwrap_or_default();
let p = Point::new(n(2), n(3));
self.path.curve_to(c0, Point::new(n(0), n(1)), p);
self.cur = Some(p);
}
"y" => {
self.ensure_start();
let p = Point::new(n(2), n(3));
self.path.curve_to(Point::new(n(0), n(1)), p, p);
self.cur = Some(p);
}
"h" => {
if self.cur.is_some() {
self.path.close_path();
self.cur = self.start;
}
}
"re" => {
let (x, y, w, h) = (n(0), n(1), n(2), n(3));
self.path.move_to((x, y));
self.path.line_to((x + w, y));
self.path.line_to((x + w, y + h));
self.path.line_to((x, y + h));
self.path.close_path();
self.cur = Some(Point::new(x, y));
self.start = self.cur;
}
// Painting.
"S" => self.paint(false, None, true),
"s" => {
self.path.close_path();
self.paint(false, None, true)
}
"f" | "F" => self.paint(true, Some(FillRule::NonZero), false),
"f*" => self.paint(true, Some(FillRule::EvenOdd), false),
"B" => self.paint(true, Some(FillRule::NonZero), true),
"B*" => self.paint(true, Some(FillRule::EvenOdd), true),
"b" => {
self.path.close_path();
self.paint(true, Some(FillRule::NonZero), true)
}
"b*" => {
self.path.close_path();
self.paint(true, Some(FillRule::EvenOdd), true)
}
"n" => self.paint(false, None, false),
"W" => self.pending_clip = Some(FillRule::NonZero),
"W*" => self.pending_clip = Some(FillRule::EvenOdd),
// Colour.
"g" => self.gs.fill_cs = Cs::Gray,
"G" => self.gs.stroke_cs = Cs::Gray,
"rg" => self.gs.fill_cs = Cs::Rgb,
"RG" => self.gs.stroke_cs = Cs::Rgb,
"k" => self.gs.fill_cs = Cs::Cmyk,
"K" => self.gs.stroke_cs = Cs::Cmyk,
"cs" | "CS" => {
let cs = args.first().map(|a| color_space(self.file, a, Some(res))).unwrap_or(Cs::Gray);
let init = Fill::Color(cs.to_rgb(&cs.initial()));
if op == "cs" {
self.gs.fill_cs = cs;
self.gs.fill = init;
} else {
self.gs.stroke_cs = cs;
self.gs.stroke = init;
}
}
"sc" | "scn" | "SC" | "SCN" => {
let fill = op.starts_with('s');
let cs = if fill { self.gs.fill_cs.clone() } else { self.gs.stroke_cs.clone() };
let v = if let (Cs::Pattern, Some(name)) = (&cs, args.last().and_then(Obj::name)) {
self.pattern(res, name, pattern_base)
} else {
Fill::Color(cs.to_rgb(&nums()))
};
if fill {
self.gs.fill = v;
} else {
self.gs.stroke = v;
}
}
"sh" => {
if let Some(name) = args.first().and_then(Obj::name) {
self.shade(res, name);
}
}
"Do" => {
if let Some(name) = args.first().and_then(Obj::name) {
self.xobject(res, name, depth);
}
}
"BT" => {
in_text = true;
self.b.skip("text");
}
"BI" => {
// Inline image: skip to EI.
let d = lx.data;
let mut p = lx.pos;
while p + 2 < d.len() && !(d[p] == b'I' && d[p - 1] == b'D' && crate::object::is_white(d[p + 1])) {
p += 1;
}
while p + 2 < d.len()
&& !(crate::object::is_white(d[p - 1]) && d[p] == b'E' && d[p + 1] == b'I' && (p + 2 == d.len() || crate::object::is_white(d[p + 2])))
{
p += 1;
}
lx.pos = (p + 2).min(d.len());
self.b.skip("image");
}
"BMC" => self.marked.push(None),
"BDC" => {
let layer = match (args.first().and_then(Obj::name), args.get(1)) {
(Some("OC"), Some(Obj::Name(prop))) => self
.file
.get_dict(res, "Properties")
.and_then(|p| self.file.get_dict(p, prop))
.map(|ocg| self.ocg_name(ocg).unwrap_or_else(|| prop.clone())),
(Some("OC"), Some(Obj::Dict(ocg))) => self.ocg_name(ocg),
_ => None,
};
match layer {
Some(name) => {
self.marked.push(Some(self.b.depth()));
self.b.open(&name, true);
}
None => self.marked.push(None),
}
}
"EMC" => {
if let Some(Some(d)) = self.marked.pop() {
self.b.close_to(d);
}
}
_ => {}
}
// Colour operands set by the device-space shorthands.
match op.as_str() {
"g" | "rg" | "k" => self.gs.fill = Fill::Color(self.gs.fill_cs.to_rgb(&nums())),
"G" | "RG" | "K" => self.gs.stroke = Fill::Color(self.gs.stroke_cs.to_rgb(&nums())),
_ => {}
}
args.clear();
}
}
fn ocg_name(&self, ocg: &Dict) -> Option<String> {
// An optional-content membership dictionary names its groups in /OCGs.
let d = match self.file.get(ocg, "OCGs") {
Some(Obj::Dict(g)) => g,
Some(Obj::Array(a)) => a.first().map(|x| self.file.resolve(x)).and_then(Obj::dict).unwrap_or(ocg),
_ => ocg,
};
match self.file.get(d, "Name") {
Some(Obj::Str(s)) => Some(text_string(s)),
_ => None,
}
}
fn ensure_start(&mut self) {
if self.cur.is_none() {
self.path.move_to((0.0, 0.0));
self.cur = Some(Point::ZERO);
self.start = self.cur;
}
}
fn ext_gstate(&mut self, g: &Dict) {
let f = self.file;
if let Some(v) = f.get_num(g, "LW") {
self.gs.lw = v;
}
if let Some(v) = f.get_num(g, "LC") {
self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(v as usize).min(2)];
}
if let Some(v) = f.get_num(g, "LJ") {
self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(v as usize).min(2)];
}
if let Some(v) = f.get_num(g, "ML") {
self.gs.miter = v;
}
if let Some(v) = f.get_num(g, "CA") {
self.gs.stroke_alpha = v.clamp(0.0, 1.0);
}
if let Some(v) = f.get_num(g, "ca") {
self.gs.fill_alpha = v.clamp(0.0, 1.0);
}
if let Some(Obj::Array(d)) = f.get(g, "D") {
let arr = d.first().map(|a| f.nums(a)).unwrap_or_default();
let ph = d.get(1).and_then(|p| f.resolve(p).num()).unwrap_or(0.0);
self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then_some((arr, ph));
}
if f.get(g, "SMask").is_some_and(|s| !matches!(s, Obj::Name(n) if n == "None")) {
self.b.skip("soft mask");
}
}
fn paint_of(f: &Fill) -> Option<Paint> {
match f {
Fill::Color(c) => Some(Paint::Color(*c)),
Fill::Paint(p) => Some(p.clone()),
Fill::None => None,
}
}
fn paint(&mut self, fill: bool, rule: Option<FillRule>, stroke: bool) {
let path = std::mem::take(&mut self.path);
self.cur = None;
self.start = None;
let ctm = self.gs.ctm;
let fill = if fill { Self::paint_of(&self.gs.fill).map(|p| (p, self.gs.fill_alpha, rule.unwrap_or(FillRule::NonZero))) } else { None };
let stroke = if stroke {
Self::paint_of(&self.gs.stroke).map(|p| {
// Width 0: the thinnest line the device can draw (one pixel).
let s = { (ctm.determinant().abs()).sqrt() };
let width = if self.gs.lw <= 0.0 { if s > 0.0 { 1.0 / s } else { 1.0 } } else { self.gs.lw };
Stroke {
paint: p,
opacity: self.gs.stroke_alpha,
width,
cap: self.gs.cap,
join: self.gs.join,
miter: self.gs.miter.max(1.0),
dash: self.gs.dash.clone(),
}
})
} else {
None
};
if fill.is_some() || stroke.is_some() {
self.b.fill_stroke(path.clone(), ctm, fill, stroke);
}
if let Some(r) = self.pending_clip.take() {
self.b.clip(ctm * path, r);
}
}
/// A shading dictionary → gradient paint in shading space.
fn shading_gradient(&mut self, sh: &Dict) -> Option<Gradient> {
let f = self.file;
let ty = f.get_num(sh, "ShadingType").unwrap_or(0.0) as i32;
let cs = f.get(sh, "ColorSpace").map(|c| color_space(f, c, None)).unwrap_or(Cs::Rgb);
let coords = f.get(sh, "Coords").map(|c| f.nums(c)).unwrap_or_default();
let func = f.get(sh, "Function").map(|x| Func::read(f, x)).unwrap_or(Func::Unsupported);
let dom = f.get(sh, "Domain").map(|d| f.nums(d)).filter(|d| d.len() == 2).unwrap_or_else(|| vec![0.0, 1.0]);
let kind = match (ty, coords.len()) {
(2, 4) => GradientKind::Linear { x1: coords[0], y1: coords[1], x2: coords[2], y2: coords[3] },
(3, 6) => GradientKind::Radial { cx: coords[3], cy: coords[4], r: coords[5], fx: coords[0], fy: coords[1] },
_ => {
self.b.skip(&format!("shading type {ty}"));
return None;
}
};
if func == Func::Unsupported {
self.b.skip("shading function");
}
// Sample the function: evenly, plus exactly at stitching bounds.
let mut ts: Vec<f64> = (0..=24).map(|i| i as f64 / 24.0).collect();
for b in func.breakpoints() {
if dom[1] > dom[0] {
ts.push(((b - dom[0]) / (dom[1] - dom[0])).clamp(0.0, 1.0));
}
}
ts.sort_by(f64::total_cmp);
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
let stops = ts
.into_iter()
.map(|t| {
let c = cs.to_rgb(&func.eval1(dom[0] + t * (dom[1] - dom[0])));
(t, [c[0], c[1], c[2], 1.0])
})
.collect();
Some(Gradient { kind, stops, bbox_units: false, transform: Affine::IDENTITY, spread: Spread::Pad })
}
fn pattern(&mut self, res: &Dict, name: &str, base: Affine) -> Fill {
let f = self.file;
let Some(p) = f.get_dict(res, "Pattern").and_then(|pp| f.get_dict(pp, name)) else { return Fill::None };
match f.get_num(p, "PatternType").unwrap_or(0.0) as i32 {
2 => {
let m = f.get(p, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY);
let Some(sh) = f.get(p, "Shading").and_then(Obj::dict) else { return Fill::None };
let sh = sh.clone();
match self.shading_gradient(&sh) {
// Gradient space → shape user space (shapes carry the CTM).
Some(mut g) => {
g.transform = self.gs.ctm.inverse() * base * m;
Fill::Paint(Paint::Gradient(g))
}
None => Fill::None,
}
}
_ => {
self.b.skip("tiling pattern");
Fill::Color([0.5; 3])
}
}
}
fn shade(&mut self, res: &Dict, name: &str) {
let f = self.file;
let Some(sh) = f.get_dict(res, "Shading").and_then(|s| f.get_dict(s, name)).cloned() else { return };
let Some(g) = self.shading_gradient(&sh) else { return };
// Fill everything (the current clip limits it): the page box in user space.
let inv = self.gs.ctm.inverse();
let b = self.page_box;
let mut path = BezPath::new();
for (i, (x, y)) in [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].into_iter().enumerate() {
let p = inv * Point::new(x, y);
if i == 0 {
path.move_to(p);
} else {
path.line_to(p);
}
}
path.close_path();
self.b.fill_stroke(path, self.gs.ctm, Some((Paint::Gradient(g), self.gs.fill_alpha, FillRule::NonZero)), None);
}
fn xobject(&mut self, res: &Dict, name: &str, depth: usize) {
let f = self.file;
let Some(x) = f.get_dict(res, "XObject").and_then(|xs| xs.get(name)).map(|x| f.resolve(x)) else { return };
let Obj::Stream(d, raw) = x else { return };
match f.get(d, "Subtype").and_then(Obj::name) {
Some("Form") if depth < 12 => {
let Some(content) = decode_stream(f, d, raw) else { return };
let m = f.get(d, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY);
let form_res = f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone());
self.saved.push((self.gs.clone(), self.b.depth()));
self.gs.ctm *= m;
let bb = f.get(d, "BBox").map(|b| f.nums(b)).unwrap_or_default();
if bb.len() == 4 {
let r = kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]);
self.b.clip(self.gs.ctm * kurbo::Shape::to_path(&r, 0.1), FillRule::NonZero);
}
let base = self.gs.ctm;
let path = std::mem::take(&mut self.path);
self.run(&content, &form_res, base, depth + 1);
self.path = path;
if let Some((g, dd)) = self.saved.pop() {
self.gs = g;
self.b.close_to(dd);
}
}
Some("Image") => self.b.skip("image"),
_ => {}
}
}
}

1462
crates/pdf/src/ps.rs Normal file

File diff suppressed because it is too large Load Diff

150
crates/pdf/src/tests.rs Normal file
View File

@ -0,0 +1,150 @@
//! Test files are generated here (no third-party samples).
use super::*;
use crate::write::pdf;
const CONTENT: &str = "/OC /MC0 BDC\n0 1 0 rg 0 0 200 100 re f\nEMC\n\
/OC /MC1 BDC\nq 10 10 80 80 re W n\n1 0 0 rg 0 0 50 100 re f\nQ\n\
/Pattern cs /P0 scn 100 0 100 50 re f\n\
0 0 1 RG 4 w 120 80 m 180 80 l S\n\
BT /F1 12 Tf (ignored) Tj ET\nEMC\n";
pub(crate) fn sample_pdf(object_stream: bool) -> Vec<u8> {
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Properties << /MC0 5 0 R /MC1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>";
let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [100 0 200 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> >>";
let mut objs = vec![
(1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), None),
(3, page.to_string(), None),
(4, "<< >>".to_string(), Some(CONTENT.as_bytes().to_vec())),
(7, pattern.to_string(), None),
];
let ocg5 = "<< /Type /OCG /Name (Background) >>";
let ocg6 = "<< /Type /OCG /Name <FEFF00410072007400> >>"; // "Art" in UTF-16BE
if object_stream {
let body = format!("{ocg5} {ocg6}");
let head = format!("5 0 6 {} ", ocg5.len() + 1);
let data = format!("{head}{body}");
objs.push((8, format!("<< /Type /ObjStm /N 2 /First {} >>", head.len()), Some(data.into_bytes())));
} else {
objs.push((5, ocg5.to_string(), None));
objs.push((6, ocg6.to_string(), None));
}
pdf(&objs, 1)
}
fn px(doc: &Doc, x: i64, y: i64) -> [f32; 4] {
let (w, h) = doc.pixel_size();
let img = effectcraft_svg::rasterize(doc, w, h, 1.0);
img.get(x, y)
}
#[test]
fn pdf_paths_clip_gradient_and_layers() {
for objstm in [false, true] {
let bytes = sample_pdf(objstm);
assert_eq!(sniff(&bytes), Some(Format::Pdf));
assert_eq!(page_count(&bytes), 1);
let doc = parse(&bytes).unwrap();
assert_eq!((doc.width, doc.height), (200.0, 100.0));
assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}");
assert!(doc.skipped.contains(&"text".to_string()));
let (w, h) = doc.pixel_size();
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
let at = |x: i64, y: i64| img.get(x, y);
// Red rectangle clipped to x ≥ 10, over the green background.
assert!(at(30, 50)[0] > 0.99 && at(30, 50)[1] < 0.01, "{:?}", at(30, 50));
assert!(at(5, 50)[1] > 0.99 && at(5, 50)[0] < 0.01, "clipped out: {:?}", at(5, 50));
assert!(at(70, 50)[1] > 0.99, "{:?}", at(70, 50));
// Axial shading, red → blue left to right (in the lower half: PDF y is up).
let (l, r) = (at(110, 75), at(190, 75));
assert!(l[0] > 0.8 && l[2] < 0.2 && r[2] > 0.8 && r[0] < 0.2, "{l:?} {r:?}");
assert!(at(150, 25)[1] > 0.99, "the gradient stays in its rectangle: {:?}", at(150, 25));
// Stroked line (PDF y = 80 → 20 px from the top).
assert!(at(150, 20)[2] > 0.99 && at(150, 20)[1] < 0.01, "{:?}", at(150, 20));
// One layer at a time.
let bg = layer_doc(&doc, 0);
assert!(px(&bg, 30, 50)[1] > 0.99);
let art = layer_doc(&doc, 1);
assert_eq!(px(&art, 5, 50)[3], 0.0);
// Continuous rasterisation: twice the size stays sharp at the clip edge.
let big = effectcraft_svg::rasterize(&doc, 400, 200, 2.0);
assert!(big.get(21, 100)[0] > 0.99 && big.get(18, 100)[1] > 0.99);
}
}
#[test]
fn pdf_forms_rotation_cmyk_and_alpha() {
let content = "q 2 0 0 2 0 0 cm /Fm0 Do Q\n/GS0 gs 0 0 0 1 k 0 0 10 10 re f\n";
let form = "0 1 1 0 k 0 0 20 20 re f";
let objs = vec![
(1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 /Rotate 90 >>".to_string(), None),
(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << /XObject << /Fm0 5 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> >> /Contents 4 0 R >>"
.to_string(),
None,
),
(4, "<< >>".to_string(), Some(content.as_bytes().to_vec())),
(5, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>".to_string(), Some(form.as_bytes().to_vec())),
];
let doc = parse(&pdf(&objs, 1)).unwrap();
// Rotated a quarter turn: 50 × 100.
assert_eq!((doc.width, doc.height), (50.0, 100.0));
let (w, h) = doc.pixel_size();
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
// The form (CMYK red, clipped to its 10×10 box, scaled ×2) covers PDF (0..20, 0..20); the
// half-transparent black square covers PDF (0..10, 0..10). /Rotate 90: PDF (x, y) → (y, x).
let red = img.get(15, 15);
assert!(red[0] > 0.99 && red[1] < 0.01 && red[3] > 0.99, "{red:?}");
let dark = img.get(5, 5);
assert!((dark[0] - 0.5).abs() < 0.02 && dark[3] > 0.99, "{dark:?}");
assert_eq!(img.get(30, 30)[3], 0.0, "form clipped to its bounding box");
}
const EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\
/m {moveto} bind def /l {lineto} bind def\n\
/cm { 6 array astore concat } bind def\n\
/box { 4 dict begin /h exch def /w exch def /y exch def /x exch def x y m w 0 rlineto 0 h rlineto w neg 0 rlineto closepath end } bind def\n\
1 0 0 setrgbcolor\n10 10 30 30 box fill\n\
gsave 0 0 1 setrgbcolor 70 50 20 0 360 arc fill grestore\n\
gsave 1 0 0 1 50 0 cm 0 1 0 setrgbcolor 0 0 10 10 box fill grestore\n\
0 setgray 2 setlinewidth 0 90 m 100 90 l stroke\n\
/Helvetica findfont 12 scalefont setfont 5 5 moveto (text) show\n\
showpage\n%%EOF\n";
#[test]
fn eps_postscript_subset() {
let doc = parse(EPS.as_bytes()).unwrap();
assert_eq!((doc.width, doc.height), (100.0, 100.0));
assert!(doc.skipped.contains(&"text".to_string()));
let at = |x, y| px(&doc, x, y);
assert!(at(25, 75)[0] > 0.99 && at(25, 75)[3] > 0.99, "red box {:?}", at(25, 75));
assert!(at(70, 50)[2] > 0.99, "blue disc {:?}", at(70, 50));
assert!(at(55, 95)[1] > 0.99, "translated green box {:?}", at(55, 95));
let line = at(50, 10);
assert!(line[3] > 0.99 && line[0] < 0.01, "black line {line:?}");
assert_eq!(at(95, 70)[3], 0.0);
// DOS EPS binary header (with a fake TIFF preview after the PostScript).
let ps = EPS.as_bytes();
let mut dos = vec![0xC5, 0xD0, 0xD3, 0xC6];
dos.extend_from_slice(&30u32.to_le_bytes());
dos.extend_from_slice(&(ps.len() as u32).to_le_bytes());
dos.extend_from_slice(&[0; 18]);
dos.extend_from_slice(ps);
dos.extend_from_slice(b"II*\0 not a real preview");
let d2 = parse(&dos).unwrap();
assert_eq!(d2.root.children.len(), doc.root.children.len());
assert_eq!(codec("art.eps", &dos), Some("EPS"));
assert_eq!(codec("art.ai", &sample_pdf(false)), Some("AI"));
assert_eq!(parse(b"hello"), Err(Error::NotVector));
}
#[test]
fn filters_decode() {
assert_eq!(object::ascii85(b"<~87cURD]i,\"Ebo80~>"), b"Hello World!".to_vec());
let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6);
assert_eq!(object::inflate(&z).unwrap(), b"abc");
}

32
crates/pdf/src/write.rs Normal file
View File

@ -0,0 +1,32 @@
//! A minimal PDF writer for generating test files (EffectCraft does not export PDF).
/// Numbered objects `(number, dictionary, stream data)` (streams Flate-compressed), a
/// cross-reference table and a trailer naming object `root` as the catalog.
pub fn pdf(objects: &[(u32, String, Option<Vec<u8>>)], root: u32) -> Vec<u8> {
let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec();
let mut offsets = vec![];
for (n, dict, stream) in objects {
offsets.push((*n, out.len()));
match stream {
Some(data) => {
let z = miniz_oxide::deflate::compress_to_vec_zlib(data, 6);
let d = format!("{} /Filter /FlateDecode /Length {} >>", dict.trim_end().trim_end_matches(">>"), z.len());
out.extend_from_slice(format!("{n} 0 obj\n{d}\nstream\n").as_bytes());
out.extend_from_slice(&z);
out.extend_from_slice(b"\nendstream\nendobj\n");
}
None => out.extend_from_slice(format!("{n} 0 obj\n{dict}\nendobj\n").as_bytes()),
}
}
let xref = out.len();
let max = objects.iter().map(|o| o.0).max().unwrap_or(0);
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", max + 1).as_bytes());
for i in 1..=max {
match offsets.iter().find(|o| o.0 == i) {
Some((_, off)) => out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()),
None => out.extend_from_slice(b"0000000000 65535 f \n"),
}
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root {root} 0 R >>\nstartxref\n{xref}\n%%EOF\n", max + 1).as_bytes());
out
}

25
crates/plugin/Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "effectcraft-plugin"
description = "EffectCraft effect plug-ins: loads sandboxed WebAssembly effect modules (plug-in API v1) into the effect registry"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[features]
# The WebAssembly runtime (wasmi, a pure-Rust interpreter). Hosts enable it on native; without
# it `load_wasm` reports that WebAssembly plug-ins are unavailable.
wasm = ["dep:wasmi"]
[dependencies]
effectcraft-effects = { workspace = true }
serde_json = { workspace = true }
wasmi = { workspace = true, optional = true }
[dev-dependencies]
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }

66
crates/plugin/src/lib.rs Normal file
View File

@ -0,0 +1,66 @@
//! # effectcraft-plugin (L3)
//!
//! Loads **WebAssembly effect plug-ins** (plug-in API v1, see `docs/plugins.md`) into the effect
//! registry ([`effectcraft_effects::plugin`]). A plug-in is a `.wasm` (or `.wat`) module with
//! no imports that exports:
//!
//! | export | signature | |
//! |---|---|---|
//! | `memory` | memory | linear memory shared with the host |
//! | `ec_api_version` | `() -> i32` | must return 1 |
//! | `ec_manifest_ptr`, `ec_manifest_len` | `() -> i32` | a UTF-8 JSON [`PluginManifest`] in memory |
//! | `ec_alloc` | `(bytes: i32) -> i32` | a buffer of at least `bytes` bytes, 8-byte aligned; it may reuse the previous one |
//! | `ec_render` | `(pixels: i32, width: i32, height: i32, params: i32, nparams: i32, time: f64, scale: f64) -> i32` | process the pixels in place; 0 = ok |
//!
//! For each render the host asks `ec_alloc` for one buffer holding the parameters (`nparams`
//! little-endian `f64`s) followed by the pixels (`width × height × 4` little-endian `f32`s,
//! premultiplied RGBA, row-major), calls `ec_render`, and reads the pixels back.
//!
//! Modules run in the [wasmi](https://github.com/wasmi-labs/wasmi) interpreter (pure Rust,
//! MIT/Apache-2.0) with deterministic floats, no imports (no files, network or clock) and a
//! fuel limit per frame, so a plug-in can't escape its sandbox, hang the app, or render
//! differently on another machine. The runtime is the `wasm` feature (on for the desktop app and
//! the CLI); without it [`load_wasm`] says plug-ins are unavailable.
use effectcraft_effects::EffectSpec;
pub use effectcraft_effects::plugin::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, PluginParams, register_plugin};
#[cfg(feature = "wasm")]
mod wasm;
/// Whether this build can load WebAssembly plug-ins.
pub fn wasm_available() -> bool {
cfg!(feature = "wasm")
}
/// Load and register a WebAssembly plug-in (`bytes` = a `.wasm` binary or `.wat` text;
/// `source` = where it came from, for listings). Returns the registered effect.
pub fn load_wasm(bytes: &[u8], source: &str) -> Result<&'static EffectSpec, String> {
#[cfg(feature = "wasm")]
{
let p = wasm::WasmPlugin::new(bytes, source)?;
register_plugin(std::sync::Arc::new(p))
}
#[cfg(not(feature = "wasm"))]
{
let _ = (bytes, source);
Err("WebAssembly plug-ins are not available in this build".into())
}
}
/// [`load_wasm`] for the engine's plug-in loader hook: `{id, name, category, version, api}`.
pub fn loader(bytes: &[u8], source: &str) -> Result<serde_json::Value, String> {
let spec = load_wasm(bytes, source)?;
let m = effectcraft_effects::plugin::plugin(spec.id).map(|p| p.manifest().clone());
Ok(serde_json::json!({
"id": spec.id,
"name": spec.name,
"category": spec.category,
"version": m.as_ref().map(|m| m.version.clone()).unwrap_or_default(),
"api": PLUGIN_API_VERSION,
"params": spec.params.len(),
}))
}
#[cfg(all(test, feature = "wasm"))]
mod tests;

164
crates/plugin/src/tests.rs Normal file
View File

@ -0,0 +1,164 @@
//! WebAssembly plug-ins: load, register, render through the effect registry, sandbox limits.
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, Image};
use effectcraft_project::build::Ids;
use crate::load_wasm;
fn load_err(r: Result<&'static effectcraft_effects::EffectSpec, String>) -> String {
match r {
Err(e) => e,
Ok(s) => panic!("unexpectedly loaded {}", s.id),
}
}
/// A plug-in in WebAssembly text: multiplies RGB by `gain`, adds `lift` × `tint` when `on`.
fn gain_module(id: &str, render_body: &str) -> String {
let manifest = format!(
r#"{{"api":1,"id":"{id}","name":"Gain {id}","category":"Test Plug-ins","version":"1.0","params":[{{"id":"gain","name":"Gain","type":"slider","default":0.5,"min":0,"max":4}},{{"id":"on","name":"Lift","type":"checkbox","default":false}},{{"id":"tint","name":"Tint","type":"color","default":[0,0,1,1]}}]}}"#
);
let len = manifest.len();
let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\"");
format!(
r#"(module
(memory (export "memory") 2)
(data (i32.const 16) "{escaped}")
(func (export "ec_api_version") (result i32) i32.const 1)
(func (export "ec_manifest_ptr") (result i32) i32.const 16)
(func (export "ec_manifest_len") (result i32) i32.const {len})
(func (export "ec_alloc") (param $n i32) (result i32)
(local $need i32)
local.get $n
i32.const 131071
i32.add
i32.const 16
i32.shr_u
memory.size
i32.sub
local.tee $need
i32.const 0
i32.gt_s
if
local.get $need
memory.grow
drop
end
i32.const 65536)
(func (export "ec_render") (param $px i32) (param $w i32) (param $h i32) (param $params i32) (param $np i32) (param $t f64) (param $scale f64) (result i32)
(local $i i32) (local $end i32) (local $g f32) (local $on f64)
{render_body}
local.get $params
f64.load
f32.demote_f64
local.set $g
local.get $params
f64.load offset=8
local.set $on
local.get $px
local.get $w
local.get $h
i32.mul
i32.const 16
i32.mul
i32.add
local.set $end
local.get $px
local.set $i
block $done
loop $l
local.get $i
local.get $end
i32.ge_u
br_if $done
local.get $i
local.get $i
f32.load
local.get $g
f32.mul
f32.store
local.get $i
local.get $i
f32.load offset=4
local.get $g
f32.mul
f32.store offset=4
local.get $i
local.get $i
f32.load offset=8
local.get $g
f32.mul
local.get $on
local.get $params
f64.load offset=32
f64.mul
f32.demote_f64
f32.add
f32.store offset=8
local.get $i
i32.const 16
i32.add
local.set $i
br $l
end
end
i32.const 0))"#
)
}
fn render(spec: &effectcraft_effects::EffectSpec, gain: f64, on: bool) -> Image {
let mut next = 1;
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [4.0, 2.0]);
let mut params = effectcraft_effects::flatten_params(&g, &mut |p| p.value.clone());
params.values.insert("gain".into(), effectcraft_keyframe::Value::Scalar(gain));
params.values.insert("on".into(), effectcraft_keyframe::Value::Bool(on));
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [4.0, 2.0], seed: 1, adjustment: false, env: EffectEnv::default() };
let buf = Buf { img: Image::filled(4, 2, [0.8, 0.6, 0.2, 1.0]), offset: [0.0; 2], scale: 1.0 };
(spec.render)(&ctx, buf).img
}
#[test]
fn wasm_plugin_registers_and_renders() {
let spec = load_wasm(gain_module("org.test.gain", "").as_bytes(), "gain.wat").unwrap();
assert_eq!(spec.id, "org.test.gain");
assert_eq!(spec.category, "Test Plug-ins");
assert_eq!(spec.params.len(), 3);
// Found like a built-in, listed under its own category.
assert!(effectcraft_effects::find("org.test.gain").is_some());
assert!(effectcraft_effects::lookup("Gain org.test.gain").is_some());
assert!(effectcraft_effects::all().iter().any(|e| e.id == "org.test.gain"));
assert!(effectcraft_effects::categories().contains(&"Test Plug-ins"));
let img = render(spec, 0.5, false);
assert_eq!(img.get(1, 1), [0.4, 0.3, 0.1, 1.0]);
// The checkbox and colour parameters arrive flattened (tint blue = 1 → +1 on blue).
let img = render(spec, 1.0, true);
assert_eq!(img.get(3, 0), [0.8, 0.6, 1.2, 1.0]);
// Deterministic, and safe to run on many threads at once (each takes an instance).
let a: Vec<Image> = (0..8).map(|_| render(spec, 0.25, false)).collect();
std::thread::scope(|sc| {
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| render(spec, 0.25, false))).collect();
for h in hs {
assert_eq!(h.join().unwrap(), a[0]);
}
});
assert!(a.iter().all(|i| *i == a[0]));
// The same id can't be registered twice.
assert!(load_err(load_wasm(gain_module("org.test.gain", "").as_bytes(), "again.wat")).contains("already registered"));
}
#[test]
fn wasm_plugins_are_sandboxed() {
// A runaway loop runs out of fuel: the frame comes back unchanged instead of hanging.
let spec = load_wasm(gain_module("org.test.spin", "(loop $spin br $spin)").as_bytes(), "spin.wat").unwrap();
let img = render(spec, 0.5, false);
assert_eq!(img.get(0, 0), [0.8, 0.6, 0.2, 1.0]);
// No imports (files, clock, network…).
let imports = r#"(module (import "env" "now" (func $now (result f64))) (memory (export "memory") 1))"#;
assert!(load_err(load_wasm(imports.as_bytes(), "imports.wat")).contains("import"));
// Garbage, wrong API version, bad manifests.
assert!(load_wasm(b"\0asm nope", "bad.wasm").is_err());
let v2 = gain_module("org.test.v2", "")
.replace("(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result i32) i32.const 2)");
assert!(load_err(load_wasm(v2.as_bytes(), "v2.wat")).contains("API 2"));
let reserved = gain_module("ec.blur.mine", "");
assert!(load_err(load_wasm(reserved.as_bytes(), "reserved.wat")).contains("reserved"));
}

151
crates/plugin/src/wasm.rs Normal file
View File

@ -0,0 +1,151 @@
//! The wasmi-backed [`EffectPlugin`] (plug-in API v1 ABI, see the crate docs).
use std::sync::Mutex;
use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, TypedFunc, WasmParams, WasmResults};
use crate::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, PluginParams};
/// Instruction budget per frame: a fixed allowance plus some per pixel. Generous for per-pixel
/// work, but a runaway loop stops instead of hanging the render.
const FUEL_BASE: u64 = 50_000_000;
const FUEL_PER_PIXEL: u64 = 20_000;
type RenderFn = TypedFunc<(i32, i32, i32, i32, i32, f64, f64), i32>;
/// One instantiated module (renders running on several threads each take their own).
struct Inst {
store: Store<()>,
instance: Instance,
memory: Memory,
alloc: TypedFunc<i32, i32>,
render: RenderFn,
}
impl Inst {
fn func<P: WasmParams, R: WasmResults>(&self, name: &str) -> Result<TypedFunc<P, R>, String> {
self.instance.get_typed_func::<P, R>(&self.store, name).map_err(|_| missing(name))
}
}
fn missing(name: &str) -> String {
format!("the module exports no `{name}` (or with the wrong signature)")
}
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
fn instantiate(engine: &Engine, module: &Module) -> Result<Inst, String> {
let mut store = Store::new(engine, ());
store.set_fuel(FUEL_BASE).map_err(err)?;
let linker = Linker::<()>::new(engine);
let instance = linker.instantiate_and_start(&mut store, module).map_err(|e| format!("instantiation failed: {e}"))?;
let memory = instance.get_memory(&store, "memory").ok_or("the module exports no `memory`")?;
let alloc = instance.get_typed_func::<i32, i32>(&store, "ec_alloc").map_err(|_| missing("ec_alloc"))?;
let render = instance.get_typed_func::<(i32, i32, i32, i32, i32, f64, f64), i32>(&store, "ec_render").map_err(|_| missing("ec_render"))?;
Ok(Inst { store, instance, memory, alloc, render })
}
/// A loaded WebAssembly effect plug-in.
pub struct WasmPlugin {
manifest: PluginManifest,
source: String,
engine: Engine,
module: Module,
/// Idle instances.
pool: Mutex<Vec<Inst>>,
}
impl WasmPlugin {
pub fn new(bytes: &[u8], source: &str) -> Result<WasmPlugin, String> {
let mut config = Config::default();
config.consume_fuel(true);
let engine = Engine::new(&config);
let module = Module::new(&engine, bytes).map_err(|e| format!("not a valid WebAssembly module: {e}"))?;
if let Some(i) = module.imports().next() {
return Err(format!("plug-ins can't import anything (the module imports `{}.{}`)", i.module(), i.name()));
}
let mut inst = instantiate(&engine, &module)?;
let version = inst.func::<(), i32>("ec_api_version")?;
let v = version.call(&mut inst.store, ()).map_err(err)?;
if v != PLUGIN_API_VERSION as i32 {
return Err(format!("the module implements plug-in API {v}, this build implements API {PLUGIN_API_VERSION}"));
}
let ptr = inst.func::<(), i32>("ec_manifest_ptr")?.call(&mut inst.store, ()).map_err(err)?;
let len = inst.func::<(), i32>("ec_manifest_len")?.call(&mut inst.store, ()).map_err(err)?;
let mut text = vec![0u8; len.max(0) as usize];
inst.memory.read(&inst.store, ptr as u32 as usize, &mut text).map_err(|e| format!("manifest out of bounds: {e}"))?;
let manifest: PluginManifest = serde_json::from_slice(&text).map_err(|e| format!("bad manifest JSON: {e}"))?;
manifest.validate()?;
Ok(WasmPlugin { manifest, source: source.to_string(), engine, module, pool: Mutex::new(vec![inst]) })
}
fn take(&self) -> Result<Inst, String> {
if let Some(i) = self.pool.lock().ok().and_then(|mut p| p.pop()) {
return Ok(i);
}
instantiate(&self.engine, &self.module)
}
fn give_back(&self, inst: Inst) {
if let Ok(mut p) = self.pool.lock() {
p.push(inst);
}
}
}
impl EffectPlugin for WasmPlugin {
fn manifest(&self) -> &PluginManifest {
&self.manifest
}
fn source(&self) -> String {
self.source.clone()
}
fn render(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String> {
let n = frame.pixels.len();
let params_bytes = params.flat.len() * 8;
let total = params_bytes + n * 16;
if total > i32::MAX as usize {
return Err("frame too large for a 32-bit plug-in".into());
}
let mut inst = self.take()?;
inst.store.set_fuel(FUEL_BASE + FUEL_PER_PIXEL * n as u64).map_err(err)?;
let base = inst.alloc.call(&mut inst.store, total as i32).map_err(err)? as u32 as usize;
// Parameters, then pixels (little-endian, as WebAssembly memory is).
let mut buf: Vec<u8> = Vec::with_capacity(total);
for v in params.flat {
buf.extend_from_slice(&v.to_le_bytes());
}
for px in frame.pixels.iter() {
for c in px {
buf.extend_from_slice(&c.to_le_bytes());
}
}
inst.memory.write(&mut inst.store, base, &buf).map_err(|e| format!("ec_alloc returned a buffer out of bounds: {e}"))?;
let px_ptr = (base + params_bytes) as i32;
let r = inst
.render
.call(&mut inst.store, (px_ptr, frame.width as i32, frame.height as i32, base as i32, params.flat.len() as i32, time, frame.scale))
.map_err(|e| format!("ec_render trapped: {e}"));
let r = match r {
Ok(0) => {
inst.memory.read(&inst.store, base + params_bytes, &mut buf[params_bytes..]).map_err(err)?;
for (px, b) in frame.pixels.iter_mut().zip(buf[params_bytes..].chunks_exact(16)) {
for (c, v) in px.iter_mut().zip(b.chunks_exact(4)) {
*c = f32::from_le_bytes([v[0], v[1], v[2], v[3]]);
}
}
Ok(())
}
Ok(code) => Err(format!("ec_render returned error {code}")),
Err(e) => Err(e),
};
if r.is_ok() {
self.give_back(inst);
}
r
}
}

View File

@ -8,6 +8,7 @@ pub mod build;
pub mod essential;
pub mod props;
pub mod render_queue;
pub mod render_templates;
pub mod styles;
pub mod tracking;
@ -830,9 +831,9 @@ pub struct Proxy {
pub enabled: bool,
}
/// A layer of a layered still (a Photoshop document) used as footage
/// (File ▸ Import ▸ Composition / Composition – Retain Layer Sizes, or Choose Layer).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
/// A layer of a layered still (a Photoshop document, or a PDF / Illustrator / EPS file) used as
/// footage (File ▸ Import ▸ Composition / Composition – Retain Layer Sizes, or Choose Layer).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceLayer {
/// Layer record index in the file (bottom of the stack = 0).
pub index: u32,
@ -840,6 +841,10 @@ pub struct SourceLayer {
/// The footage is the layer's own bounds (Retain Layer Sizes) rather than the document size.
#[serde(default)]
pub layer_size: bool,
/// A Photoshop smart object: the unique id of its embedded file (linked layer data), which
/// is the footage instead of the layer's pixels.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedded: Option<String>,
}
fn one() -> u32 {
@ -856,6 +861,7 @@ pub struct Solid {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
#[allow(clippy::large_enum_variant)]
pub enum ItemKind {
Folder,
Comp(Arc<Comp>),
@ -935,11 +941,25 @@ pub struct Project {
/// The Render Queue (Composition ▸ Add to Render Queue).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub render_queue: Vec<render_queue::RenderQueueItem>,
/// Render Settings / Output Module templates and their defaults.
#[serde(default, skip_serializing_if = "render_templates::RenderTemplates::is_default")]
pub render_templates: render_templates::RenderTemplates,
/// Render Queue preferences (Notify, storage overflow folders).
#[serde(default, skip_serializing_if = "render_queue::RenderQueuePrefs::is_default")]
pub render_prefs: render_queue::RenderQueuePrefs,
}
impl Default for Project {
fn default() -> Self {
Project { schema: SCHEMA_VERSION, settings: ProjectSettings::default(), items: BTreeMap::new(), next_id: 1, render_queue: Vec::new() }
Project {
schema: SCHEMA_VERSION,
settings: ProjectSettings::default(),
items: BTreeMap::new(),
next_id: 1,
render_queue: Vec::new(),
render_templates: Default::default(),
render_prefs: Default::default(),
}
}
}

View File

@ -84,38 +84,329 @@ impl ProxyUse {
}
}
/// Render Settings ▸ Field Render: interlaced output, each frame woven from two fields rendered
/// half a frame apart.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum FieldRender {
#[default]
Off,
/// The upper field (lines 0, 2, 4…) holds the earlier time.
UpperFirst,
LowerFirst,
}
impl FieldRender {
pub const ALL: [FieldRender; 3] = [FieldRender::Off, FieldRender::UpperFirst, FieldRender::LowerFirst];
pub fn label(self) -> &'static str {
match self {
FieldRender::Off => "Off",
FieldRender::UpperFirst => "Upper Field First",
FieldRender::LowerFirst => "Lower Field First",
}
}
pub fn parse(s: &str) -> Option<FieldRender> {
match norm(s).as_str() {
"off" | "none" | "progressive" => Some(FieldRender::Off),
"upper" | "upperfirst" | "upperfieldfirst" => Some(FieldRender::UpperFirst),
"lower" | "lowerfirst" | "lowerfieldfirst" => Some(FieldRender::LowerFirst),
_ => None,
}
}
}
fn norm(s: &str) -> String {
s.to_ascii_lowercase().replace([' ', '_', '-', '.', ':', '&'], "")
}
/// Render Settings ▸ 3:2 Pulldown: with field rendering, four film frames (at 4/5 of the output
/// rate) spread over five interlaced frames in 2:3 cadence. The phase names which of the five
/// frames are Whole (both fields from one film frame) or Split.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Pulldown {
#[default]
Off,
Wssww,
Sswww,
Swwws,
Wwwss,
Wwssw,
}
/// Film frame (within a group of four) of each of the ten fields of the 2:3 cadence.
const CADENCE: [i64; 10] = [0, 0, 1, 1, 1, 2, 2, 3, 3, 3];
impl Pulldown {
pub const ALL: [Pulldown; 6] = [Pulldown::Off, Pulldown::Wssww, Pulldown::Sswww, Pulldown::Swwws, Pulldown::Wwwss, Pulldown::Wwssw];
pub fn label(self) -> &'static str {
match self {
Pulldown::Off => "Off",
Pulldown::Wssww => "WSSWW",
Pulldown::Sswww => "SSWWW",
Pulldown::Swwws => "SWWWS",
Pulldown::Wwwss => "WWWSS",
Pulldown::Wwssw => "WWSSW",
}
}
pub fn parse(s: &str) -> Option<Pulldown> {
let n = norm(s);
if matches!(n.as_str(), "off" | "none") {
return Some(Pulldown::Off);
}
Pulldown::ALL.into_iter().find(|p| p.label().eq_ignore_ascii_case(&n))
}
/// Offset (in frames) into the WWSSW base cadence.
fn rotation(self) -> Option<i64> {
Some(match self {
Pulldown::Off => return None,
Pulldown::Wwssw => 0,
Pulldown::Wssww => 1,
Pulldown::Sswww => 2,
Pulldown::Swwws => 3,
Pulldown::Wwwss => 4,
})
}
/// The film frames (counted from the first film frame of the render) of the first and
/// second field of output frame `k`.
pub fn fields(self, k: u64) -> Option<(i64, i64)> {
let r = self.rotation()?;
let film = |f: i64| f.div_euclid(10) * 4 + CADENCE[f.rem_euclid(10) as usize];
let f0 = 2 * (k as i64 + r);
let base = film(2 * r);
Some((film(f0) - base, film(f0 + 1) - base))
}
/// The W/S pattern of five consecutive output frames.
pub fn pattern(self) -> String {
(0..5).filter_map(|k| self.fields(k).map(|(a, b)| if a == b { 'W' } else { 'S' })).collect()
}
}
/// A layer switch override (Render Settings ▸ Frame Blending / Motion Blur).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SwitchOverride {
/// The layer switches and the composition's enable switch.
#[default]
Current,
/// Every layer whose switch is on, whatever the composition's enable switch.
OnForChecked,
OffForAll,
}
impl SwitchOverride {
pub const ALL: [SwitchOverride; 3] = [SwitchOverride::Current, SwitchOverride::OnForChecked, SwitchOverride::OffForAll];
pub fn label(self) -> &'static str {
match self {
SwitchOverride::Current => "Current Settings",
SwitchOverride::OnForChecked => "On for Checked Layers",
SwitchOverride::OffForAll => "Off for All Layers",
}
}
pub fn parse(s: &str) -> Option<SwitchOverride> {
match norm(s).as_str() {
"current" | "currentsettings" => Some(SwitchOverride::Current),
"on" | "onforchecked" | "onforcheckedlayers" | "true" => Some(SwitchOverride::OnForChecked),
"off" | "offforall" | "offforalllayers" | "false" => Some(SwitchOverride::OffForAll),
_ => None,
}
}
}
/// Render Settings ▸ Effects.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum EffectsMode {
#[default]
Current,
AllOn,
AllOff,
}
impl EffectsMode {
pub const ALL: [EffectsMode; 3] = [EffectsMode::Current, EffectsMode::AllOn, EffectsMode::AllOff];
pub fn label(self) -> &'static str {
match self {
EffectsMode::Current => "Current Settings",
EffectsMode::AllOn => "All On",
EffectsMode::AllOff => "All Off",
}
}
pub fn parse(s: &str) -> Option<EffectsMode> {
match norm(s).as_str() {
"current" | "currentsettings" => Some(EffectsMode::Current),
"allon" | "on" => Some(EffectsMode::AllOn),
"alloff" | "off" => Some(EffectsMode::AllOff),
_ => None,
}
}
}
/// Render Settings ▸ Solo Switches / Guide Layers: the current switches, or all off.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CurrentOrOff {
#[default]
Current,
AllOff,
}
impl CurrentOrOff {
pub fn label(self) -> &'static str {
match self {
CurrentOrOff::Current => "Current Settings",
CurrentOrOff::AllOff => "All Off",
}
}
pub fn parse(s: &str) -> Option<CurrentOrOff> {
match norm(s).as_str() {
"current" | "currentsettings" | "on" => Some(CurrentOrOff::Current),
"alloff" | "off" => Some(CurrentOrOff::AllOff),
_ => None,
}
}
}
/// Render Settings ▸ Color Depth.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ColorDepth {
/// The project's bit depth.
#[default]
Current,
Bpc8,
Bpc16,
Bpc32,
}
impl ColorDepth {
pub const ALL: [ColorDepth; 4] = [ColorDepth::Current, ColorDepth::Bpc8, ColorDepth::Bpc16, ColorDepth::Bpc32];
pub fn label(self) -> &'static str {
match self {
ColorDepth::Current => "Current Settings",
ColorDepth::Bpc8 => "8 bits per channel",
ColorDepth::Bpc16 => "16 bits per channel",
ColorDepth::Bpc32 => "32 bits per channel",
}
}
pub fn parse(s: &str) -> Option<ColorDepth> {
match norm(s).as_str() {
"current" | "currentsettings" => Some(ColorDepth::Current),
"8" | "8bpc" | "8bitsperchannel" => Some(ColorDepth::Bpc8),
"16" | "16bpc" | "16bitsperchannel" => Some(ColorDepth::Bpc16),
"32" | "32bpc" | "32bitsperchannel" | "float" => Some(ColorDepth::Bpc32),
_ => None,
}
}
/// The depth renders use in a project of depth `project`.
pub fn resolve(self, project: crate::BitDepth) -> crate::BitDepth {
match self {
ColorDepth::Current => project,
ColorDepth::Bpc8 => crate::BitDepth::Bpc8,
ColorDepth::Bpc16 => crate::BitDepth::Bpc16,
ColorDepth::Bpc32 => crate::BitDepth::Bpc32,
}
}
}
/// How one output frame is sampled in time.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FrameSample {
Progressive(Tick),
/// Two fields woven together: `first` goes to the dominant field.
Fields {
first: Tick,
second: Tick,
upper_first: bool,
},
}
fn default_true() -> bool {
true
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RenderSettings {
/// The template these settings came from ("" or "Custom" once edited).
#[serde(skip_serializing_if = "String::is_empty")]
pub name: String,
pub quality: RenderQuality,
/// Output scale (1 = Full, 0.5 = Half, 1/3 = Third, 0.25 = Quarter).
pub resolution: f64,
pub time_span: TimeSpan,
/// `None` = use the comp's frame rate.
/// `None` = use the comp's frame rate (Time Sampling ▸ Use comp's frame rate).
pub frame_rate: Option<FrameRate>,
/// Motion blur for layers with the switch on (and the comp's Enable Motion Blur).
/// Motion blur allowed at all (false = Off for All Layers; see `motion_blur_mode`).
pub motion_blur: bool,
/// Motion Blur: Current Settings / On for Checked Layers / Off for All Layers.
pub motion_blur_mode: SwitchOverride,
/// Frame Blending: Current Settings / On for Checked Layers / Off for All Layers.
pub frame_blending: SwitchOverride,
/// Field Render.
pub field_render: FieldRender,
/// 3:2 Pulldown phase (with field rendering).
pub pulldown: Pulldown,
/// Effects: Current Settings / All On / All Off.
pub effects: EffectsMode,
/// Solo Switches: Current Settings / All Off.
pub solo: CurrentOrOff,
/// Guide Layers: Current Settings / All Off.
pub guide_layers: CurrentOrOff,
/// Color Depth.
pub color_depth: ColorDepth,
/// Image sequences: skip frames whose file already exists.
pub skip_existing: bool,
/// Proxy Use (Best Settings: Use No Proxies).
pub proxy_use: ProxyUse,
/// Use Storage Overflow: when the output volume is full, continue in the project's overflow
/// folders.
#[serde(default = "default_true")]
pub storage_overflow: bool,
}
impl Default for RenderSettings {
fn default() -> Self {
RenderSettings {
name: String::new(),
quality: RenderQuality::Best,
resolution: 1.0,
time_span: TimeSpan::WorkArea,
frame_rate: None,
motion_blur: true,
motion_blur_mode: SwitchOverride::Current,
frame_blending: SwitchOverride::Current,
field_render: FieldRender::Off,
pulldown: Pulldown::Off,
effects: EffectsMode::Current,
solo: CurrentOrOff::Current,
guide_layers: CurrentOrOff::AllOff,
color_depth: ColorDepth::Current,
skip_existing: false,
proxy_use: ProxyUse::UseNone,
storage_overflow: true,
}
}
}
impl RenderSettings {
/// Effective Motion Blur override.
pub fn motion_blur_override(&self) -> SwitchOverride {
if self.motion_blur { self.motion_blur_mode } else { SwitchOverride::OffForAll }
}
/// How output frame `i` is sampled (field rendering and 3:2 pulldown).
pub fn sample(&self, comp: &Comp, i: u64) -> FrameSample {
let upper_first = match self.field_render {
FieldRender::Off => return FrameSample::Progressive(self.frame_time(comp, i)),
FieldRender::UpperFirst => true,
FieldRender::LowerFirst => false,
};
let r = self.rate(comp);
if let Some((a, b)) = self.pulldown.fields(i) {
// Film frames at 4/5 of the output rate, from the first one starting in the span.
let film = FrameRate::new(r.num * 4, r.den * 5);
let f0 = Self::ceil_frame(film, self.span(comp).0);
return FrameSample::Fields { first: film.tick_of(f0 + a), second: film.tick_of(f0 + b), upper_first };
}
let f = self.first_frame(comp) + i as i64;
let t = r.tick_of(f);
let half = Tick((r.tick_of(f + 1).0 - t.0) / 2);
FrameSample::Fields { first: t, second: Tick(t.0 + half.0), upper_first }
}
/// The comp-time span `[start, end)` to render.
pub fn span(&self, comp: &Comp) -> (Tick, Tick) {
let (a, b) = match self.time_span {
@ -162,11 +453,45 @@ impl RenderSettings {
_ => "Custom",
}
}
/// One-line summary shown next to "Render Settings:" (AE shows the template name).
/// One-line summary shown next to "Render Settings:" (the template name, like AE).
pub fn summary(&self) -> String {
if !self.name.is_empty() {
return self.name.clone();
}
let q = if self.quality == RenderQuality::Best { "Best Settings" } else { "Draft Settings" };
format!("{q} · {}", self.resolution_label())
}
/// Every setting as `(label, value)` lines (render logs, the settings dialog).
pub fn describe(&self, comp: Option<&Comp>) -> Vec<(&'static str, String)> {
let span = match self.time_span {
TimeSpan::WorkArea => "Work Area Only".to_string(),
TimeSpan::LengthOfComp => "Length of Comp".to_string(),
TimeSpan::Custom { start, end } => format!("Custom ({:.3}s – {:.3}s)", start.seconds(), end.seconds()),
};
let rate = match (self.frame_rate, comp) {
(Some(r), _) => format!("{:.3} fps", r.as_f64()),
(None, Some(c)) => format!("Use comp's frame rate ({:.3})", c.frame_rate.as_f64()),
(None, None) => "Use comp's frame rate".into(),
};
vec![
("Template", if self.name.is_empty() { "Custom".into() } else { self.name.clone() }),
("Quality", format!("{:?}", self.quality)),
("Resolution", format!("{} ({:.3})", self.resolution_label(), self.resolution)),
("Proxy Use", self.proxy_use.label().into()),
("Effects", self.effects.label().into()),
("Solo Switches", self.solo.label().into()),
("Guide Layers", self.guide_layers.label().into()),
("Color Depth", self.color_depth.label().into()),
("Frame Blending", self.frame_blending.label().into()),
("Field Render", self.field_render.label().into()),
("3:2 Pulldown", self.pulldown.label().into()),
("Motion Blur", self.motion_blur_override().label().into()),
("Time Span", span),
("Frame Rate", rate),
("Skip Existing Files", if self.skip_existing { "On" } else { "Off" }.into()),
("Use Storage Overflow", if self.storage_overflow { "On" } else { "Off" }.into()),
]
}
}
/// Output Module ▸ Format.
@ -303,8 +628,30 @@ pub enum Channels {
/// Opaque, composited over the comp background colour.
#[default]
Rgb,
/// With straight (unmatted) alpha.
/// With alpha (straight or premultiplied: [`AlphaMode`]).
Rgba,
/// The alpha channel alone, as an opaque greyscale matte.
Alpha,
}
impl Channels {
pub fn label(self) -> &'static str {
match self {
Channels::Rgb => "RGB",
Channels::Rgba => "RGB + Alpha",
Channels::Alpha => "Alpha",
}
}
}
/// Output Module ▸ Color: how RGB is stored with alpha.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum AlphaMode {
/// Unmatted colour.
#[default]
Straight,
/// Colour multiplied by alpha (matted with black).
Premultiplied,
}
/// Output Module ▸ Audio Output.
@ -317,6 +664,121 @@ pub enum AudioOutput {
Off,
}
/// Output Module ▸ Audio Output ▸ sample format (PCM outputs: WAV, AIFF, QuickTime).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum AudioFormat {
#[default]
S16,
S24,
/// 32-bit float (WAV, QuickTime; AIFF writes 24-bit).
F32,
}
impl AudioFormat {
pub fn label(self) -> &'static str {
match self {
AudioFormat::S16 => "16 Bit",
AudioFormat::S24 => "24 Bit",
AudioFormat::F32 => "32 Bit Float",
}
}
pub fn parse(s: &str) -> Option<AudioFormat> {
match norm(s).as_str() {
"16" | "16bit" | "s16" => Some(AudioFormat::S16),
"24" | "24bit" | "s24" => Some(AudioFormat::S24),
"32" | "32bit" | "32bitfloat" | "f32" | "float" => Some(AudioFormat::F32),
_ => None,
}
}
}
/// Output Module ▸ Crop (pixels of the rendered frame; negative values add transparent pixels).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Crop {
pub enabled: bool,
/// Use Region of Interest: crop to `roi` (comp pixels, captured from the viewer's region of
/// interest when the option is turned on) instead of the edge values.
pub use_roi: bool,
pub roi: Option<[f64; 4]>,
pub top: i32,
pub left: i32,
pub bottom: i32,
pub right: i32,
}
impl Crop {
/// Edges `(top, left, bottom, right)` in pixels of a `w`×`h` frame rendered at `scale`.
pub fn edges(&self, w: u32, h: u32, scale: f64) -> (i32, i32, i32, i32) {
if !self.enabled {
return (0, 0, 0, 0);
}
if self.use_roi
&& let Some([x, y, rw, rh]) = self.roi
{
let l = (x * scale).round() as i32;
let t = (y * scale).round() as i32;
let r = w as i32 - ((x + rw) * scale).round() as i32;
let b = h as i32 - ((y + rh) * scale).round() as i32;
return (t, l, b, r);
}
(self.top, self.left, self.bottom, self.right)
}
}
/// Output Module ▸ Resize quality.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResizeQuality {
/// Bilinear.
Low,
/// Bicubic.
#[default]
High,
}
/// Output Module ▸ Resize.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Resize {
pub enabled: bool,
pub width: u32,
pub height: u32,
/// Lock Aspect Ratio: setting one dimension derives the other from the rendered frame.
pub lock_aspect: bool,
pub quality: ResizeQuality,
}
impl Default for Resize {
fn default() -> Self {
Resize { enabled: false, width: 1920, height: 1080, lock_aspect: true, quality: ResizeQuality::High }
}
}
/// Output Module ▸ Resize ▸ Resize to: presets.
pub const RESIZE_PRESETS: [(&str, u32, u32); 10] = [
("HDTV 1080", 1920, 1080),
("HDTV 720", 1280, 720),
("UHD 4K", 3840, 2160),
("DCI 2K", 2048, 1080),
("DCI 4K", 4096, 2160),
("NTSC DV", 720, 480),
("PAL D1/DV", 720, 576),
("Square 1080", 1080, 1080),
("Vertical 1080×1920", 1080, 1920),
("Web 640×360", 640, 360),
];
/// Output Module ▸ Post-Render Action.
pub fn post_render_parse(s: &str) -> Option<PostRenderAction> {
match norm(s).as_str() {
"none" => Some(PostRenderAction::None),
"import" => Some(PostRenderAction::Import),
"importreplace" | "importandreplace" | "importandreplaceusage" | "importreplaceusage" | "replace" => Some(PostRenderAction::ImportAndReplace),
"setproxy" | "proxy" => Some(PostRenderAction::SetProxy),
_ => None,
}
}
/// ProRes flavour.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProResProfile {
@ -420,13 +882,11 @@ pub struct VideoCodecOptions {
pub rate_control: RateControlMode,
/// Constant-quality setting 1–100 (100 ≈ visually lossless), used with [`RateControlMode::Quality`].
pub quality: u8,
/// Frames between key frames; 0 = automatic (about 2 seconds).
pub keyframe_interval: u32,
}
impl Default for VideoCodecOptions {
fn default() -> Self {
VideoCodecOptions { profile: CodecProfile::Main, level: None, rate_control: RateControlMode::Bitrate, quality: 70, keyframe_interval: 0 }
VideoCodecOptions { profile: CodecProfile::Main, level: None, rate_control: RateControlMode::Bitrate, quality: 70 }
}
}
@ -435,10 +895,6 @@ impl VideoCodecOptions {
pub const HEVC_LEVELS: [u8; 13] = [10, 20, 21, 30, 31, 40, 41, 50, 51, 52, 60, 61, 62];
/// AV1 levels (× 10) offered in the UI.
pub const AV1_LEVELS: [u8; 14] = [20, 21, 30, 31, 40, 41, 50, 51, 52, 53, 60, 61, 62, 63];
/// The key-frame interval in frames at `fps` (automatic: 2 s).
pub fn keyint(&self, fps: f64) -> u32 {
if self.keyframe_interval > 0 { self.keyframe_interval } else { (fps * 2.0).round().max(1.0) as u32 }
}
/// HEVC QP (0–51) for the constant-quality setting.
pub fn hevc_qp(&self) -> u8 {
(4.0 + (100 - self.quality.clamp(1, 100)) as f64 * 0.45).round() as u8
@ -512,17 +968,31 @@ impl OpusApplication {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputModule {
/// The template this module came from ("" or "Custom" once edited).
#[serde(skip_serializing_if = "String::is_empty")]
pub name: String,
pub format: OutputFormat,
pub channels: Channels,
/// Color: straight or premultiplied RGB with alpha.
pub alpha_mode: AlphaMode,
/// Output path or template (see the module docs).
pub output: String,
/// JPEG quality 1–100.
/// JPEG / WebM quality 1–100.
pub quality: u8,
/// H.264 target bitrate (also HEVC / AV1 with [`RateControlMode::Bitrate`]).
/// H.264 target bitrate (and WebM's when `webm_bitrate`; HEVC / AV1 with [`RateControlMode::Bitrate`]).
pub bitrate_kbps: u32,
/// WebM: rate control by `bitrate_kbps` instead of `quality`.
pub webm_bitrate: bool,
/// Movies: frames between key frames (0 = automatic, two seconds).
pub keyframe_interval: u32,
pub prores_profile: ProResProfile,
pub crop: Crop,
pub resize: Resize,
pub audio: AudioOutput,
pub audio_sample_rate: u32,
/// 1 (mono) or 2 (stereo).
pub audio_channels: u8,
pub audio_format: AudioFormat,
/// GIF: loop forever.
pub gif_loop: bool,
/// HEVC / AV1 options.
@ -532,29 +1002,110 @@ pub struct OutputModule {
/// WebM Opus audio bitrate (stereo total), kbit/s.
pub opus_bitrate_kbps: u32,
pub opus_application: OpusApplication,
/// Include Project Link: kept for parity; EffectCraft's writers have no place to store a
/// link back to the project, so it changes nothing in the file.
pub include_project_link: bool,
}
impl Default for OutputModule {
fn default() -> Self {
OutputModule {
name: String::new(),
format: OutputFormat::H264,
channels: Channels::Rgb,
alpha_mode: AlphaMode::Straight,
output: DEFAULT_TEMPLATE.into(),
quality: 90,
bitrate_kbps: 10_000,
webm_bitrate: false,
keyframe_interval: 0,
prores_profile: ProResProfile::Hq,
crop: Crop::default(),
resize: Resize::default(),
audio: AudioOutput::Auto,
audio_sample_rate: 48_000,
audio_channels: 2,
audio_format: AudioFormat::S16,
gif_loop: true,
codec: VideoCodecOptions::default(),
webm_codec: WebmVideoCodec::Vp9,
opus_bitrate_kbps: 192,
opus_application: OpusApplication::Audio,
include_project_link: true,
}
}
}
impl OutputModule {
/// The size of the frames this module writes for frames rendered at `w`×`h` with render
/// scale `scale`: after Crop and Resize (before codec rounding).
pub fn frame_size(&self, w: u32, h: u32, scale: f64) -> (u32, u32) {
let (t, l, b, r) = self.crop.edges(w, h, scale);
let cw = (w as i32 - l - r).max(1) as u32;
let ch = (h as i32 - t - b).max(1) as u32;
if !self.resize.enabled {
return (cw, ch);
}
let (mut rw, mut rh) = (self.resize.width.max(1), self.resize.height.max(1));
if self.resize.lock_aspect {
// Width rules; height follows the cropped frame's aspect.
rh = ((rw as f64 * ch as f64 / cw as f64).round() as u32).max(1);
}
rw = rw.min(30_000);
rh = rh.min(30_000);
(rw, rh)
}
/// The written frame size of an item (Render Settings resolution, Crop, Resize, codec
/// rounding).
pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) {
let (w, h) = settings.output_size(comp);
let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0));
self.format.coded_size(w, h)
}
/// Every setting as `(label, value)` lines (render logs).
pub fn describe(&self) -> Vec<(&'static str, String)> {
let mut v = vec![
("Template", if self.name.is_empty() { "Custom".into() } else { self.name.clone() }),
("Format", self.format.label().into()),
("Channels", self.channels.label().into()),
("Output To", self.output.clone()),
];
if self.channels == Channels::Rgba {
v.insert(3, ("Color", format!("{:?}", self.alpha_mode)));
}
match self.format {
OutputFormat::H264 => v.push(("Bitrate", format!("{} kbps", self.bitrate_kbps))),
OutputFormat::ProRes => v.push(("Codec", self.prores_profile.label().into())),
OutputFormat::WebM if self.webm_bitrate => v.push(("Bitrate", format!("{} kbps", self.bitrate_kbps))),
OutputFormat::WebM | OutputFormat::JpegSequence => v.push(("Quality", self.quality.to_string())),
_ => {}
}
if self.crop.enabled {
let c = &self.crop;
v.push((
"Crop",
if c.use_roi { format!("Region of Interest {:?}", c.roi) } else { format!("T {} L {} B {} R {}", c.top, c.left, c.bottom, c.right) },
));
}
if self.resize.enabled {
let r = &self.resize;
v.push(("Resize", format!("{}×{} ({:?} quality{})", r.width, r.height, r.quality, if r.lock_aspect { ", aspect locked" } else { "" })));
}
if self.format.supports_audio() {
v.push((
"Audio Output",
format!(
"{:?} · {} Hz · {} · {}",
self.audio,
self.audio_sample_rate,
if self.audio_channels == 1 { "Mono" } else { "Stereo" },
self.audio_format.label()
),
));
}
v.push(("Include Project Link", if self.include_project_link { "On" } else { "Off" }.into()));
v
}
pub fn for_format(format: OutputFormat) -> OutputModule {
let mut m = OutputModule { format, ..Default::default() };
if format.is_sequence() {
@ -566,7 +1117,7 @@ impl OutputModule {
pub fn set_format(&mut self, format: OutputFormat) {
let old = self.format;
self.format = format;
if !self.supports_alpha() {
if !self.supports_alpha() && self.channels == Channels::Rgba {
self.channels = Channels::Rgb;
}
if self.output.contains("[fileExtension]") {
@ -590,6 +1141,10 @@ impl OutputModule {
pub fn supports_alpha(&self) -> bool {
self.format.supports_alpha() && !(self.format == OutputFormat::WebM && self.webm_codec == WebmVideoCodec::Av1)
}
/// The key-frame interval in frames at `fps` ([`OutputModule::keyframe_interval`]; automatic: 2 s).
pub fn keyint(&self, fps: f64) -> u32 {
if self.keyframe_interval > 0 { self.keyframe_interval } else { (fps * 2.0).round().max(1.0) as u32 }
}
/// Codec options summary: `Main · 10000 kbps · Level Auto · Key every auto`.
fn codec_summary(&self) -> String {
let c = &self.codec;
@ -597,15 +1152,15 @@ impl OutputModule {
RateControlMode::Bitrate => format!("{} kbps", self.bitrate_kbps),
RateControlMode::Quality => format!("Quality {}", c.quality),
};
let gop = if c.keyframe_interval == 0 { "auto".to_string() } else { c.keyframe_interval.to_string() };
let gop = if self.keyframe_interval == 0 { "auto".to_string() } else { self.keyframe_interval.to_string() };
format!("{} · {rate} · Level {} · Key every {gop}", c.profile.label(), c.level_label())
}
/// One-line summary shown next to "Output Module:".
pub fn summary(&self) -> String {
let ch = match self.channels {
Channels::Rgb => "RGB",
Channels::Rgba => "RGB + Alpha",
};
if !self.name.is_empty() {
return self.name.clone();
}
let ch = self.channels.label();
match self.format {
OutputFormat::H264 => format!("H.264 · {} kbps", self.bitrate_kbps),
OutputFormat::ProRes => format!(
@ -678,6 +1233,85 @@ pub struct RenderQueueItem {
/// Post-Render Action of the first output module.
#[serde(default, skip_serializing_if = "PostRenderAction::is_none")]
pub post_render: PostRenderAction,
/// The Log menu: what the render log next to the output records.
#[serde(default, skip_serializing_if = "RenderLog::is_default")]
pub log: RenderLog,
}
/// Render Queue item ▸ Log.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum RenderLog {
/// A log only when the render fails.
#[default]
ErrorsOnly,
/// Always: the result plus every Render Settings and Output Module setting.
PlusSettings,
/// Also one line per rendered frame.
PlusPerFrameInfo,
}
impl RenderLog {
pub const ALL: [RenderLog; 3] = [RenderLog::ErrorsOnly, RenderLog::PlusSettings, RenderLog::PlusPerFrameInfo];
pub fn is_default(&self) -> bool {
*self == RenderLog::ErrorsOnly
}
pub fn label(self) -> &'static str {
match self {
RenderLog::ErrorsOnly => "Errors Only",
RenderLog::PlusSettings => "Plus Settings",
RenderLog::PlusPerFrameInfo => "Plus Per Frame Info",
}
}
pub fn parse(s: &str) -> Option<RenderLog> {
match norm(s).as_str() {
"errors" | "errorsonly" => Some(RenderLog::ErrorsOnly),
"settings" | "plussettings" => Some(RenderLog::PlusSettings),
"perframe" | "plusperframe" | "plusperframeinfo" | "frames" => Some(RenderLog::PlusPerFrameInfo),
_ => None,
}
}
}
/// The render log's path for an output: next to it, `<name>_RenderLog.txt` (a sequence's
/// frame-number run is dropped).
pub fn log_path(output: &str) -> String {
let p = std::path::Path::new(output);
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let mut stem: String = stem.replace("_[#####]", "").replace("[#####]", "");
if let Some(i) = stem.find('#') {
let n = stem[i..].chars().take_while(|c| *c == '#').count();
stem.replace_range(i..i + n, "");
}
let stem = stem.trim_end_matches(['_', '.', ' ']);
let name = format!("{}_RenderLog.txt", if stem.is_empty() { "Render" } else { stem });
match p.parent().filter(|d| !d.as_os_str().is_empty()) {
Some(d) => d.join(name).to_string_lossy().to_string(),
None => name,
}
}
/// Storage hook (Render Settings ▸ Use Storage Overflow): whether `bytes` more fit on the volume
/// holding `path`. The desktop app has no quota by default; tests and hosts with quotas supply
/// one.
pub trait StorageQuota: Send + Sync {
fn has_room(&self, path: &str, bytes: u64) -> bool;
}
/// Project-wide Render Queue preferences.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RenderQueuePrefs {
/// Notify when the render finishes (a sound and a notification in the desktop app).
pub notify: bool,
/// Storage overflow folders, used in order when the output volume is full.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub overflow_folders: Vec<String>,
}
impl RenderQueuePrefs {
pub fn is_default(&self) -> bool {
*self == RenderQueuePrefs::default()
}
}
/// What happens after an item renders (Output Module ▸ Post-Render Action).
@ -721,6 +1355,7 @@ impl RenderQueueItem {
last_output: None,
extra_outputs: vec![],
post_render: PostRenderAction::None,
log: RenderLog::ErrorsOnly,
}
}
/// Every output module, the first one first.
@ -812,6 +1447,90 @@ mod tests {
assert_eq!(ProResProfile::from_name("hq"), Some(ProResProfile::Hq));
}
#[test]
fn pulldown_cadence() {
assert_eq!(Pulldown::Wwssw.pattern(), "WWSSW");
for p in &Pulldown::ALL[1..] {
assert_eq!(p.pattern(), p.label(), "{p:?}");
}
// 2:3 cadence: ten fields carry four film frames; every output frame starts on film 0.
for p in &Pulldown::ALL[1..] {
let mut fields = vec![];
for k in 0..10 {
let (a, b) = p.fields(k).unwrap();
fields.extend([a, b]);
}
assert_eq!(fields[0], 0, "{p:?}");
assert!(fields.windows(2).all(|w| w[1] == w[0] || w[1] == w[0] + 1), "{p:?}: {fields:?}");
// 20 fields = 8 film frames' worth of cadence (a phase may start or end mid-frame).
assert!((7..=8).contains(&(*fields.last().unwrap() - fields[0])), "{p:?} {fields:?}");
// Each film frame appears in 2 or 3 consecutive fields, alternating.
let mut runs = vec![];
let mut n = 1;
for w in fields.windows(2) {
if w[1] == w[0] {
n += 1;
} else {
runs.push(n);
n = 1;
}
}
assert!(runs[1..].iter().all(|r| *r == 2 || *r == 3), "{p:?}: {runs:?}");
assert!(runs[1..].windows(2).all(|w| w[0] != w[1]), "2 and 3 alternate: {p:?}: {runs:?}");
}
assert_eq!(Pulldown::parse("sswww"), Some(Pulldown::Sswww));
// With field rendering at 29.97, film frames are 23.976 fps.
let c = Comp::new(64, 48, FrameRate::new(30000, 1001), Tick::from_seconds_f64(2.0));
let s = RenderSettings { field_render: FieldRender::UpperFirst, pulldown: Pulldown::Wssww, time_span: TimeSpan::LengthOfComp, ..Default::default() };
let film = FrameRate::new(24000, 1001);
match s.sample(&c, 1) {
FrameSample::Fields { first, second, upper_first } => {
assert!(upper_first);
assert_eq!((first, second), (film.tick_of(0), film.tick_of(1)), "WSSWW: frame 1 is split between film frames 0 and 1");
}
f => panic!("{f:?}"),
}
}
#[test]
fn field_samples_are_half_a_frame_apart() {
let c = Comp::new(64, 48, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
let s = RenderSettings { field_render: FieldRender::LowerFirst, time_span: TimeSpan::LengthOfComp, ..Default::default() };
assert_eq!(s.sample(&c, 3), FrameSample::Fields { first: Tick::from_seconds_f64(0.12), second: Tick::from_seconds_f64(0.14), upper_first: false });
let p = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
assert_eq!(p.sample(&c, 3), FrameSample::Progressive(Tick::from_seconds_f64(0.12)));
}
#[test]
fn crop_and_resize_sizes() {
let c = Comp::new(1920, 1080, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0));
let rs = RenderSettings::default();
let mut m = OutputModule::default();
assert_eq!(m.output_size(&c, &rs), (1920, 1080));
m.crop = Crop { enabled: true, top: 10, left: 20, bottom: 30, right: 41, ..Default::default() };
assert_eq!(m.output_size(&c, &rs), (1858, 1040), "H.264 rounds the odd width down");
m.format = OutputFormat::PngSequence;
assert_eq!(m.output_size(&c, &rs), (1859, 1040));
// Region of interest, at half resolution.
m.crop = Crop { enabled: true, use_roi: true, roi: Some([100.0, 50.0, 800.0, 400.0]), ..Default::default() };
let half = RenderSettings { resolution: 0.5, ..Default::default() };
assert_eq!(m.output_size(&c, &half), (400, 200));
// Negative crop pads.
m.crop = Crop { enabled: true, top: -10, bottom: -10, ..Default::default() };
assert_eq!(m.output_size(&c, &rs), (1920, 1100));
// Resize: fixed, and with the aspect locked to the (cropped) frame.
m.crop = Crop::default();
m.resize = Resize { enabled: true, width: 1280, height: 999, lock_aspect: false, quality: ResizeQuality::High };
assert_eq!(m.output_size(&c, &rs), (1280, 999));
m.resize.lock_aspect = true;
assert_eq!(m.output_size(&c, &rs), (1280, 720));
m.crop = Crop { enabled: true, left: 480, right: 480, ..Default::default() };
assert_eq!(m.output_size(&c, &rs), (1280, 1440), "960×1080 cropped, then 1280 wide at 8:9");
assert_eq!(log_path("/out/Main_[#####].png"), "/out/Main_RenderLog.txt");
assert_eq!(log_path("/out/a.mov"), "/out/a_RenderLog.txt");
assert_eq!(log_path("seq###.tif"), "seq_RenderLog.txt");
}
#[test]
fn spans() {
let mut c = Comp::new(100, 50, FrameRate::new(25, 1), Tick::from_seconds_f64(4.0));
@ -853,9 +1572,6 @@ mod tests {
assert_eq!((o.hevc_qp(), o.av1_qindex()), (4, 4));
o.quality = 1;
assert!(o.hevc_qp() <= 51 && o.av1_qindex() >= 240);
assert_eq!(o.keyint(29.97), 60);
o.keyframe_interval = 1;
assert_eq!(o.keyint(29.97), 1);
// WebM with AV1 has no alpha; switching drops RGB + Alpha.
let mut m = OutputModule::for_format(OutputFormat::WebM);
m.channels = Channels::Rgba;
@ -863,7 +1579,11 @@ mod tests {
m.webm_codec = WebmVideoCodec::Av1;
assert!(!m.supports_alpha());
assert!(m.summary().starts_with("WebM (AV1 + Opus 192 kbps)"), "{}", m.summary());
let h = OutputModule::for_format(OutputFormat::Hevc);
let mut h = OutputModule::for_format(OutputFormat::Hevc);
assert_eq!(h.keyint(29.97), 60);
h.keyframe_interval = 1;
assert_eq!(h.keyint(29.97), 1);
h.keyframe_interval = 0;
assert_eq!(h.summary(), "HEVC (H.265) · Main · 10000 kbps · Level Auto · Key every auto");
// Older projects (no codec fields) load with the defaults.
let old: OutputModule = serde_json::from_str(r#"{"format":"H264","bitrate_kbps":5000}"#).unwrap();

Some files were not shown because too many files have changed in this diff Show More