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:
commit
a30cf38180
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
14
ROADMAP.md
14
ROADMAP.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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("/"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -19,4 +19,5 @@ effectcraft-track = { workspace = true }
|
|||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -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(¶ms, &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: ¶ms, 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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, ¶ms, ctx.time) {
|
||||
log::warn!("plug-in `{}`: {e}", m.id);
|
||||
buf.img.data = before;
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) + "]");
|
||||
})();
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).");
|
||||
})();
|
||||
|
|
@ -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.
|
||||
|
|
@ -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();
|
||||
})();
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.");
|
||||
})();
|
||||
|
|
@ -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.
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(¶ms);
|
||||
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(¶ms, 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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¶ms, 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, ¶ms, 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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¶ms)?;
|
||||
self.learn_observe(id, ¶ms);
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¤t);
|
||||
self.prefs_revision += 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -34,3 +34,4 @@ effectcraft-media = { workspace = true }
|
|||
effectcraft-color = { workspace = true }
|
||||
filmcraft-opus = { workspace = true }
|
||||
filmcraft-vp9 = { workspace = true }
|
||||
effectcraft-keyframe = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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![] })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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")));
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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;
|
||||
|
|
@ -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: ¶ms, 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"));
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue