Merge main into M13.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9d23944c2b
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1197,6 +1197,7 @@ dependencies = [
|
|||
"effectcraft-project",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-track",
|
||||
"log",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1237,6 +1238,7 @@ name = "effectcraft-export"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-media",
|
||||
"effectcraft-opusenc",
|
||||
"effectcraft-project",
|
||||
|
|
@ -1311,12 +1313,14 @@ dependencies = [
|
|||
"effectcraft-expr",
|
||||
"effectcraft-media",
|
||||
"effectcraft-pdf",
|
||||
"effectcraft-plugin",
|
||||
"effectcraft-project",
|
||||
"effectcraft-psd",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-script",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-time",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
|
|
@ -1410,6 +1414,18 @@ dependencies = [
|
|||
"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"
|
||||
|
|
@ -2114,6 +2130,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"
|
||||
|
|
@ -2288,7 +2310,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",
|
||||
|
|
@ -2428,6 +2450,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"
|
||||
|
|
@ -2436,7 +2467,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
|||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2447,7 +2478,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
|||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2765,6 +2796,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"
|
||||
|
|
@ -4444,6 +4481,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"
|
||||
|
|
@ -4471,6 +4514,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"
|
||||
|
|
@ -5139,6 +5192,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"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ effectcraft-ui-egui = { path = "crates/ui-egui" }
|
|||
effectcraft-automation = { path = "crates/automation" }
|
||||
effectcraft-opusenc = { path = "crates/opusenc" }
|
||||
effectcraft-script = { path = "crates/script" }
|
||||
effectcraft-plugin = { path = "crates/plugin" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
|
|
@ -53,6 +54,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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,61 +130,193 @@ 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|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.format.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")),
|
||||
};
|
||||
}
|
||||
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 {
|
||||
|
|
@ -174,15 +330,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();
|
||||
|
|
@ -194,6 +364,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)
|
||||
|
|
@ -227,7 +405,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() {
|
||||
|
|
@ -249,6 +427,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));
|
||||
|
|
@ -259,16 +441,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;
|
||||
|
|
@ -305,7 +496,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, _| {
|
||||
|
|
@ -321,12 +512,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(())
|
||||
})?;
|
||||
|
|
@ -338,7 +537,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;
|
||||
|
|
@ -452,7 +651,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);
|
||||
|
|
@ -469,11 +668,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;
|
||||
|
|
@ -506,6 +705,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)?;
|
||||
|
|
@ -513,6 +714,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!(
|
||||
|
|
@ -520,7 +902,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|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?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
|
||||
can_add,
|
||||
add
|
||||
),
|
||||
|
|
@ -531,7 +913,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
|
||||
),
|
||||
|
|
@ -540,7 +922,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Output Module Settings...",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, module?: n (1 = first), format?, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool, output?}",
|
||||
"{item?|index?, module?: n (1 = first), template?: name, format?, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames, proresProfile?: proxy|lt|standard|hq|4444|4444xq, 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
|
||||
),
|
||||
|
|
@ -569,6 +951,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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ 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;
|
||||
|
|
@ -26,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;
|
||||
|
|
@ -55,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)]
|
||||
|
|
@ -98,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 {
|
||||
|
|
@ -292,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.
|
||||
|
|
@ -337,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>,
|
||||
|
|
@ -370,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 {
|
||||
|
|
@ -386,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,
|
||||
|
|
@ -408,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,
|
||||
|
|
@ -489,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();
|
||||
|
|
@ -779,6 +785,8 @@ mod tests_effects;
|
|||
#[cfg(test)]
|
||||
mod tests_essential;
|
||||
#[cfg(test)]
|
||||
mod tests_face;
|
||||
#[cfg(test)]
|
||||
mod tests_fidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
|
|
@ -896,6 +904,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),
|
||||
}
|
||||
}
|
||||
|
|
@ -419,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) {
|
||||
|
|
@ -517,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
|
||||
|
|
@ -908,6 +925,7 @@ Effect
|
|||
Remove All | effect.removeAll
|
||||
---
|
||||
Manage Effects... | effect.manage
|
||||
Load Effect Plug-in... | effect.plugins.load
|
||||
---
|
||||
@effects
|
||||
Animation
|
||||
|
|
@ -1146,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"}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -32,3 +32,4 @@ effectcraft-media = { workspace = true }
|
|||
effectcraft-color = { workspace = true }
|
||||
filmcraft-opus = { workspace = true }
|
||||
filmcraft-vp9 = { workspace = true }
|
||||
effectcraft-keyframe = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -4,12 +4,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())
|
||||
|
|
@ -208,29 +208,60 @@ 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)?),
|
||||
_ => 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::H264 { Brand::Mp4 } else { Brand::Mov };
|
||||
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);
|
||||
|
|
@ -242,7 +273,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)?;
|
||||
|
|
@ -257,15 +289,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);
|
||||
|
|
@ -290,21 +329,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)?;
|
||||
}
|
||||
}
|
||||
|
|
@ -336,7 +372,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)]
|
||||
|
|
|
|||
|
|
@ -2,13 +2,21 @@
|
|||
//!
|
||||
//! | 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 |
|
||||
//! | 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 | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`), 48 kHz stereo |
|
||||
//! | 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
|
||||
|
|
@ -20,19 +28,21 @@
|
|||
|
||||
mod encode;
|
||||
mod out;
|
||||
mod pipeline;
|
||||
mod webm;
|
||||
|
||||
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 {
|
||||
|
|
@ -71,6 +81,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.
|
||||
|
|
@ -102,6 +127,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).
|
||||
|
|
@ -109,10 +138,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.
|
||||
|
|
@ -127,19 +155,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::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::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();
|
||||
|
|
@ -172,39 +211,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;
|
||||
}
|
||||
|
|
@ -221,10 +229,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;
|
||||
|
|
@ -233,13 +244,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();
|
||||
|
|
@ -249,11 +265,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 => {
|
||||
|
|
@ -264,6 +290,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 };
|
||||
|
|
@ -282,16 +312,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 {
|
||||
|
|
@ -305,11 +336,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);
|
||||
|
|
@ -324,8 +356,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();
|
||||
|
|
@ -344,7 +376,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};
|
||||
|
||||
const OPUS_RATE: u32 = 48_000;
|
||||
const OPUS_BITRATE: u32 = 192_000;
|
||||
|
|
@ -82,6 +85,10 @@ fn block(track: u64, rel: i16, flags: u8, data: &[u8]) -> 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>>,
|
||||
}
|
||||
|
|
@ -94,10 +101,15 @@ 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);
|
||||
|
|
@ -113,18 +125,33 @@ fn cluster(frames: &mut [Frame]) -> Vec<u8> {
|
|||
out
|
||||
}
|
||||
|
||||
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> {
|
||||
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 mut opus = with_audio.then(|| effectcraft_opusenc::OpusEncoder::new(2, OPUS_BITRATE));
|
||||
let opus_channels = if job.output.audio_channels == 1 { 1usize } else { 2 };
|
||||
let mut opus = with_audio.then(|| effectcraft_opusenc::OpusEncoder::new(opus_channels as _, OPUS_BITRATE));
|
||||
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![];
|
||||
|
|
@ -178,7 +205,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);
|
||||
}
|
||||
|
|
@ -197,10 +224,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;
|
||||
|
|
@ -209,7 +236,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;
|
||||
}
|
||||
};
|
||||
|
|
@ -218,23 +245,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);
|
||||
|
|
@ -260,7 +292,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
|
||||
|
|
@ -278,14 +310,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;
|
||||
|
|
@ -294,10 +337,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 {
|
||||
|
|
@ -308,13 +348,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");
|
||||
|
|
@ -331,24 +372,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)]
|
||||
|
|
|
|||
|
|
@ -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,6 +16,11 @@ 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 }
|
||||
|
|
@ -24,3 +29,4 @@ 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")));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -940,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.
|
||||
|
|
@ -287,8 +612,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.
|
||||
|
|
@ -301,6 +648,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 {
|
||||
|
|
@ -341,38 +803,133 @@ impl ProResProfile {
|
|||
#[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.
|
||||
/// H.264 target bitrate (and WebM's when `webm_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,
|
||||
/// 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,
|
||||
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() {
|
||||
|
|
@ -384,7 +941,7 @@ impl OutputModule {
|
|||
pub fn set_format(&mut self, format: OutputFormat) {
|
||||
let old = self.format;
|
||||
self.format = format;
|
||||
if !format.supports_alpha() {
|
||||
if !format.supports_alpha() && self.channels == Channels::Rgba {
|
||||
self.channels = Channels::Rgb;
|
||||
}
|
||||
if self.output.contains("[fileExtension]") {
|
||||
|
|
@ -406,10 +963,10 @@ impl OutputModule {
|
|||
}
|
||||
/// 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!(
|
||||
|
|
@ -478,6 +1035,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).
|
||||
|
|
@ -521,6 +1157,7 @@ impl RenderQueueItem {
|
|||
last_output: None,
|
||||
extra_outputs: vec![],
|
||||
post_render: PostRenderAction::None,
|
||||
log: RenderLog::ErrorsOnly,
|
||||
}
|
||||
}
|
||||
/// Every output module, the first one first.
|
||||
|
|
@ -612,6 +1249,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));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
//! Render Settings and Output Module templates (Edit ▸ Templates ▸ Render Settings… / Output
|
||||
//! Module…): the built-in presets, templates saved with the project, and the defaults new
|
||||
//! Render Queue items, pre-renders and proxies start from.
|
||||
//!
|
||||
//! Built-ins always exist; a saved template with a built-in's name overrides it. Templates
|
||||
//! carry their name in [`RenderSettings::name`] / [`OutputModule::name`], which the Render Queue
|
||||
//! shows like After Effects does.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::render_queue::*;
|
||||
use effectcraft_time::FrameRate;
|
||||
|
||||
/// A default slot (the Defaults section of the Templates dialogs).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum TemplateSlot {
|
||||
/// New Render Queue items rendering a movie.
|
||||
Movie,
|
||||
/// Single-frame renders (Composition ▸ Save Frame As ▸ File…).
|
||||
Still,
|
||||
/// Composition ▸ Pre-render….
|
||||
PreRender,
|
||||
/// File ▸ Create Proxy ▸ Movie….
|
||||
MovieProxy,
|
||||
/// File ▸ Create Proxy ▸ Still….
|
||||
StillProxy,
|
||||
}
|
||||
|
||||
impl TemplateSlot {
|
||||
pub const ALL: [TemplateSlot; 5] = [TemplateSlot::Movie, TemplateSlot::Still, TemplateSlot::PreRender, TemplateSlot::MovieProxy, TemplateSlot::StillProxy];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TemplateSlot::Movie => "Movie Default",
|
||||
TemplateSlot::Still => "Frame Default",
|
||||
TemplateSlot::PreRender => "Pre-Render Default",
|
||||
TemplateSlot::MovieProxy => "Movie Proxy Default",
|
||||
TemplateSlot::StillProxy => "Still Proxy Default",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<TemplateSlot> {
|
||||
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"movie" | "moviedefault" => Some(TemplateSlot::Movie),
|
||||
"still" | "frame" | "framedefault" | "stilldefault" => Some(TemplateSlot::Still),
|
||||
"prerender" | "prerenderdefault" => Some(TemplateSlot::PreRender),
|
||||
"movieproxy" | "movieproxydefault" => Some(TemplateSlot::MovieProxy),
|
||||
"stillproxy" | "stillproxydefault" => Some(TemplateSlot::StillProxy),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which template list.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TemplateKind {
|
||||
RenderSettings,
|
||||
OutputModule,
|
||||
}
|
||||
|
||||
impl TemplateKind {
|
||||
pub fn parse(s: &str) -> Option<TemplateKind> {
|
||||
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"rendersettings" | "settings" | "render" => Some(TemplateKind::RenderSettings),
|
||||
"outputmodule" | "output" | "module" => Some(TemplateKind::OutputModule),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Defaults per slot (template names).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TemplateDefaults {
|
||||
pub render_settings: Vec<(TemplateSlot, String)>,
|
||||
pub output_modules: Vec<(TemplateSlot, String)>,
|
||||
}
|
||||
|
||||
impl Default for TemplateDefaults {
|
||||
fn default() -> Self {
|
||||
use TemplateSlot::*;
|
||||
let s = |v: &[(TemplateSlot, &str)]| v.iter().map(|(k, n)| (*k, n.to_string())).collect();
|
||||
TemplateDefaults {
|
||||
render_settings: s(&[
|
||||
(Movie, "Best Settings"),
|
||||
(Still, "Current Settings"),
|
||||
(PreRender, "Best Settings"),
|
||||
(MovieProxy, "Draft Settings"),
|
||||
(StillProxy, "Best Settings"),
|
||||
]),
|
||||
output_modules: s(&[
|
||||
(Movie, "H.264 - Match Render Settings - 15 Mbps"),
|
||||
(Still, "PNG Sequence"),
|
||||
(PreRender, "Lossless with Alpha"),
|
||||
(MovieProxy, "Lossless with Alpha"),
|
||||
(StillProxy, "PNG Sequence with Alpha"),
|
||||
]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Templates saved with the project, plus the defaults.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RenderTemplates {
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub render_settings: Vec<RenderSettings>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub output_modules: Vec<OutputModule>,
|
||||
pub defaults: TemplateDefaults,
|
||||
}
|
||||
|
||||
/// The built-in Render Settings templates.
|
||||
pub fn builtin_render_settings() -> Vec<RenderSettings> {
|
||||
let best = RenderSettings {
|
||||
name: "Best Settings".into(),
|
||||
frame_blending: SwitchOverride::OnForChecked,
|
||||
motion_blur_mode: SwitchOverride::OnForChecked,
|
||||
..RenderSettings::default()
|
||||
};
|
||||
let draft = RenderSettings {
|
||||
name: "Draft Settings".into(),
|
||||
quality: RenderQuality::Draft,
|
||||
resolution: 0.5,
|
||||
proxy_use: ProxyUse::CurrentSettings,
|
||||
frame_blending: SwitchOverride::Current,
|
||||
motion_blur_mode: SwitchOverride::Current,
|
||||
..RenderSettings::default()
|
||||
};
|
||||
let dv =
|
||||
RenderSettings { name: "DV Settings".into(), field_render: FieldRender::LowerFirst, frame_rate: Some(FrameRate::new(30000, 1001)), ..best.clone() };
|
||||
let multi = RenderSettings { name: "Multi-Machine Settings".into(), skip_existing: true, ..best.clone() };
|
||||
let current = RenderSettings {
|
||||
name: "Current Settings".into(),
|
||||
proxy_use: ProxyUse::CurrentSettings,
|
||||
guide_layers: CurrentOrOff::Current,
|
||||
..RenderSettings::default()
|
||||
};
|
||||
vec![best, draft, dv, multi, current]
|
||||
}
|
||||
|
||||
fn om(name: &str, format: OutputFormat) -> OutputModule {
|
||||
let mut m = OutputModule::for_format(format);
|
||||
m.name = name.into();
|
||||
m
|
||||
}
|
||||
|
||||
/// The built-in Output Module templates.
|
||||
pub fn builtin_output_modules() -> Vec<OutputModule> {
|
||||
let mut v = vec![];
|
||||
let mut hq = om("High Quality", OutputFormat::ProRes);
|
||||
hq.prores_profile = ProResProfile::Hq;
|
||||
v.push(hq);
|
||||
let mut hqa = om("High Quality with Alpha", OutputFormat::ProRes);
|
||||
hqa.prores_profile = ProResProfile::P4444;
|
||||
hqa.channels = Channels::Rgba;
|
||||
hqa.alpha_mode = AlphaMode::Premultiplied;
|
||||
v.push(hqa);
|
||||
// Lossless: the highest-fidelity movie codec this build writes (ProRes 4444 XQ).
|
||||
let mut ll = om("Lossless", OutputFormat::ProRes);
|
||||
ll.prores_profile = ProResProfile::P4444Xq;
|
||||
v.push(ll);
|
||||
let mut lla = om("Lossless with Alpha", OutputFormat::ProRes);
|
||||
lla.prores_profile = ProResProfile::P4444Xq;
|
||||
lla.channels = Channels::Rgba;
|
||||
v.push(lla);
|
||||
for mbps in [5u32, 15, 40] {
|
||||
let mut h = om(&format!("H.264 - Match Render Settings - {mbps} Mbps"), OutputFormat::H264);
|
||||
h.bitrate_kbps = mbps * 1000;
|
||||
v.push(h);
|
||||
}
|
||||
let mut alpha = om("Alpha Only", OutputFormat::ProRes);
|
||||
alpha.channels = Channels::Alpha;
|
||||
v.push(alpha);
|
||||
v.push(om("AIFF 48kHz", OutputFormat::Aiff));
|
||||
v.push(om("WAV 48kHz", OutputFormat::Wav));
|
||||
v.push(om("PNG Sequence", OutputFormat::PngSequence));
|
||||
let mut pa = om("PNG Sequence with Alpha", OutputFormat::PngSequence);
|
||||
pa.channels = Channels::Rgba;
|
||||
v.push(pa);
|
||||
let mut ta = om("TIFF Sequence with Alpha", OutputFormat::TiffSequence);
|
||||
ta.channels = Channels::Rgba;
|
||||
v.push(ta);
|
||||
v.push(om("JPEG Sequence", OutputFormat::JpegSequence));
|
||||
let mut exr = om("OpenEXR Sequence with Alpha", OutputFormat::ExrSequence);
|
||||
exr.channels = Channels::Rgba;
|
||||
v.push(exr);
|
||||
v.push(om("Animated GIF", OutputFormat::Gif));
|
||||
v.push(om("WebM (VP9 + Opus)", OutputFormat::WebM));
|
||||
let mut wa = om("WebM with Alpha", OutputFormat::WebM);
|
||||
wa.channels = Channels::Rgba;
|
||||
v.push(wa);
|
||||
v
|
||||
}
|
||||
|
||||
fn same(a: &str, b: &str) -> bool {
|
||||
a.trim().eq_ignore_ascii_case(b.trim())
|
||||
}
|
||||
|
||||
impl RenderTemplates {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == RenderTemplates::default()
|
||||
}
|
||||
/// Every Render Settings template: built-ins (unless overridden), then saved ones.
|
||||
pub fn render_settings_list(&self) -> Vec<RenderSettings> {
|
||||
let mut v: Vec<RenderSettings> =
|
||||
builtin_render_settings().into_iter().filter(|b| !self.render_settings.iter().any(|u| same(&u.name, &b.name))).collect();
|
||||
v.extend(self.render_settings.iter().cloned());
|
||||
v
|
||||
}
|
||||
pub fn output_module_list(&self) -> Vec<OutputModule> {
|
||||
let mut v: Vec<OutputModule> = builtin_output_modules().into_iter().filter(|b| !self.output_modules.iter().any(|u| same(&u.name, &b.name))).collect();
|
||||
v.extend(self.output_modules.iter().cloned());
|
||||
v
|
||||
}
|
||||
pub fn render_settings(&self, name: &str) -> Option<RenderSettings> {
|
||||
self.render_settings_list().into_iter().find(|t| same(&t.name, name))
|
||||
}
|
||||
pub fn output_module(&self, name: &str) -> Option<OutputModule> {
|
||||
self.output_module_list().into_iter().find(|t| same(&t.name, name))
|
||||
}
|
||||
pub fn is_builtin(kind: TemplateKind, name: &str) -> bool {
|
||||
match kind {
|
||||
TemplateKind::RenderSettings => builtin_render_settings().iter().any(|t| same(&t.name, name)),
|
||||
TemplateKind::OutputModule => builtin_output_modules().iter().any(|t| same(&t.name, name)),
|
||||
}
|
||||
}
|
||||
/// Save (or replace) a template under `name`.
|
||||
pub fn save_render_settings(&mut self, name: &str, mut s: RenderSettings) {
|
||||
s.name = name.trim().to_string();
|
||||
self.render_settings.retain(|t| !same(&t.name, name));
|
||||
self.render_settings.push(s);
|
||||
}
|
||||
pub fn save_output_module(&mut self, name: &str, mut m: OutputModule) {
|
||||
m.name = name.trim().to_string();
|
||||
self.output_modules.retain(|t| !same(&t.name, name));
|
||||
self.output_modules.push(m);
|
||||
}
|
||||
/// Delete a saved template (a built-in it overrode comes back). Built-ins themselves can't
|
||||
/// be deleted.
|
||||
pub fn delete(&mut self, kind: TemplateKind, name: &str) -> Result<(), String> {
|
||||
let before = (self.render_settings.len(), self.output_modules.len());
|
||||
match kind {
|
||||
TemplateKind::RenderSettings => self.render_settings.retain(|t| !same(&t.name, name)),
|
||||
TemplateKind::OutputModule => self.output_modules.retain(|t| !same(&t.name, name)),
|
||||
}
|
||||
if before != (self.render_settings.len(), self.output_modules.len()) {
|
||||
return Ok(());
|
||||
}
|
||||
if Self::is_builtin(kind, name) { Err(format!("“{name}” is a built-in template")) } else { Err(format!("no template “{name}”")) }
|
||||
}
|
||||
/// The default template name of a slot.
|
||||
pub fn default_name(&self, kind: TemplateKind, slot: TemplateSlot) -> String {
|
||||
let list = match kind {
|
||||
TemplateKind::RenderSettings => &self.defaults.render_settings,
|
||||
TemplateKind::OutputModule => &self.defaults.output_modules,
|
||||
};
|
||||
list.iter().find(|(s, _)| *s == slot).map(|(_, n)| n.clone()).unwrap_or_else(|| {
|
||||
let d = TemplateDefaults::default();
|
||||
let l = if kind == TemplateKind::RenderSettings { d.render_settings } else { d.output_modules };
|
||||
l.into_iter().find(|(s, _)| *s == slot).map(|(_, n)| n).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
pub fn set_default(&mut self, kind: TemplateKind, slot: TemplateSlot, name: &str) -> Result<(), String> {
|
||||
let exists = match kind {
|
||||
TemplateKind::RenderSettings => self.render_settings(name).is_some(),
|
||||
TemplateKind::OutputModule => self.output_module(name).is_some(),
|
||||
};
|
||||
if !exists {
|
||||
return Err(format!("no template “{name}”"));
|
||||
}
|
||||
let list = match kind {
|
||||
TemplateKind::RenderSettings => &mut self.defaults.render_settings,
|
||||
TemplateKind::OutputModule => &mut self.defaults.output_modules,
|
||||
};
|
||||
list.retain(|(s, _)| *s != slot);
|
||||
list.push((slot, name.trim().to_string()));
|
||||
Ok(())
|
||||
}
|
||||
/// The Render Settings a slot starts from (a missing template falls back to the built-in
|
||||
/// default).
|
||||
pub fn default_render_settings(&self, slot: TemplateSlot) -> RenderSettings {
|
||||
self.render_settings(&self.default_name(TemplateKind::RenderSettings, slot)).unwrap_or_else(|| builtin_render_settings().remove(0))
|
||||
}
|
||||
pub fn default_output_module(&self, slot: TemplateSlot) -> OutputModule {
|
||||
self.output_module(&self.default_name(TemplateKind::OutputModule, slot)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builtins_defaults_and_round_trip() {
|
||||
let mut t = RenderTemplates::default();
|
||||
let names: Vec<String> = t.render_settings_list().into_iter().map(|s| s.name).collect();
|
||||
assert_eq!(names, ["Best Settings", "Draft Settings", "DV Settings", "Multi-Machine Settings", "Current Settings"]);
|
||||
assert_eq!(t.default_render_settings(TemplateSlot::Movie).name, "Best Settings");
|
||||
assert_eq!(t.default_output_module(TemplateSlot::PreRender).channels, Channels::Rgba);
|
||||
let dv = t.render_settings("dv settings").unwrap();
|
||||
assert_eq!(dv.field_render, FieldRender::LowerFirst);
|
||||
// Save a custom template, make it the movie default, round-trip through JSON.
|
||||
let mut s = RenderSettings { resolution: 0.25, ..Default::default() };
|
||||
s.color_depth = ColorDepth::Bpc16;
|
||||
t.save_render_settings("Quarter 16", s);
|
||||
t.set_default(TemplateKind::RenderSettings, TemplateSlot::Movie, "Quarter 16").unwrap();
|
||||
let mut m = OutputModule::for_format(OutputFormat::WebM);
|
||||
m.crop = Crop { enabled: true, top: 4, left: 2, ..Default::default() };
|
||||
t.save_output_module("Web Crop", m);
|
||||
let json = serde_json::to_string(&t).unwrap();
|
||||
let back: RenderTemplates = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, t);
|
||||
assert_eq!(back.default_render_settings(TemplateSlot::Movie).color_depth, ColorDepth::Bpc16);
|
||||
assert_eq!(back.output_module("web crop").unwrap().crop.top, 4);
|
||||
assert!(t.delete(TemplateKind::RenderSettings, "Best Settings").is_err());
|
||||
t.delete(TemplateKind::OutputModule, "Web Crop").unwrap();
|
||||
assert!(t.output_module("Web Crop").is_none());
|
||||
// Overriding a built-in, then deleting the override, restores it.
|
||||
t.save_output_module("High Quality", OutputModule::for_format(OutputFormat::Gif));
|
||||
assert_eq!(t.output_module("High Quality").unwrap().format, OutputFormat::Gif);
|
||||
t.delete(TemplateKind::OutputModule, "High Quality").unwrap();
|
||||
assert_eq!(t.output_module("High Quality").unwrap().format, OutputFormat::ProRes);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,14 @@
|
|||
//! * **Security**: scripts can import/open/save projects and render, but `File` reads are limited
|
||||
//! to the project's folder and writes (and the network) are off unless Preferences ▸ Scripting
|
||||
//! & Expressions ▸ Allow Scripts to Write Files and Access Network is on.
|
||||
//! * **ScriptUI** (`scriptui.js`, `ui.rs`): `Window` (dialog / palette / window), `Panel`,
|
||||
//! `Group`, `Button`, `StaticText`, `EditText`, `Checkbox`, `RadioButton`, `Slider`,
|
||||
//! `Progressbar`, `DropDownList`, `ListBox`, `TabbedPanel`/`Tab`, with `add()`, `orientation`,
|
||||
//! `alignChildren`, `alignment`, `margins`, `spacing`, `preferredSize`, `onClick` /
|
||||
//! `onChange` / `onChanging` / `onClose`, `show()` / `close()` and `layout.layout()`. Windows
|
||||
//! are published to [`Session::script_ui`](effectcraft_engine::Session::script_ui) for
|
||||
//! frontends and agents (`scriptui.*` commands); modal dialogs block `show()` until they close.
|
||||
//! Scripts in the ScriptUI Panels folder run with `this` = a dockable `Panel`.
|
||||
//!
|
||||
//! Entry points: [`install`] sets [`Session::script`](effectcraft_engine::Session::script), which
|
||||
//! the `script.run` command, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`), the Script Console
|
||||
|
|
@ -40,19 +48,22 @@
|
|||
pub mod matchnames;
|
||||
mod model;
|
||||
mod runtime;
|
||||
mod ui;
|
||||
|
||||
use effectcraft_engine::{ScriptRequest, Session};
|
||||
|
||||
pub use runtime::{Outcome, ScriptError, run};
|
||||
pub use ui::dispatch_ui;
|
||||
|
||||
/// The [`effectcraft_engine::ScriptRunner`] this crate provides.
|
||||
pub fn runner(s: &mut Session, req: &ScriptRequest) -> serde_json::Value {
|
||||
run(s, req).to_json()
|
||||
}
|
||||
|
||||
/// Enable scripting on a session.
|
||||
/// Enable scripting (and ScriptUI) on a session.
|
||||
pub fn install(s: &mut Session) {
|
||||
s.script = Some(runner);
|
||||
s.script_ui.dispatch = Some(dispatch_ui);
|
||||
}
|
||||
|
||||
/// Run `code` as a one-off script named `name`.
|
||||
|
|
@ -62,3 +73,5 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_ui;
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ pub fn query(s: &mut Session, kind: &str, a: &J) -> R {
|
|||
}
|
||||
"labels" => Ok(json!(Label::ALL.iter().map(|l| l.name()).collect::<Vec<_>>())),
|
||||
"effects" => Ok(json!(
|
||||
effectcraft_engine::effects::registry()
|
||||
.iter()
|
||||
effectcraft_engine::effects::all()
|
||||
.into_iter()
|
||||
.map(|e| json!({"id": e.id, "name": e.name, "matchName": matchnames::effect(e.id), "category": e.category}))
|
||||
.collect::<Vec<_>>()
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ pub struct Outcome {
|
|||
/// `writeLn` / `$.writeln` / `alert` output, one entry per line.
|
||||
pub output: Vec<String>,
|
||||
pub error: Option<ScriptError>,
|
||||
/// The script is waiting in a modal ScriptUI dialog's `show()` (it goes on when the dialog
|
||||
/// closes; see [`crate::dispatch_ui`]).
|
||||
pub waiting: bool,
|
||||
}
|
||||
|
||||
/// An uncaught script error.
|
||||
|
|
@ -42,36 +45,49 @@ impl Outcome {
|
|||
self.error.is_none()
|
||||
}
|
||||
pub fn to_json(&self) -> J {
|
||||
json!({
|
||||
let mut j = json!({
|
||||
"ok": self.ok(),
|
||||
"result": self.result,
|
||||
"output": self.output.join("\n"),
|
||||
"error": self.error.as_ref().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})),
|
||||
})
|
||||
});
|
||||
if self.waiting {
|
||||
j["waiting"] = json!(true);
|
||||
}
|
||||
j
|
||||
}
|
||||
}
|
||||
|
||||
/// The session and per-run state while a script runs (the natives reach it here; nothing
|
||||
/// borrowed ever enters the JS heap).
|
||||
struct Active {
|
||||
session: Session,
|
||||
output: Vec<String>,
|
||||
pub(crate) struct Active {
|
||||
pub(crate) session: Session,
|
||||
pub(crate) output: Vec<String>,
|
||||
/// Open undo groups: (name, project snapshot when the outermost group began).
|
||||
groups: Vec<(String, Arc<Project>)>,
|
||||
pub(crate) groups: Vec<(String, Arc<Project>)>,
|
||||
/// `app.scheduleTask` queue: (id, code, repeat).
|
||||
tasks: Vec<(u32, String, bool)>,
|
||||
next_task: u32,
|
||||
name: String,
|
||||
pub(crate) tasks: Vec<(u32, String, bool)>,
|
||||
pub(crate) next_task: u32,
|
||||
pub(crate) name: String,
|
||||
/// Running on a script-host thread: modal dialogs hand the session back to the caller
|
||||
/// through this link while they wait.
|
||||
pub(crate) link: Option<crate::ui::Link>,
|
||||
}
|
||||
|
||||
impl Active {
|
||||
pub(crate) fn new(session: Session, name: &str) -> Active {
|
||||
Active { session, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: name.to_string(), link: None }
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ACTIVE: RefCell<Option<Active>> = const { RefCell::new(None) };
|
||||
pub(crate) static ACTIVE: RefCell<Option<Active>> = const { RefCell::new(None) };
|
||||
/// The Script Console's persistent context (never dropped, like the expression runtime: boa's
|
||||
/// own thread-local heap may be torn down first at thread exit).
|
||||
static CONSOLE: RefCell<Option<ManuallyDrop<Context>>> = const { RefCell::new(None) };
|
||||
pub(crate) static CONSOLE: RefCell<Option<ManuallyDrop<Context>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn with_active<T>(f: impl FnOnce(&mut Active) -> Result<T, String>) -> Result<T, String> {
|
||||
pub(crate) fn with_active<T>(f: impl FnOnce(&mut Active) -> Result<T, String>) -> Result<T, String> {
|
||||
ACTIVE.with(|a| match a.try_borrow_mut() {
|
||||
Ok(mut g) => match g.as_mut() {
|
||||
Some(act) => f(act),
|
||||
|
|
@ -81,11 +97,11 @@ fn with_active<T>(f: impl FnOnce(&mut Active) -> Result<T, String>) -> Result<T,
|
|||
})
|
||||
}
|
||||
|
||||
fn throw(msg: impl Into<String>) -> boa_engine::JsError {
|
||||
pub(crate) fn throw(msg: impl Into<String>) -> boa_engine::JsError {
|
||||
JsNativeError::error().with_message(msg.into()).into()
|
||||
}
|
||||
|
||||
fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<String> {
|
||||
pub(crate) fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<String> {
|
||||
Ok(args.get(i).cloned().unwrap_or_default().to_string(ctx)?.to_std_string_escaped())
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +113,7 @@ fn arg_json(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<J> {
|
|||
serde_json::from_str(&s).map_err(|e| throw(format!("bad parameters: {e}")))
|
||||
}
|
||||
|
||||
fn js_str(s: &str) -> JsValue {
|
||||
pub(crate) fn js_str(s: &str) -> JsValue {
|
||||
JsValue::from(JsString::from(s))
|
||||
}
|
||||
|
||||
|
|
@ -205,7 +221,7 @@ fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
|||
}
|
||||
|
||||
/// Fold the undo steps recorded since the project was `snap` into one step called `name`.
|
||||
fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>) {
|
||||
pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>) {
|
||||
let h = &mut s.history;
|
||||
let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else {
|
||||
// Nothing was recorded (or history was reset by New / Open Project).
|
||||
|
|
@ -354,28 +370,32 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
|||
}
|
||||
}
|
||||
|
||||
fn new_context() -> Result<Context, String> {
|
||||
pub(crate) fn new_context() -> Result<Context, String> {
|
||||
let mut ctx = Context::default();
|
||||
let limits = ctx.runtime_limits_mut();
|
||||
limits.set_loop_iteration_limit(200_000_000);
|
||||
limits.set_recursion_limit(2000);
|
||||
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 6] = [
|
||||
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 9] = [
|
||||
(js_string!("__exec"), 2, native_exec),
|
||||
(js_string!("__query"), 2, native_query),
|
||||
(js_string!("__print"), 1, native_print),
|
||||
(js_string!("__undo"), 2, native_undo),
|
||||
(js_string!("__task"), 3, native_task),
|
||||
(js_string!("__file"), 3, native_file),
|
||||
(js_string!("__uiNewId"), 0, crate::ui::native_new_id),
|
||||
(js_string!("__uiModal"), 1, crate::ui::native_modal),
|
||||
(js_string!("__uiLayoutNative"), 1, crate::ui::native_layout),
|
||||
];
|
||||
for (name, len, f) in natives {
|
||||
ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
ctx.eval(Source::from_bytes(PRELUDE)).map_err(|e| format!("script prelude: {e}"))?;
|
||||
ctx.eval(Source::from_bytes(crate::ui::SCRIPTUI)).map_err(|e| format!("ScriptUI prelude: {e}"))?;
|
||||
Ok(ctx)
|
||||
}
|
||||
|
||||
/// Turn a boa error into a message with line and column (`code` is the user's script).
|
||||
fn script_error(e: &boa_engine::JsError, ctx: &mut Context, file: &str, code: &str) -> ScriptError {
|
||||
pub(crate) fn script_error(e: &boa_engine::JsError, ctx: &mut Context, file: &str, code: &str) -> ScriptError {
|
||||
// Thrown values: `throw new Error("x")`, `throw "x"`, native errors.
|
||||
let display = e.to_string();
|
||||
let message = match e.as_opaque() {
|
||||
|
|
@ -447,7 +467,7 @@ fn position(msg: &str, file: &str, code: &str) -> (Option<u32>, Option<u32>) {
|
|||
}
|
||||
|
||||
/// Evaluate `code` in `ctx` and convert the result.
|
||||
fn eval_in(ctx: &mut Context, code: &str, file: &str) -> Result<J, ScriptError> {
|
||||
pub(crate) fn eval_in(ctx: &mut Context, code: &str, file: &str) -> Result<J, ScriptError> {
|
||||
let src = Source::from_bytes(code.as_bytes()).with_path(std::path::Path::new(file));
|
||||
let script = boa_engine::Script::parse(src, None, ctx).map_err(|e| script_error(&e, ctx, file, code))?;
|
||||
let v = script.evaluate(ctx).map_err(|e| script_error(&e, ctx, file, code))?;
|
||||
|
|
@ -471,8 +491,8 @@ fn result_json(v: &JsValue, ctx: &mut Context) -> J {
|
|||
}
|
||||
|
||||
/// Restores the caller's session when a run ends (on every exit path).
|
||||
struct Restore<'a> {
|
||||
target: &'a mut Session,
|
||||
pub(crate) struct Restore<'a> {
|
||||
pub(crate) target: &'a mut Session,
|
||||
}
|
||||
|
||||
impl Drop for Restore<'_> {
|
||||
|
|
@ -483,8 +503,54 @@ impl Drop for Restore<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// End of a script step: close undo groups left open, drop selections that no longer exist,
|
||||
/// and take the output so far.
|
||||
pub(crate) fn settle() -> Vec<String> {
|
||||
with_active(|a| {
|
||||
if let Some((name, snap)) = a.groups.first().cloned() {
|
||||
collapse(&mut a.session, &name, &snap);
|
||||
a.groups.clear();
|
||||
}
|
||||
a.session.sanitize_state();
|
||||
Ok(std::mem::take(&mut a.output))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Evaluate a script file's code, then the one-shot tasks it scheduled (`app.scheduleTask`).
|
||||
pub(crate) fn eval_with_tasks(ctx: &mut Context, code: &str, file: &str) -> Result<J, ScriptError> {
|
||||
let mut r = eval_in(ctx, code, file);
|
||||
let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default();
|
||||
for (_, code, repeat) in tasks {
|
||||
if repeat {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = eval_in(ctx, &code, "scheduled task")
|
||||
&& r.is_ok()
|
||||
{
|
||||
r = Err(e);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
pub(crate) fn outcome(output: Vec<String>, result: Result<J, ScriptError>) -> Outcome {
|
||||
let mut out = Outcome { output, ..Default::default() };
|
||||
match result {
|
||||
Ok(v) => out.result = v,
|
||||
Err(e) => out.error = Some(e),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run a script against `session`: the object model sees and edits that session through engine
|
||||
/// commands. Script errors come back in [`Outcome::error`].
|
||||
///
|
||||
/// On native builds a script file runs on its own script-host thread, so a ScriptUI dialog's
|
||||
/// `show()` can wait for the user (the session comes back here meanwhile, and the dialog's
|
||||
/// events reach the host through [`crate::dispatch_ui`]); palettes and panels keep their host
|
||||
/// alive for their handlers. The Script Console (and the web build) run inline, where dialogs
|
||||
/// don't block.
|
||||
pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
|
||||
let running = ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true));
|
||||
if running {
|
||||
|
|
@ -493,59 +559,49 @@ pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
|
|||
..Default::default()
|
||||
};
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !req.console {
|
||||
return crate::ui::run_threaded(session, req);
|
||||
}
|
||||
run_inline(session, req)
|
||||
}
|
||||
|
||||
/// Run on this thread (the Script Console's persistent context, or a fresh one).
|
||||
pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome {
|
||||
let host = if req.console { crate::ui::CONSOLE_HOST } else { crate::ui::new_host_id() };
|
||||
let s = std::mem::take(session);
|
||||
ACTIVE.with(|a| *a.borrow_mut() = Some(Active { session: s, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: req.name.to_string() }));
|
||||
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, req.name)));
|
||||
let guard = Restore { target: session };
|
||||
let file = if req.name.is_empty() { "script" } else { req.name };
|
||||
let mut result = if req.console {
|
||||
let (result, windows) = if req.console {
|
||||
CONSOLE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.is_none() {
|
||||
match new_context() {
|
||||
Ok(ctx) => *c = Some(ManuallyDrop::new(ctx)),
|
||||
Err(e) => return Err(ScriptError { message: e, file: file.into(), ..Default::default() }),
|
||||
Err(e) => return (Err(ScriptError { message: e, file: file.into(), ..Default::default() }), vec![]),
|
||||
}
|
||||
}
|
||||
let ctx = c.as_mut().map(|m| &mut **m).expect("console context");
|
||||
eval_in(ctx, req.code, file)
|
||||
let r = eval_in(ctx, req.code, file);
|
||||
(r, crate::ui::snapshot(ctx))
|
||||
})
|
||||
} else {
|
||||
match new_context() {
|
||||
Ok(mut ctx) => {
|
||||
let r = eval_in(&mut ctx, req.code, file);
|
||||
// app.scheduleTask: run the queued one-shot tasks after the script, in order.
|
||||
let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default();
|
||||
let mut r = r;
|
||||
for (_, code, repeat) in tasks {
|
||||
if repeat {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = eval_in(&mut ctx, &code, "scheduled task")
|
||||
&& r.is_ok()
|
||||
{
|
||||
r = Err(e);
|
||||
}
|
||||
let r = eval_with_tasks(&mut ctx, req.code, file);
|
||||
let windows = crate::ui::snapshot(&mut ctx);
|
||||
if windows.iter().any(|w| w.visible) {
|
||||
// Palettes and panels outlive the run: keep their context for the handlers.
|
||||
crate::ui::keep_inline(host, ctx);
|
||||
}
|
||||
r
|
||||
(r, windows)
|
||||
}
|
||||
Err(e) => Err(ScriptError { message: e, file: file.into(), ..Default::default() }),
|
||||
Err(e) => (Err(ScriptError { message: e, file: file.into(), ..Default::default() }), vec![]),
|
||||
}
|
||||
};
|
||||
// Close undo groups left open; drop selections that no longer exist.
|
||||
let output = with_active(|a| {
|
||||
if let Some((name, snap)) = a.groups.first().cloned() {
|
||||
collapse(&mut a.session, &name, &snap);
|
||||
a.groups.clear();
|
||||
}
|
||||
a.session.sanitize_state();
|
||||
Ok(std::mem::take(&mut a.output))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let output = settle();
|
||||
drop(guard);
|
||||
let mut out = Outcome { output, ..Default::default() };
|
||||
match result.as_mut() {
|
||||
Ok(v) => out.result = std::mem::take(v),
|
||||
Err(e) => out.error = Some(e.clone()),
|
||||
}
|
||||
out
|
||||
crate::ui::publish(session, host, req.name, windows);
|
||||
outcome(output, result)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,544 @@
|
|||
// EffectCraft scripting: ScriptUI, the dialog / panel object model After Effects scripts use
|
||||
// (`new Window("dialog", "Title")`, `win.add("button", undefined, "OK")`, `onClick`, `show()`…).
|
||||
//
|
||||
// Written from the behaviour Adobe's public JavaScript Tools Guide documents. Controls are plain
|
||||
// JavaScript objects; after each script step the host reads `__uiSnapshot()` (a JSON
|
||||
// description of the open windows) and lays them out, frontends draw them, and user actions come
|
||||
// back through `__uiDispatch(window, control, kind, valueJson)`. Modal dialogs block in
|
||||
// `show()` (`__uiModal`) until they close, as in After Effects.
|
||||
|
||||
var __uiWins = [];
|
||||
// show() results of closed dialogs, by window id.
|
||||
var __uiResults = {};
|
||||
|
||||
function __uiModalResult(id) {
|
||||
var r = __uiResults[id];
|
||||
if (r !== undefined) {
|
||||
delete __uiResults[id];
|
||||
return r;
|
||||
}
|
||||
return __uiWin(id) ? null : 2;
|
||||
}
|
||||
|
||||
function __dim(w, h) {
|
||||
var d = [w, h];
|
||||
Object.defineProperty(d, "width", { get: function () { return this[0]; }, set: function (v) { this[0] = v; } });
|
||||
Object.defineProperty(d, "height", { get: function () { return this[1]; }, set: function (v) { this[1] = v; } });
|
||||
return d;
|
||||
}
|
||||
function __bounds4(l, t, r, b) {
|
||||
var a = [l, t, r, b];
|
||||
Object.defineProperty(a, "left", { get: function () { return this[0]; } });
|
||||
Object.defineProperty(a, "top", { get: function () { return this[1]; } });
|
||||
Object.defineProperty(a, "right", { get: function () { return this[2]; } });
|
||||
Object.defineProperty(a, "bottom", { get: function () { return this[3]; } });
|
||||
Object.defineProperty(a, "x", { get: function () { return this[0]; } });
|
||||
Object.defineProperty(a, "y", { get: function () { return this[1]; } });
|
||||
Object.defineProperty(a, "width", { get: function () { return this[2] - this[0]; } });
|
||||
Object.defineProperty(a, "height", { get: function () { return this[3] - this[1]; } });
|
||||
return a;
|
||||
}
|
||||
function __toBounds(b) {
|
||||
if (b === undefined || b === null) return null;
|
||||
if (b.length === 4) return [__num(b[0]), __num(b[1]), __num(b[2]), __num(b[3])];
|
||||
if (b.width !== undefined) {
|
||||
var x = b.x || b.left || 0, y = b.y || b.top || 0;
|
||||
return [x, y, x + __num(b.width), y + __num(b.height)];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var __uiTypes = {
|
||||
window: "Window", panel: "Panel", group: "Group", button: "Button", iconbutton: "IconButton",
|
||||
statictext: "StaticText", edittext: "EditText", checkbox: "Checkbox", radiobutton: "RadioButton",
|
||||
slider: "Slider", scrollbar: "Scrollbar", progressbar: "Progressbar", dropdownlist: "DropDownList",
|
||||
listbox: "ListBox", tabbedpanel: "TabbedPanel", tab: "Tab", image: "ScriptUIImage"
|
||||
};
|
||||
var __uiContainers = { window: 1, panel: 1, group: 1, tabbedpanel: 1, tab: 1 };
|
||||
|
||||
function Window(type, title, bounds, props) {
|
||||
if (!(this instanceof Window)) return new Window(type, title, bounds, props);
|
||||
type = String(type === undefined ? "window" : type).toLowerCase();
|
||||
if (type.indexOf("{") >= 0) throw __err("ScriptUI resource strings are not supported: build the window with add()");
|
||||
if (type !== "dialog" && type !== "palette" && type !== "window") throw __err("Bad window type " + type);
|
||||
__uiInit(this, "window", null, null, bounds, title, props);
|
||||
this.__kind = type;
|
||||
this.__root = true;
|
||||
__uiWins.push(this);
|
||||
}
|
||||
Window.alert = function (s) { alert(s); };
|
||||
Window.confirm = function (s) { return confirm(s); };
|
||||
Window.prompt = function (m, d) { return prompt(m, d); };
|
||||
Window.find = function (type, title) {
|
||||
for (var i = 0; i < __uiWins.length; i++) if (__uiWins[i].text === title) return __uiWins[i];
|
||||
return null;
|
||||
};
|
||||
|
||||
function Panel() {}
|
||||
function Group() {}
|
||||
function Button() {}
|
||||
function IconButton() {}
|
||||
function StaticText() {}
|
||||
function EditText() {}
|
||||
function Checkbox() {}
|
||||
function RadioButton() {}
|
||||
function Slider() {}
|
||||
function Scrollbar() {}
|
||||
function Progressbar() {}
|
||||
function DropDownList() {}
|
||||
function ListBox() {}
|
||||
function TabbedPanel() {}
|
||||
function Tab() {}
|
||||
function ScriptUIImage() {}
|
||||
function ListItem() {}
|
||||
|
||||
var ScriptUI = {
|
||||
version: "6.2.2",
|
||||
frameworkName: "EffectCraft",
|
||||
environment: { keyboardState: { shiftKey: false, ctrlKey: false, altKey: false, metaKey: false, keyName: "" } },
|
||||
FontStyle: { REGULAR: 0, BOLD: 1, ITALIC: 2, BOLDITALIC: 3 },
|
||||
BrushType: { SOLID_COLOR: 0, THEME_COLOR: 1 },
|
||||
PenType: { SOLID_COLOR: 0, THEME_COLOR: 1 },
|
||||
Alignment: { LEFT: "left", RIGHT: "right", TOP: "top", BOTTOM: "bottom", CENTER: "center", FILL: "fill" },
|
||||
newFont: function (name, style, size) { return { name: name, style: style, size: size }; },
|
||||
newImage: function () { return {}; },
|
||||
getResourceText: function (t) { return t; },
|
||||
events: { createEvent: function (t) { return { type: t }; } }
|
||||
};
|
||||
|
||||
function __uiGraphics() {
|
||||
return {
|
||||
newBrush: function (t, c) { return { type: t, color: c }; },
|
||||
newPen: function (t, c, w) { return { type: t, color: c, lineWidth: w }; },
|
||||
newPath: function () {}, moveTo: function () {}, lineTo: function () {}, rectPath: function () {},
|
||||
ellipsePath: function () {}, fillPath: function () {}, strokePath: function () {}, closePath: function () {},
|
||||
drawString: function () {}, drawOSControl: function () {}, drawImage: function () {}, drawFocusRing: function () {},
|
||||
measureString: function (s) { return __dim(String(s).length * 7, 16); },
|
||||
font: null, foregroundColor: null, backgroundColor: null, disabledForegroundColor: null, disabledBackgroundColor: null
|
||||
};
|
||||
}
|
||||
|
||||
function __uiInit(o, type, win, parent, bounds, text, props) {
|
||||
o.type = type;
|
||||
o.window = win || o;
|
||||
o.parent = parent || null;
|
||||
o.__id = win ? win.__next++ : 0;
|
||||
if (!win) {
|
||||
o.__next = 1;
|
||||
o.__wid = __uiNewId();
|
||||
}
|
||||
o.properties = props || {};
|
||||
o.text = text === undefined || text === null ? "" : String(text);
|
||||
o.enabled = true;
|
||||
o.visible = true;
|
||||
o.helpTip = "";
|
||||
o.children = [];
|
||||
o.__ub = __toBounds(bounds);
|
||||
o.__lb = [0, 0, 0, 0];
|
||||
o.preferredSize = __dim(-1, -1);
|
||||
o.minimumSize = __dim(0, 0);
|
||||
o.maximumSize = __dim(10000, 10000);
|
||||
o.alignment = null;
|
||||
o.graphics = __uiGraphics();
|
||||
o.__listeners = {};
|
||||
if (o.properties.name) o.name = o.properties.name;
|
||||
if (__uiContainers[type]) {
|
||||
o.orientation = type === "group" ? "row" : type === "tabbedpanel" ? "stack" : "column";
|
||||
o.alignChildren = type === "group" ? ["center", "center"] : ["center", "top"];
|
||||
o.spacing = 10;
|
||||
o.margins = type === "window" ? [15, 15, 15, 15] : type === "panel" ? [10, 15, 10, 10] : type === "tab" ? [10, 10, 10, 10] : [0, 0, 0, 0];
|
||||
var self = o;
|
||||
o.layout = {
|
||||
layout: function () { __uiLayout(self.window); },
|
||||
resize: function () { __uiLayout(self.window); },
|
||||
margins: o.margins,
|
||||
spacing: o.spacing
|
||||
};
|
||||
}
|
||||
if (type === "checkbox" || type === "radiobutton") o.value = !!o.properties.value;
|
||||
if (type === "slider" || type === "scrollbar" || type === "progressbar") {
|
||||
o.value = 0;
|
||||
o.minvalue = 0;
|
||||
o.maxvalue = 100;
|
||||
}
|
||||
if (type === "edittext") {
|
||||
o.characters = 0;
|
||||
o.textselection = "";
|
||||
o.active = false;
|
||||
}
|
||||
if (type === "dropdownlist" || type === "listbox") {
|
||||
o.items = [];
|
||||
o.__sel = [];
|
||||
var it = o.properties.items;
|
||||
if (it) for (var i = 0; i < it.length; i++) o.add("item", it[i]);
|
||||
}
|
||||
if (type === "tabbedpanel") o.__sel = [];
|
||||
return o;
|
||||
}
|
||||
|
||||
function __uiProto() {
|
||||
return {
|
||||
add: function (type, bounds, text, props) {
|
||||
var t = String(type).toLowerCase();
|
||||
if (t === "item" || t === "separator") return this.__addItem(t === "separator" ? "-" : bounds);
|
||||
if (!__uiContainers[this.type]) throw __err("add(): a " + this.type + " can't contain controls");
|
||||
var ctor = __uiTypes[t];
|
||||
if (!ctor || t === "window") throw __err("add(): unknown control type " + type);
|
||||
var c = Object.create(__ecGlobal[ctor].prototype);
|
||||
// Value arguments after `text`: slider (value, min, max), progressbar (value, max), lists (items).
|
||||
__uiInit(c, t, this.window, this, bounds, typeof text === "object" && text !== null && !(text instanceof Array) ? "" : text, props);
|
||||
if ((t === "dropdownlist" || t === "listbox") && text instanceof Array) {
|
||||
for (var i = 0; i < text.length; i++) c.add("item", text[i]);
|
||||
c.text = "";
|
||||
}
|
||||
if (t === "slider" || t === "scrollbar") {
|
||||
c.text = "";
|
||||
c.value = text === undefined ? 0 : __num(text);
|
||||
if (arguments.length > 3 && typeof props === "number") {
|
||||
c.minvalue = props;
|
||||
c.maxvalue = arguments[4] === undefined ? 100 : __num(arguments[4]);
|
||||
c.properties = arguments[5] || {};
|
||||
}
|
||||
}
|
||||
if (t === "progressbar") {
|
||||
c.text = "";
|
||||
c.value = text === undefined ? 0 : __num(text);
|
||||
if (typeof props === "number") {
|
||||
c.maxvalue = props;
|
||||
c.properties = arguments[4] || {};
|
||||
}
|
||||
}
|
||||
if (t === "checkbox" || t === "radiobutton") c.value = false;
|
||||
if (t === "edittext" && c.properties.multiline) c.__multiline = true;
|
||||
if (t === "edittext" && c.properties.readonly) c.__readonly = true;
|
||||
if (c.properties && c.properties.name) c.name = c.properties.name;
|
||||
this.children.push(c);
|
||||
if (t === "tab" && this.type === "tabbedpanel" && this.__sel.length === 0) this.__sel = [this.children.length - 1];
|
||||
return c;
|
||||
},
|
||||
remove: function (what) {
|
||||
var list = this.items && this.type !== "tabbedpanel" ? this.items : this.children;
|
||||
var i = typeof what === "number" ? what : list.indexOf(what);
|
||||
if (i < 0 || i >= list.length) return;
|
||||
list.splice(i, 1);
|
||||
if (this.items) {
|
||||
for (var k = 0; k < this.items.length; k++) this.items[k].index = k;
|
||||
this.__sel = this.__sel.filter(function (s) { return s !== i; }).map(function (s) { return s > i ? s - 1 : s; });
|
||||
}
|
||||
},
|
||||
removeAll: function () {
|
||||
if (this.items) {
|
||||
this.items = [];
|
||||
this.__sel = [];
|
||||
} else this.children = [];
|
||||
},
|
||||
find: function (text) {
|
||||
var list = this.items || this.children;
|
||||
for (var i = 0; i < list.length; i++) if (list[i].text === text) return list[i];
|
||||
return null;
|
||||
},
|
||||
__addItem: function (text) {
|
||||
if (!this.items) throw __err("add(\"item\"): only lists have items");
|
||||
var it = Object.create(ListItem.prototype);
|
||||
it.text = String(text === undefined ? "" : text);
|
||||
it.type = text === "-" ? "separator" : "item";
|
||||
it.index = this.items.length;
|
||||
it.parent = this;
|
||||
it.image = null;
|
||||
it.checked = false;
|
||||
this.items.push(it);
|
||||
return it;
|
||||
},
|
||||
show: function () {
|
||||
if (!this.__root) {
|
||||
this.visible = true;
|
||||
return;
|
||||
}
|
||||
this.__shown = true;
|
||||
this.__closed = false;
|
||||
this.__fire("onShow");
|
||||
if (this.__kind === "dialog") {
|
||||
this.__modal = true;
|
||||
var r = __uiModal(this.__wid);
|
||||
this.__modal = false;
|
||||
return r === null ? undefined : r;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
hide: function () {
|
||||
if (this.__root) {
|
||||
this.close(2);
|
||||
} else this.visible = false;
|
||||
},
|
||||
close: function (result) {
|
||||
if (!this.__root) return;
|
||||
if (this.__fire("onClose") === false) return;
|
||||
this.__closed = true;
|
||||
this.__shown = false;
|
||||
this.__result = result === undefined ? 0 : result;
|
||||
if (this.__modal) __uiResults[this.__wid] = this.__result;
|
||||
},
|
||||
center: function () {},
|
||||
notify: function (ev) {
|
||||
var e = ev || (this.type === "edittext" || this.type === "slider" || this.type === "dropdownlist" || this.type === "listbox" ? "onChange" : "onClick");
|
||||
if (e.indexOf("on") !== 0) e = "on" + e.charAt(0).toUpperCase() + e.slice(1);
|
||||
this.__fire(e);
|
||||
},
|
||||
addEventListener: function (name, fn) {
|
||||
var k = String(name).toLowerCase();
|
||||
(this.__listeners[k] = this.__listeners[k] || []).push(fn);
|
||||
},
|
||||
removeEventListener: function (name, fn) {
|
||||
var k = String(name).toLowerCase();
|
||||
if (this.__listeners[k]) this.__listeners[k] = this.__listeners[k].filter(function (f) { return f !== fn; });
|
||||
},
|
||||
dispatchEvent: function (e) { this.__fire("on" + String(e.type || e).charAt(0).toUpperCase() + String(e.type || e).slice(1)); },
|
||||
__fire: function (name) {
|
||||
var r;
|
||||
if (typeof this[name] === "function") r = this[name].call(this);
|
||||
var l = this.__listeners[name.slice(2).toLowerCase()];
|
||||
if (l) for (var i = 0; i < l.length; i++) l[i].call(this, { type: name.slice(2).toLowerCase(), target: this });
|
||||
return r;
|
||||
},
|
||||
__handlers: function () {
|
||||
var out = [];
|
||||
var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize"];
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var n = names[i];
|
||||
if (typeof this[n] === "function" || (this.__listeners[n.slice(2).toLowerCase()] || []).length) out.push(n);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
toString: function () { return "[object " + (__uiTypes[this.type] || "Window") + "]"; }
|
||||
};
|
||||
}
|
||||
|
||||
(function () {
|
||||
var all = ["Window", "Panel", "Group", "Button", "IconButton", "StaticText", "EditText", "Checkbox", "RadioButton", "Slider",
|
||||
"Scrollbar", "Progressbar", "DropDownList", "ListBox", "TabbedPanel", "Tab", "ScriptUIImage"];
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
var C = __ecGlobal[all[i]];
|
||||
var p = __uiProto();
|
||||
for (var k in p) C.prototype[k] = p[k];
|
||||
Object.defineProperty(C.prototype, "bounds", {
|
||||
get: function () { var b = this.__ub || this.__lb; return __bounds4(b[0], b[1], b[2], b[3]); },
|
||||
set: function (v) { this.__ub = __toBounds(v); },
|
||||
configurable: true
|
||||
});
|
||||
Object.defineProperty(C.prototype, "size", {
|
||||
get: function () { var b = this.bounds; return __dim(b[2] - b[0], b[3] - b[1]); },
|
||||
set: function (v) {
|
||||
var b = this.bounds;
|
||||
var w = v.width !== undefined ? v.width : v[0], h = v.height !== undefined ? v.height : v[1];
|
||||
this.__ub = [b[0], b[1], b[0] + __num(w), b[1] + __num(h)];
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
Object.defineProperty(C.prototype, "location", {
|
||||
get: function () { var b = this.bounds; return __dim(b[0], b[1]); },
|
||||
set: function (v) {
|
||||
var b = this.bounds;
|
||||
var x = v.x !== undefined ? v.x : v[0], y = v.y !== undefined ? v.y : v[1];
|
||||
this.__ub = [__num(x), __num(y), __num(x) + b[2] - b[0], __num(y) + b[3] - b[1]];
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
var sel = {
|
||||
get: function () {
|
||||
var self = this;
|
||||
if (this.type === "tabbedpanel") return this.__sel.length ? this.children[this.__sel[0]] : null;
|
||||
var items = this.__sel.map(function (i) { return self.items[i]; });
|
||||
if (this.properties.multiselect) return items.length ? items : null;
|
||||
return items.length ? items[0] : null;
|
||||
},
|
||||
set: function (v) {
|
||||
var self = this;
|
||||
var list = this.type === "tabbedpanel" ? this.children : this.items;
|
||||
var idx = function (x) { return typeof x === "number" ? x : list.indexOf(x); };
|
||||
if (v === null || v === undefined) this.__sel = [];
|
||||
else if (v instanceof Array) this.__sel = v.map(idx).filter(function (i) { return i >= 0 && i < list.length; });
|
||||
else {
|
||||
var i = idx(v);
|
||||
this.__sel = i >= 0 && i < list.length ? [i] : [];
|
||||
}
|
||||
if (this.items) for (var k = 0; k < this.items.length; k++) this.items[k].selected = this.__sel.indexOf(k) >= 0;
|
||||
},
|
||||
configurable: true
|
||||
};
|
||||
Object.defineProperty(DropDownList.prototype, "selection", sel);
|
||||
Object.defineProperty(ListBox.prototype, "selection", sel);
|
||||
Object.defineProperty(TabbedPanel.prototype, "selection", sel);
|
||||
Object.defineProperty(ListItem.prototype, "toString", { value: function () { return this.text; } });
|
||||
})();
|
||||
|
||||
// A dockable ScriptUI panel (scripts in the ScriptUI Panels folder run with `this` = it).
|
||||
function __uiDockPanel(title) {
|
||||
var p = Object.create(Panel.prototype);
|
||||
__uiInit(p, "panel", null, null, undefined, title, {});
|
||||
p.type = "panel";
|
||||
p.__kind = "panel";
|
||||
p.__root = true;
|
||||
p.__shown = true;
|
||||
p.margins = [10, 10, 10, 10];
|
||||
__uiWins.push(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function __uiAlign(v) {
|
||||
if (v === null || v === undefined) return [];
|
||||
if (v instanceof Array) return v.map(function (x) { return String(x).toLowerCase(); });
|
||||
return [String(v).toLowerCase()];
|
||||
}
|
||||
function __uiMargins(m) {
|
||||
if (m === null || m === undefined) return null;
|
||||
if (typeof m === "number") return [m, m, m, m];
|
||||
if (m.length === 4) return [m[0], m[1], m[2], m[3]];
|
||||
return null;
|
||||
}
|
||||
function __uiSize(d) {
|
||||
if (!d) return null;
|
||||
var w = d.width !== undefined ? d.width : d[0], h = d.height !== undefined ? d.height : d[1];
|
||||
w = Number(w);
|
||||
h = Number(h);
|
||||
if (!(w > 0) && !(h > 0)) return null;
|
||||
return [w > 0 ? w : 0, h > 0 ? h : 0];
|
||||
}
|
||||
|
||||
function __uiJson(c) {
|
||||
var ps = __uiSize(c.preferredSize);
|
||||
if (c.type === "edittext" && c.characters > 0) ps = [c.characters * 7 + 12, ps ? ps[1] : 0];
|
||||
var o = {
|
||||
id: c.__id,
|
||||
type: c.type,
|
||||
name: String(c.name || c.properties.name || ""),
|
||||
text: String(c.text === undefined || c.text === null ? "" : c.text),
|
||||
enabled: c.enabled !== false,
|
||||
visible: c.visible !== false,
|
||||
helpTip: String(c.helpTip || ""),
|
||||
handlers: c.__handlers(),
|
||||
children: []
|
||||
};
|
||||
if (ps) o.preferredSize = ps;
|
||||
if (c.__ub && !c.__root) o.fixedBounds = c.__ub;
|
||||
if (c.__root && c.__ub) o.fixedBounds = [0, 0, c.__ub[2] - c.__ub[0], c.__ub[3] - c.__ub[1]];
|
||||
if (__uiContainers[c.type]) {
|
||||
o.orientation = String(c.orientation || "").toLowerCase();
|
||||
o.alignChildren = __uiAlign(c.alignChildren);
|
||||
var m = __uiMargins(c.margins);
|
||||
if (m) o.margins = m;
|
||||
if (typeof c.spacing === "number") o.spacing = c.spacing;
|
||||
var kids = c.children;
|
||||
for (var i = 0; i < kids.length; i++) o.children.push(__uiJson(kids[i]));
|
||||
}
|
||||
if (c.alignment !== null && c.alignment !== undefined) o.alignment = __uiAlign(c.alignment);
|
||||
if (c.type === "checkbox" || c.type === "radiobutton") o.checked = !!c.value;
|
||||
if (c.type === "slider" || c.type === "scrollbar" || c.type === "progressbar") {
|
||||
o.value = Number(c.value) || 0;
|
||||
o.min = Number(c.minvalue) || 0;
|
||||
o.max = Number(c.maxvalue);
|
||||
if (!(o.max === o.max)) o.max = 100;
|
||||
}
|
||||
if (c.items) {
|
||||
o.items = c.items.map(function (it) { return String(it.text); });
|
||||
o.selection = c.__sel.slice();
|
||||
}
|
||||
if (c.type === "tabbedpanel") o.activeTab = c.__sel.length ? c.__sel[0] : 0;
|
||||
if (c.type === "edittext") {
|
||||
o.multiline = !!c.__multiline;
|
||||
o.readOnly = !!c.__readonly;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
// The open windows of this script, for the host.
|
||||
function __uiSnapshot() {
|
||||
var out = [];
|
||||
for (var i = 0; i < __uiWins.length; i++) {
|
||||
var w = __uiWins[i];
|
||||
out.push({ id: w.__wid, kind: w.__kind, title: String(w.text || ""), visible: !!w.__shown && !w.__closed, modal: !!w.__modal, root: __uiJson(w) });
|
||||
}
|
||||
// Closed windows are forgotten.
|
||||
__uiWins = __uiWins.filter(function (w) { return !w.__closed; });
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
function __uiOpenCount() {
|
||||
var n = 0;
|
||||
for (var i = 0; i < __uiWins.length; i++) if (__uiWins[i].__shown && !__uiWins[i].__closed) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function __uiWin(id) {
|
||||
for (var i = 0; i < __uiWins.length; i++) if (__uiWins[i].__wid === id) return __uiWins[i];
|
||||
return null;
|
||||
}
|
||||
function __uiFind(c, id) {
|
||||
if (c.__id === id) return c;
|
||||
for (var i = 0; i < (c.children || []).length; i++) {
|
||||
var r = __uiFind(c.children[i], id);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// layout.layout(): lay the window out now (the host does it before drawing anyway) so scripts
|
||||
// can read bounds and sizes.
|
||||
function __uiLayout(win) {
|
||||
var b = JSON.parse(__uiLayoutNative(JSON.stringify({ id: win.__wid, kind: win.__kind, title: String(win.text || ""), visible: true, root: __uiJson(win) })));
|
||||
// Bounds are relative to the parent container, as in ScriptUI.
|
||||
(function apply(c, ox, oy) {
|
||||
var r = b[c.__id];
|
||||
if (!r) return;
|
||||
c.__lb = [r[0] - ox, r[1] - oy, r[0] - ox + r[2], r[1] - oy + r[3]];
|
||||
for (var i = 0; i < (c.children || []).length; i++) apply(c.children[i], r[0], r[1]);
|
||||
})(win, 0, 0);
|
||||
}
|
||||
|
||||
// A user action from the frontend.
|
||||
function __uiDispatch(winId, id, kind, valueJson) {
|
||||
var w = __uiWin(winId);
|
||||
if (!w) return;
|
||||
var v = valueJson === undefined || valueJson === "" ? null : JSON.parse(valueJson);
|
||||
if (kind === "close") {
|
||||
w.close(v === null ? 2 : v);
|
||||
return;
|
||||
}
|
||||
var c = __uiFind(w, id);
|
||||
if (!c || c.enabled === false) return;
|
||||
var radio = function () {
|
||||
var sib = c.parent ? c.parent.children : [];
|
||||
for (var i = 0; i < sib.length; i++) if (sib[i] !== c && sib[i].type === "radiobutton") sib[i].value = false;
|
||||
c.value = true;
|
||||
};
|
||||
if (kind === "click") {
|
||||
if (c.type === "checkbox") c.value = !c.value;
|
||||
else if (c.type === "radiobutton") radio();
|
||||
else if (c.type === "tab" && c.parent) {
|
||||
c.parent.selection = c;
|
||||
c.parent.__fire("onChange");
|
||||
return;
|
||||
}
|
||||
var handled = c.__handlers().indexOf("onClick") >= 0;
|
||||
c.__fire("onClick");
|
||||
// Dialog buttons without handlers: OK closes with 1, Cancel with 2.
|
||||
if (!handled && c.type === "button" && w.__kind === "dialog") {
|
||||
var n = String(c.name || c.properties.name || c.text).toLowerCase();
|
||||
if (c === w.defaultElement || n === "ok") w.close(1);
|
||||
else if (c === w.cancelElement || n === "cancel") w.close(2);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// change / changing
|
||||
if (c.type === "edittext") c.text = v === null ? "" : String(v);
|
||||
else if (c.type === "statictext") c.text = v === null ? "" : String(v);
|
||||
else if (c.type === "slider" || c.type === "scrollbar" || c.type === "progressbar") c.value = Number(v);
|
||||
else if (c.type === "checkbox") c.value = !!v;
|
||||
else if (c.type === "radiobutton") { if (v) radio(); else c.value = false; }
|
||||
else if (c.type === "dropdownlist" || c.type === "listbox" || c.type === "tabbedpanel") c.selection = v;
|
||||
if (c.type === "edittext" || c.type === "slider" || c.type === "scrollbar") c.__fire("onChanging");
|
||||
if (kind === "change") {
|
||||
if (c.type === "checkbox" || c.type === "radiobutton") c.__fire("onClick");
|
||||
else c.__fire("onChange");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
//! ScriptUI: windows built by scripts are published to the session, drawn by frontends and
|
||||
//! driven through the `scriptui.*` commands; dialogs block `show()` until they close; dockable
|
||||
//! panels; File ▸ Scripts (installed and sample scripts).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::scriptui::{WidgetKind, WindowKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::run_code;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default()));
|
||||
s
|
||||
}
|
||||
|
||||
fn comp_names(s: &Session) -> Vec<String> {
|
||||
s.project.items.values().filter(|i| i.as_comp().is_some()).map(|i| i.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn layer_names(s: &Session) -> Vec<String> {
|
||||
s.active_comp().map(|c| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
const PALETTE: &str = r#"
|
||||
var made = 0;
|
||||
var w = new Window("palette", "Maker");
|
||||
w.orientation = "column";
|
||||
w.alignChildren = ["fill", "top"];
|
||||
var name = w.add("edittext", undefined, "Box");
|
||||
name.characters = 12;
|
||||
var big = w.add("checkbox", undefined, "Big");
|
||||
var row = w.add("group");
|
||||
var make = row.add("button", undefined, "Make", { name: "make" });
|
||||
var label = row.add("statictext", undefined, "none yet");
|
||||
var size = w.add("slider", undefined, 50, 10, 200);
|
||||
var kind = w.add("dropdownlist", undefined, ["Solid", "Null"]);
|
||||
kind.selection = 0;
|
||||
var echo = "";
|
||||
name.onChange = function () { echo = name.text; };
|
||||
make.onClick = function () {
|
||||
var c = app.project.activeItem;
|
||||
var s = big.value ? 200 : Math.round(size.value);
|
||||
if (kind.selection.index === 0) c.layers.addSolid([1, 0, 0], name.text, s, s, 1);
|
||||
else c.layers.addNull().name = name.text;
|
||||
made++;
|
||||
label.text = "made " + made;
|
||||
writeLn("made " + name.text + " " + s);
|
||||
};
|
||||
w.show();
|
||||
"palette up"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn palette_controls_run_their_handlers() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 2})).unwrap();
|
||||
let o = run_code(&mut s, PALETTE, "maker.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!("palette up"));
|
||||
assert_eq!(s.script_ui.windows.len(), 1);
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
assert_eq!((w.kind, w.title.as_str(), w.script.as_str()), (WindowKind::Palette, "Maker", "maker.jsx"));
|
||||
let kinds: Vec<WidgetKind> = w.root.children.iter().map(|c| c.kind).collect();
|
||||
assert_eq!(kinds, [WidgetKind::EditText, WidgetKind::Checkbox, WidgetKind::Group, WidgetKind::Slider, WidgetKind::DropDownList]);
|
||||
// Laid out: the window has a size, the fill-aligned controls span its width.
|
||||
assert!(w.root.bounds[2] > 100.0 && w.root.bounds[3] > 100.0, "{:?}", w.root.bounds);
|
||||
let edit = &w.root.children[0];
|
||||
assert_eq!(edit.bounds[2], w.root.bounds[2] - 30.0);
|
||||
assert_eq!(w.root.children[3].value, 50.0);
|
||||
assert_eq!((w.root.children[3].min, w.root.children[3].max), (10.0, 200.0));
|
||||
assert_eq!(w.root.children[4].items, ["Solid", "Null"]);
|
||||
assert_eq!(w.root.children[4].selection, [0]);
|
||||
assert!(w.root.children[2].children[0].handlers.contains(&"onClick".to_string()));
|
||||
// Agents: list, read, click.
|
||||
let list = s.execute("scriptui.list", json!({})).unwrap();
|
||||
assert_eq!(list[0]["title"], "Maker");
|
||||
let tree = s.execute("scriptui.get", json!({"window": w.id})).unwrap();
|
||||
assert_eq!(tree["root"]["children"][2]["children"][0]["name"], "make");
|
||||
let r = s.execute("scriptui.click", json!({"widget": "make"})).unwrap();
|
||||
assert_eq!(r["output"], "made Box 50", "{r}");
|
||||
assert_eq!(layer_names(&s), ["Box"]);
|
||||
// Handlers' edits are undoable engine commands.
|
||||
assert!(s.undo());
|
||||
assert!(layer_names(&s).is_empty());
|
||||
// Set values like a user: text (onChange), checkbox, slider, list by item text.
|
||||
s.execute("scriptui.set", json!({"widget": "#1", "value": "Crate"})).unwrap();
|
||||
s.execute("scriptui.click", json!({"widget": "Big"})).unwrap();
|
||||
s.execute("scriptui.set", json!({"window": "Maker", "widget": 7, "value": "Null"})).unwrap();
|
||||
let r = s.execute("scriptui.click", json!({"widget": "make"})).unwrap();
|
||||
assert_eq!(r["output"], "made Crate 200");
|
||||
assert_eq!(layer_names(&s), ["Crate"]);
|
||||
assert!(s.active_comp().unwrap().layers[0].source == effectcraft_engine::project::LayerSource::Null);
|
||||
// The handler updated a label: the published tree follows.
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
assert_eq!(w.root.children[2].children[1].text, "made 2");
|
||||
assert!(w.root.children[1].checked);
|
||||
assert_eq!(w.root.children[4].selection, [1]);
|
||||
// Errors.
|
||||
assert!(s.execute("scriptui.click", json!({"widget": "nope"})).is_err());
|
||||
assert!(s.execute("scriptui.get", json!({"window": 999_999})).is_err());
|
||||
// Closing the window ends the script host.
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
assert!(s.script_ui.windows.is_empty());
|
||||
assert!(s.execute("scriptui.list", json!({})).unwrap().as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
const DIALOG: &str = r#"
|
||||
var d = new Window("dialog", "New Comp");
|
||||
d.alignChildren = "left";
|
||||
var e = d.add("edittext", undefined, "Untitled");
|
||||
var fps = d.add("radiobutton", undefined, "24 fps");
|
||||
var fps30 = d.add("radiobutton", undefined, "30 fps");
|
||||
fps.value = true;
|
||||
var g = d.add("group");
|
||||
g.add("button", undefined, "Cancel", { name: "cancel" });
|
||||
var ok = g.add("button", undefined, "OK", { name: "ok" });
|
||||
writeLn("before");
|
||||
var r = d.show();
|
||||
writeLn("result " + r + " " + e.text + " " + (fps30.value ? 30 : 24));
|
||||
if (r === 1) app.project.items.addComp(e.text, 100, 100, 1, 1, fps30.value ? 30 : 24);
|
||||
r
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn dialogs_block_show_until_they_close() {
|
||||
let mut s = session();
|
||||
let o = run_code(&mut s, DIALOG, "dialog.jsx");
|
||||
// The script waits in show(); the session is back here meanwhile.
|
||||
assert!(o.waiting, "{o:?}");
|
||||
assert_eq!(o.output, ["before"]);
|
||||
assert_eq!(s.script_ui.windows.len(), 1);
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
assert!(w.modal && w.kind == WindowKind::Dialog);
|
||||
// The app keeps working while the dialog is up.
|
||||
s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
s.execute("scriptui.set", json!({"widget": "#1", "value": "Shot 010"})).unwrap();
|
||||
s.execute("scriptui.click", json!({"widget": "30 fps"})).unwrap();
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
assert!(!w.root.children[1].checked && w.root.children[2].checked, "radio buttons are exclusive");
|
||||
// OK closes the dialog with 1: the script runs on.
|
||||
let r = s.execute("scriptui.click", json!({"widget": "ok"})).unwrap();
|
||||
assert_eq!(r["output"], "result 1 Shot 010 30", "{r}");
|
||||
assert_eq!(r["result"], 1);
|
||||
assert!(r.get("waiting").is_none());
|
||||
assert!(s.script_ui.windows.is_empty());
|
||||
let names = comp_names(&s);
|
||||
assert!(names.contains(&"Shot 010".to_string()) && names.contains(&"Other".to_string()), "{names:?}");
|
||||
// Cancel (or closing the window) returns 2.
|
||||
let o = run_code(&mut s, DIALOG, "dialog.jsx");
|
||||
assert!(o.waiting);
|
||||
let r = s.execute("scriptui.close", json!({})).unwrap();
|
||||
assert_eq!(r["output"], "result 2 Untitled 24");
|
||||
assert_eq!(comp_names(&s).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_and_handler_errors() {
|
||||
let mut s = session();
|
||||
let code = r#"
|
||||
var w = new Window("palette", "L");
|
||||
var g = w.add("group");
|
||||
g.orientation = "row";
|
||||
var a = g.add("button", undefined, "A");
|
||||
var b = g.add("button", undefined, "B");
|
||||
b.preferredSize = [120, 30];
|
||||
var p = w.add("panel", undefined, "Options");
|
||||
p.add("checkbox", undefined, "One");
|
||||
var bad = w.add("button", undefined, "Bad");
|
||||
bad.onClick = function () { nope(); };
|
||||
w.layout.layout(true);
|
||||
w.show();
|
||||
[a.bounds.width, b.size.width, b.size.height, b.bounds[0] - a.bounds[0], w.bounds.width > 200, p.bounds.top > a.bounds.bottom]
|
||||
"#;
|
||||
let o = run_code(&mut s, code, "layout.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!([80, 120, 30, 90, true, true]));
|
||||
// An error in a handler is reported, the window stays.
|
||||
let r = s.execute("scriptui.click", json!({"widget": "Bad"})).unwrap();
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(r["error"]["message"].as_str().unwrap().contains("nope"), "{r}");
|
||||
assert_eq!(s.script_ui.windows.len(), 1);
|
||||
// Resource strings aren't supported (a clear error).
|
||||
let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\")", "res.jsx");
|
||||
assert!(o.error.unwrap().message.contains("resource"));
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_windows_run_inline() {
|
||||
let mut s = session();
|
||||
let req = effectcraft_engine::ScriptRequest {
|
||||
code: "var cw = new Window('palette', 'Console UI'); var cb = cw.add('button', undefined, 'Hi'); var hits = 0; cb.onClick = function () { hits++; writeLn('hit ' + hits); }; cw.show(); 1",
|
||||
name: "Script Console",
|
||||
console: true,
|
||||
};
|
||||
let o = crate::run(&mut s, &req);
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(s.script_ui.windows.len(), 1);
|
||||
let r = s.execute("scriptui.click", json!({"widget": "Hi"})).unwrap();
|
||||
assert_eq!(r["output"], "hit 1");
|
||||
// The console context sees the handler's state.
|
||||
let o = crate::run(&mut s, &effectcraft_engine::ScriptRequest { code: "hits", name: "Script Console", console: true });
|
||||
assert_eq!(o.result, json!(1));
|
||||
// Dialogs don't block in the console.
|
||||
let o = crate::run(
|
||||
&mut s,
|
||||
&effectcraft_engine::ScriptRequest { code: "var dd = new Window('dialog', 'D'); dd.show(); 'went on'", name: "Script Console", console: true },
|
||||
);
|
||||
assert_eq!(o.result, json!("went on"));
|
||||
assert_eq!(s.script_ui.windows.len(), 2);
|
||||
s.execute("scriptui.close", json!({"window": "D"})).unwrap();
|
||||
s.execute("scriptui.close", json!({"window": "Console UI"})).unwrap();
|
||||
assert!(s.script_ui.windows.is_empty());
|
||||
}
|
||||
|
||||
fn solid(s: &mut Session, name: &str, at: f64) -> u64 {
|
||||
let id = s.execute("layer.newSolid", json!({"name": name, "color": "#808080", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [id], "in": at})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_scripts_and_installing() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 400, "height": 200, "duration": 10})).unwrap();
|
||||
for (n, at) in [("C", 2.0), ("A", 0.0), ("B", 1.0)] {
|
||||
solid(&mut s, n, at);
|
||||
}
|
||||
// File ▸ Scripts lists the samples.
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
let names: Vec<&str> = list.as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap()).collect();
|
||||
for want in ["Rename Layers.jsx", "Sort Layers by In Point.jsx", "Create Null at Selected Layers.jsx", "Render Queue Batch.jsx", "Layer Tools.jsx"] {
|
||||
assert!(names.contains(&want), "{names:?}");
|
||||
}
|
||||
// Sort Layers by In Point (all layers when none are selected).
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
s.execute("file.runScript", json!({"name": "Sort Layers by In Point.jsx"})).unwrap();
|
||||
assert_eq!(layer_names(&s), ["A", "B", "C"]);
|
||||
assert!(s.undo());
|
||||
assert_eq!(layer_names(&s), ["B", "A", "C"]);
|
||||
// Create Null at Selected Layers.
|
||||
let ids: Vec<u64> = s.active_comp().unwrap().layers.iter().take(2).map(|l| l.id.0).collect();
|
||||
s.execute("layer.select", json!({"layers": ids})).unwrap();
|
||||
s.execute("file.runScript", json!({"name": "Create Null at Selected Layers.jsx"})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
let null = c.layers.iter().find(|l| l.name == "Centroid").unwrap();
|
||||
assert!(c.layers.iter().filter(|l| ids.contains(&l.id.0)).all(|l| l.parent == Some(null.id)));
|
||||
// Rename Layers: a dialog; fill it in and press OK.
|
||||
s.execute("layer.select", json!({"layers": ids})).unwrap();
|
||||
let r = s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap();
|
||||
assert_eq!(r["waiting"], true);
|
||||
s.execute("scriptui.set", json!({"widget": "#11", "value": "Shot_"})).unwrap();
|
||||
s.execute("scriptui.click", json!({"widget": "Number the layers"})).unwrap();
|
||||
s.execute("scriptui.click", json!({"widget": "ok"})).unwrap();
|
||||
let names = layer_names(&s);
|
||||
assert!(names.contains(&"Shot_B 1".to_string()) && names.contains(&"Shot_A 2".to_string()), "{names:?}");
|
||||
// Install a script and a ScriptUI panel (they live in the settings store).
|
||||
let dir = std::env::temp_dir().join(format!("ec-scripts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("Hello.jsx");
|
||||
std::fs::write(&file, "writeLn('hello from ' + $.fileName); 42").unwrap();
|
||||
s.execute("file.installScript", json!({"path": file.to_string_lossy()})).unwrap();
|
||||
let panel = dir.join("Mini Panel.jsx");
|
||||
std::fs::write(&panel, "var p = this; p.add('button', undefined, 'Go').onClick = function () { writeLn('go'); }; p.layout.layout(true);").unwrap();
|
||||
s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap();
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Hello.jsx" && e["source"] == "installed" && e["panel"] == false));
|
||||
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Mini Panel.jsx" && e["panel"] == true));
|
||||
let r = s.execute("file.runScript", json!({"name": "Hello.jsx"})).unwrap();
|
||||
assert_eq!(r["result"], 42);
|
||||
// Window ▸ Mini Panel.jsx: a dockable panel; `this` is the panel.
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Mini Panel.jsx"})).unwrap();
|
||||
let wid = r["window"].as_u64().unwrap();
|
||||
let w = s.script_ui.window(wid as u32).unwrap().clone();
|
||||
assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Mini Panel"));
|
||||
let r = s.execute("scriptui.click", json!({"window": wid, "widget": "Go"})).unwrap();
|
||||
assert_eq!(r["output"], "go");
|
||||
// Opening it again brings the same panel forward.
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Mini Panel.jsx"})).unwrap();
|
||||
assert_eq!(r["window"], wid);
|
||||
// The sample panel docks too, and works.
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap();
|
||||
let tools = r["window"].as_u64().unwrap();
|
||||
let before = s.active_comp().unwrap().layers.len();
|
||||
s.execute("layer.select", json!({"layers": ids})).unwrap();
|
||||
s.execute("scriptui.click", json!({"window": tools, "widget": "Null at Centroid"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), before + 1);
|
||||
s.execute("scriptui.close", json!({"window": tools})).unwrap();
|
||||
s.execute("scriptui.close", json!({"window": wid})).unwrap();
|
||||
assert!(s.execute("file.installScript", json!({"path": "/nope/x.txt"})).is_err());
|
||||
s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap();
|
||||
assert!(s.execute("file.runScript", json!({"name": "Hello.jsx"})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _: Value = json!(null);
|
||||
}
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
//! ScriptUI hosts: who owns a script window's JavaScript objects, and how user actions reach
|
||||
//! its handlers.
|
||||
//!
|
||||
//! * **Script files** (native builds) run on a *script-host thread*. The caller's session moves
|
||||
//! to that thread while JavaScript runs and comes back whenever the script stops: at the end,
|
||||
//! or while a modal dialog waits in `show()`. Each [`ScriptUiEvent`] moves the session there
|
||||
//! again, runs the handler, and brings it back with the windows' new state. The host ends
|
||||
//! when its last window closes. Only one side holds the session at a time, so nothing runs
|
||||
//! concurrently and every edit is still an ordinary undoable engine command.
|
||||
//! * **The Script Console** (and every run on the web) runs inline: its windows' handlers run in
|
||||
//! the console's (or a kept) context on the caller's thread, and dialogs don't block.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::mem::ManuallyDrop;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use boa_engine::{Context, JsResult, JsValue, js_string};
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::scriptui::{ScriptUiEvent, ScriptWindow, layout};
|
||||
use serde_json::{Value as J, json};
|
||||
|
||||
use crate::runtime::{ACTIVE, Active, CONSOLE, Restore, ScriptError, arg_string, js_str, outcome, script_error, settle, throw, with_active};
|
||||
|
||||
pub(crate) const SCRIPTUI: &str = include_str!("scriptui.js");
|
||||
|
||||
/// The Script Console's host id.
|
||||
pub(crate) const CONSOLE_HOST: u32 = u32::MAX;
|
||||
|
||||
static NEXT_WINDOW: AtomicU32 = AtomicU32::new(1);
|
||||
static NEXT_HOST: AtomicU32 = AtomicU32::new(1);
|
||||
|
||||
pub(crate) fn new_host_id() -> u32 {
|
||||
NEXT_HOST.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Contexts of inline runs whose palettes / panels are still open (dropped when they close).
|
||||
static INLINE: RefCell<BTreeMap<u32, ManuallyDrop<Context>>> = const { RefCell::new(BTreeMap::new()) };
|
||||
}
|
||||
|
||||
pub(crate) fn keep_inline(host: u32, ctx: Context) {
|
||||
INLINE.with(|m| m.borrow_mut().insert(host, ManuallyDrop::new(ctx)));
|
||||
}
|
||||
|
||||
fn drop_inline(host: u32) {
|
||||
if let Some(ctx) = INLINE.with(|m| m.borrow_mut().remove(&host)) {
|
||||
drop(ManuallyDrop::into_inner(ctx));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- JavaScript side
|
||||
|
||||
fn call_global(ctx: &mut Context, name: &str, args: &[JsValue]) -> JsResult<JsValue> {
|
||||
let f = ctx.global_object().get(js_string!(name), ctx)?;
|
||||
let f = f.as_object().ok_or_else(|| throw(format!("{name} is not defined")))?;
|
||||
f.call(&JsValue::undefined(), args, ctx)
|
||||
}
|
||||
|
||||
/// The open windows of a context (closed ones are reported once, invisible, then forgotten).
|
||||
pub(crate) fn snapshot(ctx: &mut Context) -> Vec<ScriptWindow> {
|
||||
let Ok(v) = call_global(ctx, "__uiSnapshot", &[]) else { return vec![] };
|
||||
let s = v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
|
||||
serde_json::from_str(&s).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Run a window event's handlers in `ctx`.
|
||||
fn dispatch_js(ctx: &mut Context, ev: &ScriptUiEvent) -> Result<J, ScriptError> {
|
||||
let args = [JsValue::from(ev.window), JsValue::from(ev.widget), js_str(&ev.kind), js_str(&ev.value.to_string())];
|
||||
match call_global(ctx, "__uiDispatch", &args) {
|
||||
Ok(_) => Ok(J::Null),
|
||||
Err(e) => {
|
||||
let mut err = script_error(&e, ctx, "ScriptUI handler", "");
|
||||
err.message = format!("{} handler: {}", ev.kind, err.message);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `__uiNewId()`: a window id unique in the process.
|
||||
pub(crate) fn native_new_id(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(NEXT_WINDOW.fetch_add(1, Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
/// `__uiLayoutNative(windowJson)` → `{controlId: [x, y, width, height]}`: ScriptUI's automatic
|
||||
/// layout ([`effectcraft_engine::scriptui::layout`]) for `layout.layout()`.
|
||||
pub(crate) fn native_layout(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let s = arg_string(args, 0, ctx)?;
|
||||
let mut w: ScriptWindow = serde_json::from_str(&s).map_err(|e| throw(format!("layout: {e}")))?;
|
||||
layout(&mut w);
|
||||
let mut all = vec![];
|
||||
w.root.walk(&mut all);
|
||||
let map: serde_json::Map<String, J> = all.iter().map(|c| (c.id.to_string(), json!(c.bounds))).collect();
|
||||
Ok(js_str(&J::Object(map).to_string()))
|
||||
}
|
||||
|
||||
/// `__uiModal(windowId)`: a dialog's `show()`. On a script-host thread it hands the session back
|
||||
/// to the caller and waits for the dialog's events until it closes, returning its result. Inline
|
||||
/// (Script Console, web) it returns `null` at once: the dialog stays open like a palette.
|
||||
pub(crate) fn native_modal(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let id = args.first().cloned().unwrap_or_default().to_number(ctx)? as u32;
|
||||
let linked = with_active(|a| Ok(a.link.is_some())).unwrap_or(false);
|
||||
if !linked {
|
||||
return Ok(JsValue::null());
|
||||
}
|
||||
loop {
|
||||
let r = call_global(ctx, "__uiModalResult", &[JsValue::from(id)])?;
|
||||
if !r.is_null() && !r.is_undefined() {
|
||||
return Ok(r);
|
||||
}
|
||||
let windows = snapshot(ctx);
|
||||
let out = crate::Outcome { output: settle(), waiting: true, ..Default::default() };
|
||||
let Some(ev) = yield_wait(out, windows, false) else {
|
||||
// The caller went away: cancel the dialog.
|
||||
return Ok(JsValue::from(2));
|
||||
};
|
||||
if let Err(e) = dispatch_js(ctx, &ev) {
|
||||
let line = e.line.map(|l| format!(" (line {l})")).unwrap_or_default();
|
||||
let _ = with_active(|a| {
|
||||
a.output.push(format!("Error{line}: {}", e.message));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- script-host threads
|
||||
|
||||
pub(crate) enum ToHost {
|
||||
Start(Session),
|
||||
Event(Session, ScriptUiEvent),
|
||||
}
|
||||
|
||||
pub(crate) struct FromHost {
|
||||
session: Session,
|
||||
outcome: crate::Outcome,
|
||||
windows: Vec<ScriptWindow>,
|
||||
name: String,
|
||||
/// The host thread has ended (no windows left).
|
||||
done: bool,
|
||||
}
|
||||
|
||||
/// The host thread's end of the hand-off.
|
||||
pub(crate) struct Link {
|
||||
rx: std::sync::mpsc::Receiver<ToHost>,
|
||||
tx: std::sync::mpsc::Sender<FromHost>,
|
||||
}
|
||||
|
||||
/// The caller's end.
|
||||
struct HostEnd {
|
||||
tx: std::sync::mpsc::Sender<ToHost>,
|
||||
rx: std::sync::mpsc::Receiver<FromHost>,
|
||||
}
|
||||
|
||||
fn hosts() -> &'static std::sync::Mutex<BTreeMap<u32, HostEnd>> {
|
||||
static H: std::sync::OnceLock<std::sync::Mutex<BTreeMap<u32, HostEnd>>> = std::sync::OnceLock::new();
|
||||
H.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
// The session crosses to script-host threads and back.
|
||||
const _: fn() = || {
|
||||
fn send<T: Send>() {}
|
||||
send::<Session>();
|
||||
};
|
||||
|
||||
/// On the host thread: hand the session (with the windows' state and what the script printed)
|
||||
/// back to the caller, and wait for the next event. `None` when the caller has gone.
|
||||
fn yield_wait(outcome: crate::Outcome, windows: Vec<ScriptWindow>, done: bool) -> Option<ScriptUiEvent> {
|
||||
let (session, link, name) = ACTIVE.with(|a| {
|
||||
let mut a = a.borrow_mut();
|
||||
let a = a.as_mut()?;
|
||||
Some((std::mem::take(&mut a.session), a.link.take()?, a.name.clone()))
|
||||
})?;
|
||||
if link.tx.send(FromHost { session, outcome, windows, name, done }).is_err() {
|
||||
return None;
|
||||
}
|
||||
if done {
|
||||
return None;
|
||||
}
|
||||
match link.rx.recv() {
|
||||
Ok(ToHost::Event(s, ev)) => {
|
||||
ACTIVE.with(|a| {
|
||||
if let Some(a) = a.borrow_mut().as_mut() {
|
||||
a.session = s;
|
||||
a.link = Some(link);
|
||||
}
|
||||
});
|
||||
Some(ev)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn host_main(name: String, code: String, link: Link) {
|
||||
let Ok(ToHost::Start(session)) = link.rx.recv() else { return };
|
||||
let mut a = Active::new(session, &name);
|
||||
a.link = Some(link);
|
||||
ACTIVE.with(|c| *c.borrow_mut() = Some(a));
|
||||
let file = if name.is_empty() { "script".to_string() } else { name.clone() };
|
||||
let body = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut ctx = match crate::runtime::new_context() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
yield_wait(outcome(settle(), Err(ScriptError { message: e, file: file.clone(), ..Default::default() })), vec![], true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = crate::runtime::eval_with_tasks(&mut ctx, &code, &file);
|
||||
let mut out = outcome(settle(), r);
|
||||
loop {
|
||||
let windows = snapshot(&mut ctx);
|
||||
let open = windows.iter().any(|w| w.visible);
|
||||
match yield_wait(out, windows, !open) {
|
||||
Some(ev) => {
|
||||
let r = dispatch_js(&mut ctx, &ev);
|
||||
out = outcome(settle(), r);
|
||||
}
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
}));
|
||||
// A panic: give the session back all the same.
|
||||
let left = ACTIVE.with(|c| c.borrow_mut().take());
|
||||
if let Some(mut a) = left
|
||||
&& let Some(link) = a.link.take()
|
||||
{
|
||||
let message = if body.is_err() { "the script engine stopped unexpectedly" } else { "the script ended" };
|
||||
let out = crate::Outcome { error: Some(ScriptError { message: message.into(), file, ..Default::default() }), ..Default::default() };
|
||||
let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true });
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the host to hand the session back; keep its link while it has windows open.
|
||||
fn receive(session: &mut Session, host: u32, end: HostEnd) -> crate::Outcome {
|
||||
match end.rx.recv() {
|
||||
Ok(FromHost { session: s, outcome, windows, name, done }) => {
|
||||
*session = s;
|
||||
publish(session, host, &name, windows);
|
||||
if done {
|
||||
session.script_ui.windows.retain(|w| w.host != host);
|
||||
session.script_ui.revision += 1;
|
||||
} else if let Ok(mut h) = hosts().lock() {
|
||||
h.insert(host, end);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
Err(_) => crate::Outcome { error: Some(ScriptError { message: "the script host stopped".into(), ..Default::default() }), ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a script file on a new script-host thread (see the module docs).
|
||||
pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::ScriptRequest) -> crate::Outcome {
|
||||
let host = new_host_id();
|
||||
let (to_tx, to_rx) = std::sync::mpsc::channel();
|
||||
let (from_tx, from_rx) = std::sync::mpsc::channel();
|
||||
let (name, code) = (req.name.to_string(), req.code.to_string());
|
||||
let spawned =
|
||||
std::thread::Builder::new().name(format!("script-{host}")).stack_size(64 << 20).spawn(move || host_main(name, code, Link { rx: to_rx, tx: from_tx }));
|
||||
if spawned.is_err() {
|
||||
return crate::runtime::run_inline(session, req);
|
||||
}
|
||||
if let Err(std::sync::mpsc::SendError(ToHost::Start(s) | ToHost::Event(s, _))) = to_tx.send(ToHost::Start(std::mem::take(session))) {
|
||||
*session = s;
|
||||
return crate::Outcome { error: Some(ScriptError { message: "the script host did not start".into(), ..Default::default() }), ..Default::default() };
|
||||
}
|
||||
receive(session, host, HostEnd { tx: to_tx, rx: from_rx })
|
||||
}
|
||||
|
||||
/// Record the windows a host published (named after the script that made them).
|
||||
pub(crate) fn publish(session: &mut Session, host: u32, name: &str, mut windows: Vec<ScriptWindow>) {
|
||||
for w in &mut windows {
|
||||
if w.script.is_empty() {
|
||||
w.script = name.to_string();
|
||||
}
|
||||
}
|
||||
let had = session.script_ui.windows.iter().any(|w| w.host == host);
|
||||
if windows.is_empty() && !had {
|
||||
return;
|
||||
}
|
||||
session.script_ui.publish(host, windows);
|
||||
if host != CONSOLE_HOST && !session.script_ui.windows.iter().any(|w| w.host == host) {
|
||||
drop_inline(host);
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`effectcraft_engine::scriptui::ScriptUiDispatch`] this crate provides: run a window
|
||||
/// event's handlers in the script that made the window, then publish its windows' new state.
|
||||
/// Returns the handler run's `{ok, output, error, waiting?}`.
|
||||
pub fn dispatch_ui(session: &mut Session, ev: &ScriptUiEvent) -> Result<J, String> {
|
||||
if ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true)) {
|
||||
return Err("scripts can't drive script windows".into());
|
||||
}
|
||||
let w = session.script_ui.window(ev.window).ok_or_else(|| format!("no script window {}", ev.window))?;
|
||||
let (host, name) = (w.host, w.script.clone());
|
||||
let inline = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host));
|
||||
if inline {
|
||||
return Ok(dispatch_inline(session, host, &name, ev).to_json());
|
||||
}
|
||||
let end = hosts().lock().ok().and_then(|mut h| h.remove(&host));
|
||||
let Some(end) = end else {
|
||||
session.script_ui.windows.retain(|w| w.host != host);
|
||||
session.script_ui.revision += 1;
|
||||
return Err("the script that made this window has ended".into());
|
||||
};
|
||||
if let Err(std::sync::mpsc::SendError(ToHost::Event(s, _) | ToHost::Start(s))) = end.tx.send(ToHost::Event(std::mem::take(session), ev.clone())) {
|
||||
*session = s;
|
||||
session.script_ui.windows.retain(|w| w.host != host);
|
||||
session.script_ui.revision += 1;
|
||||
return Err("the script that made this window has ended".into());
|
||||
}
|
||||
Ok(receive(session, host, end).to_json())
|
||||
}
|
||||
|
||||
fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEvent) -> crate::Outcome {
|
||||
let s = std::mem::take(session);
|
||||
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name)));
|
||||
let guard = Restore { target: session };
|
||||
let (r, windows) = if host == CONSOLE_HOST {
|
||||
CONSOLE.with(|c| match c.borrow_mut().as_mut() {
|
||||
Some(ctx) => {
|
||||
let r = dispatch_js(ctx, ev);
|
||||
(r, snapshot(ctx))
|
||||
}
|
||||
None => (Ok(J::Null), vec![]),
|
||||
})
|
||||
} else {
|
||||
INLINE.with(|m| match m.borrow_mut().get_mut(&host) {
|
||||
Some(ctx) => {
|
||||
let r = dispatch_js(ctx, ev);
|
||||
(r, snapshot(ctx))
|
||||
}
|
||||
None => (Ok(J::Null), vec![]),
|
||||
})
|
||||
};
|
||||
let output = settle();
|
||||
drop(guard);
|
||||
publish(session, host, name, windows);
|
||||
outcome(output, r)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use super::Distortion;
|
||||
use super::geometry::Pose;
|
||||
use super::linalg::*;
|
||||
|
||||
|
|
@ -19,6 +20,8 @@ pub struct BaCam {
|
|||
pub pose: Pose,
|
||||
/// Focal length (pixels).
|
||||
pub f: f64,
|
||||
/// Lens distortion (shared by all cameras; adjusted when [`BaOpts::distortion`]).
|
||||
pub dist: Distortion,
|
||||
}
|
||||
|
||||
/// One observation: camera `cam` sees point `pt` at `uv` (pixels relative to the principal
|
||||
|
|
@ -50,11 +53,13 @@ pub struct BaOpts {
|
|||
/// Huber threshold (pixels).
|
||||
pub huber: f64,
|
||||
pub max_iter: usize,
|
||||
/// Adjust the shared radial distortion `(k1, k2)` too.
|
||||
pub distortion: bool,
|
||||
}
|
||||
|
||||
impl Default for BaOpts {
|
||||
fn default() -> Self {
|
||||
BaOpts { focal: FocalMode::Fixed, fix_points: false, fix_centers: false, fixed: vec![], huber: 2.0, max_iter: 30 }
|
||||
BaOpts { focal: FocalMode::Fixed, fix_points: false, fix_centers: false, fixed: vec![], huber: 2.0, max_iter: 30, distortion: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +82,8 @@ pub fn residual(c: &BaCam, x: V3, uv: [f64; 2]) -> Option<[f64; 2]> {
|
|||
if p[2] <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
Some([c.f * p[0] / p[2] - uv[0], c.f * p[1] / p[2] - uv[1]])
|
||||
let q = c.dist.distort([c.f * p[0] / p[2], c.f * p[1] / p[2]]);
|
||||
Some([q[0] - uv[0], q[1] - uv[1]])
|
||||
}
|
||||
|
||||
/// Total robust cost.
|
||||
|
|
@ -108,6 +114,8 @@ struct Layout {
|
|||
fidx: Vec<usize>,
|
||||
/// Index of the shared focal parameter.
|
||||
shared: Option<usize>,
|
||||
/// Index of `k1` (then `k2`) when the distortion is adjusted.
|
||||
dist: Option<usize>,
|
||||
n: usize,
|
||||
rot_only: bool,
|
||||
}
|
||||
|
|
@ -132,7 +140,11 @@ fn layout(ncam: usize, o: &BaOpts) -> Layout {
|
|||
n += 1;
|
||||
n - 1
|
||||
});
|
||||
Layout { start, fidx, shared, n, rot_only: o.fix_centers }
|
||||
let dist = o.distortion.then(|| {
|
||||
n += 2;
|
||||
n - 2
|
||||
});
|
||||
Layout { start, fidx, shared, dist, n, rot_only: o.fix_centers }
|
||||
}
|
||||
|
||||
/// Camera-side Jacobian entries of one observation: (parameter index, d r / d param).
|
||||
|
|
@ -204,12 +216,21 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) ->
|
|||
continue;
|
||||
}
|
||||
let iz = 1.0 / xc[2];
|
||||
let p = [c.f * xc[0] * iz, c.f * xc[1] * iz];
|
||||
let r = [p[0] - ob.uv[0], p[1] - ob.uv[1]];
|
||||
let p0 = [c.f * xc[0] * iz, c.f * xc[1] * iz];
|
||||
let pd = c.dist.distort(p0);
|
||||
let r = [pd[0] - ob.uv[0], pd[1] - ob.uv[1]];
|
||||
let e = (r[0] * r[0] + r[1] * r[1]).sqrt();
|
||||
let w = if e <= o.huber { 1.0 } else { o.huber / e };
|
||||
let a = [[c.f * iz, 0.0, -c.f * xc[0] * iz * iz], [0.0, c.f * iz, -c.f * xc[1] * iz * iz]];
|
||||
let a0 = [[c.f * iz, 0.0, -c.f * xc[0] * iz * iz], [0.0, c.f * iz, -c.f * xc[1] * iz * iz]];
|
||||
// Through the distortion: d r = D d p.
|
||||
let (dj, dk) = c.dist.jacobian(p0);
|
||||
let a = [0, 1].map(|i| [0, 1, 2].map(|k| dj[i][0] * a0[0][k] + dj[i][1] * a0[1][k]));
|
||||
let p = [dj[0][0] * p0[0] + dj[0][1] * p0[1], dj[1][0] * p0[0] + dj[1][1] * p0[1]];
|
||||
cam_jac(&l, ci, c, xc, &a, p, &mut jc);
|
||||
if let Some(g) = l.dist {
|
||||
jc.push((g, [dk[0][0], dk[1][0]]));
|
||||
jc.push((g + 1, [dk[0][1], dk[1][1]]));
|
||||
}
|
||||
// Point Jacobian: A R.
|
||||
let jx = if o.fix_points {
|
||||
[[0.0; 3]; 2]
|
||||
|
|
@ -236,7 +257,8 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) ->
|
|||
[w * (ja[0] * jx[0][0] + ja[1] * jx[1][0]), w * (ja[0] * jx[0][1] + ja[1] * jx[1][1]), w * (ja[0] * jx[0][2] + ja[1] * jx[1][2])];
|
||||
// Each observation of a point is on another camera: only the shared focal
|
||||
// length repeats.
|
||||
let found = if Some(*ia) == l.shared { ps.w.iter_mut().find(|e| e.0 == *ia) } else { None };
|
||||
let repeats = Some(*ia) == l.shared || l.dist.is_some_and(|g| *ia == g || *ia == g + 1);
|
||||
let found = if repeats { ps.w.iter_mut().find(|e| e.0 == *ia) } else { None };
|
||||
match found {
|
||||
Some(e) => e.1 = add(e.1, wv),
|
||||
None => ps.w.push((*ia, wv)),
|
||||
|
|
@ -385,6 +407,10 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) ->
|
|||
if let Some(g) = l.shared {
|
||||
c.f *= dc[g].clamp(-0.5, 0.5).exp();
|
||||
}
|
||||
if let Some(g) = l.dist {
|
||||
c.dist.k1 += dc[g].clamp(-0.2, 0.2);
|
||||
c.dist.k2 += dc[g + 1].clamp(-0.2, 0.2);
|
||||
}
|
||||
}
|
||||
let mut np: Vec<V3> = pts.to_vec();
|
||||
if !o.fix_points {
|
||||
|
|
@ -445,7 +471,11 @@ mod tests {
|
|||
let truth: Vec<V3> = (0..80).map(|_| [u() * 6.0, u() * 4.0, 6.0 + u() * 4.0]).collect();
|
||||
let f = 900.0;
|
||||
let cams_true: Vec<BaCam> = (0..6)
|
||||
.map(|i| BaCam { pose: Pose { r: rodrigues([0.0, -0.05 * i as f64, 0.01 * i as f64]), c: [0.3 * i as f64, 0.05 * i as f64, 0.0] }, f })
|
||||
.map(|i| BaCam {
|
||||
pose: Pose { r: rodrigues([0.0, -0.05 * i as f64, 0.01 * i as f64]), c: [0.3 * i as f64, 0.05 * i as f64, 0.0] },
|
||||
f,
|
||||
dist: Distortion::NONE,
|
||||
})
|
||||
.collect();
|
||||
let mut obs = vec![];
|
||||
for (ci, c) in cams_true.iter().enumerate() {
|
||||
|
|
|
|||
|
|
@ -28,11 +28,19 @@ pub struct TrackAnalyzer {
|
|||
frame: u32,
|
||||
size: [f64; 2],
|
||||
factor: f64,
|
||||
/// (analysis long side, wanted features) overriding the options' defaults.
|
||||
budget: Option<(u32, usize)>,
|
||||
}
|
||||
|
||||
impl TrackAnalyzer {
|
||||
pub fn new(size: [f64; 2], opts: AnalyzeOpts) -> TrackAnalyzer {
|
||||
TrackAnalyzer { opts, prev: None, active: vec![], tracks: vec![], frame: 0, size, factor: 1.0 }
|
||||
TrackAnalyzer { opts, prev: None, active: vec![], tracks: vec![], frame: 0, size, factor: 1.0, budget: None }
|
||||
}
|
||||
|
||||
/// An analyzer working at most at `max_side` pixels with about `want` live features (the
|
||||
/// Warp Stabilizer's trajectories for Subspace Warp).
|
||||
pub fn with_budget(size: [f64; 2], max_side: u32, want: usize) -> TrackAnalyzer {
|
||||
TrackAnalyzer { budget: Some((max_side, want.max(8))), ..TrackAnalyzer::new(size, AnalyzeOpts::default()) }
|
||||
}
|
||||
|
||||
pub fn frames(&self) -> u32 {
|
||||
|
|
@ -45,6 +53,9 @@ impl TrackAnalyzer {
|
|||
}
|
||||
|
||||
fn params(&self) -> (u32, usize) {
|
||||
if let Some(b) = self.budget {
|
||||
return b;
|
||||
}
|
||||
if self.opts.detailed { (1280, 900) } else { (720, 500) }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,8 +28,15 @@
|
|||
//! tripod solution when it explains the tracks as well as the general one (a quick rotation fit
|
||||
//! at the general solution's focal length skips the tripod search when it clearly fails).
|
||||
//!
|
||||
//! The camera model has square pixels, the principal point at the layer centre and no lens
|
||||
//! distortion. Camera space is x right, y down, z forward (After Effects' convention); a camera
|
||||
//! 5. **Lens distortion** (Solve Lens Distortion, or Detailed Analysis): radial distortion
|
||||
//! `p_d = p (1 + k1 ρ² + k2 ρ⁴)` (ρ = distance from the principal point over half the frame
|
||||
//! diagonal; Brown's model without the tangential terms) is solved jointly with the cameras
|
||||
//! and points in a final bundle adjustment on the raw tracks; the whole solve is then repeated
|
||||
//! on tracks undistorted with that estimate (the linear initialisations assume a pinhole
|
||||
//! camera) and adjusted jointly again. The solve stores `k1, k2`; the effect can undistort
|
||||
//! its output so composited 3D layers line up.
|
||||
//!
|
||||
//! The camera model has square pixels and the principal point at the layer centre. Camera space is x right, y down, z forward (After Effects' convention); a camera
|
||||
//! is `(R, C, f)` with `X_cam = R (X − C)` and pixel `u = f x / z + w / 2`.
|
||||
//!
|
||||
//! The solve is stored in a canonical frame: the first solved frame's camera is at the origin
|
||||
|
|
@ -105,6 +112,69 @@ impl CameraTracks {
|
|||
}
|
||||
}
|
||||
|
||||
/// Radial lens distortion around the principal point (see the module docs).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Distortion {
|
||||
pub k1: f64,
|
||||
pub k2: f64,
|
||||
/// Normalisation radius ρ = 1 (pixels): half the frame diagonal; 0 = no distortion.
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
impl Distortion {
|
||||
pub const NONE: Distortion = Distortion { k1: 0.0, k2: 0.0, radius: 0.0 };
|
||||
|
||||
/// No distortion for frames of `size` (the radius set, coefficients zero).
|
||||
pub fn for_size(size: [f64; 2]) -> Distortion {
|
||||
Distortion { k1: 0.0, k2: 0.0, radius: 0.5 * size[0].hypot(size[1]) }
|
||||
}
|
||||
pub fn is_none(&self) -> bool {
|
||||
self.radius <= 0.0 || (self.k1 == 0.0 && self.k2 == 0.0)
|
||||
}
|
||||
fn rho2(&self, p: [f64; 2]) -> f64 {
|
||||
(p[0] * p[0] + p[1] * p[1]) / (self.radius * self.radius)
|
||||
}
|
||||
/// Ideal → distorted (pixels relative to the principal point).
|
||||
pub fn distort(&self, p: [f64; 2]) -> [f64; 2] {
|
||||
if self.radius <= 0.0 {
|
||||
return p;
|
||||
}
|
||||
let r2 = self.rho2(p);
|
||||
let d = 1.0 + self.k1 * r2 + self.k2 * r2 * r2;
|
||||
[p[0] * d, p[1] * d]
|
||||
}
|
||||
/// Distorted → ideal (fixed-point iteration).
|
||||
pub fn undistort(&self, q: [f64; 2]) -> [f64; 2] {
|
||||
if self.is_none() {
|
||||
return q;
|
||||
}
|
||||
let mut p = q;
|
||||
for _ in 0..30 {
|
||||
let r2 = self.rho2(p);
|
||||
let d = 1.0 + self.k1 * r2 + self.k2 * r2 * r2;
|
||||
if d.abs() < 1e-6 {
|
||||
break;
|
||||
}
|
||||
p = [q[0] / d, q[1] / d];
|
||||
}
|
||||
p
|
||||
}
|
||||
/// `d distort / d p` (2 × 2, row-major) and `d distort / d (k1, k2)` (rows x, y).
|
||||
pub fn jacobian(&self, p: [f64; 2]) -> ([[f64; 2]; 2], [[f64; 2]; 2]) {
|
||||
if self.radius <= 0.0 {
|
||||
return ([[1.0, 0.0], [0.0, 1.0]], [[0.0; 2]; 2]);
|
||||
}
|
||||
let r2 = self.rho2(p);
|
||||
let d = 1.0 + self.k1 * r2 + self.k2 * r2 * r2;
|
||||
// d(r2)/dp = 2 p / R².
|
||||
let dd = (self.k1 + 2.0 * self.k2 * r2) * 2.0 / (self.radius * self.radius);
|
||||
let j = [[d + p[0] * dd * p[0], p[0] * dd * p[1]], [p[1] * dd * p[0], d + p[1] * dd * p[1]]];
|
||||
let k = [[p[0] * r2, p[0] * r2 * r2], [p[1] * r2, p[1] * r2 * r2]];
|
||||
(j, k)
|
||||
}
|
||||
}
|
||||
|
||||
/// 3D Camera Tracker ▸ Shot Type.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -178,6 +248,8 @@ pub struct SolveSettings {
|
|||
pub method: SolveMethod,
|
||||
/// Track ids the user deleted.
|
||||
pub deleted: Vec<u32>,
|
||||
/// Solve radial lens distortion jointly with the cameras.
|
||||
pub lens_distortion: bool,
|
||||
}
|
||||
|
||||
/// The solved camera of one frame.
|
||||
|
|
@ -261,6 +333,9 @@ pub struct CameraSolve {
|
|||
pub points: Vec<SolvedPoint>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ground: Option<Ground>,
|
||||
/// The solved lens distortion (Solve Lens Distortion).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub distortion: Option<Distortion>,
|
||||
}
|
||||
|
||||
impl CameraSolve {
|
||||
|
|
@ -355,6 +430,36 @@ impl CameraSolve {
|
|||
}
|
||||
}
|
||||
|
||||
/// An ideal (pinhole) image point → where it appears in the footage (layer pixels), through
|
||||
/// the solved lens distortion.
|
||||
pub fn to_image(&self, p: [f64; 2]) -> [f64; 2] {
|
||||
match &self.distortion {
|
||||
Some(d) if !d.is_none() => {
|
||||
let c = [self.size[0] * 0.5, self.size[1] * 0.5];
|
||||
let q = d.distort([p[0] - c[0], p[1] - c[1]]);
|
||||
[q[0] + c[0], q[1] + c[1]]
|
||||
}
|
||||
_ => p,
|
||||
}
|
||||
}
|
||||
/// A footage point → its ideal (undistorted) position.
|
||||
pub fn to_ideal(&self, p: [f64; 2]) -> [f64; 2] {
|
||||
match &self.distortion {
|
||||
Some(d) if !d.is_none() => {
|
||||
let c = [self.size[0] * 0.5, self.size[1] * 0.5];
|
||||
let q = d.undistort([p[0] - c[0], p[1] - c[1]]);
|
||||
[q[0] + c[0], q[1] + c[1]]
|
||||
}
|
||||
_ => p,
|
||||
}
|
||||
}
|
||||
/// Project world point `x` on frame `k`: through the lens distortion into the footage, or
|
||||
/// (`undistorted`) into the undistorted image.
|
||||
pub fn project(&self, k: usize, x: V3, undistorted: bool) -> Option<[f64; 2]> {
|
||||
let q = self.frames.get(k)?.project(self.size, x)?;
|
||||
Some(if undistorted { q } else { self.to_image(q) })
|
||||
}
|
||||
|
||||
/// The first solved frame.
|
||||
pub fn first_solved(&self) -> Option<usize> {
|
||||
self.frames.iter().position(|c| c.solved)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use rayon::prelude::*;
|
|||
use super::bundle::{self, BaCam, BaObs, BaOpts, FocalMode};
|
||||
use super::geometry::{self, Pose};
|
||||
use super::linalg::*;
|
||||
use super::{CameraSolve, CameraTracks, ShotType, SolveMethod, SolveSettings, SolvedFrame, SolvedPoint};
|
||||
use super::{CameraSolve, CameraTracks, Distortion, ShotType, SolveMethod, SolveSettings, SolvedFrame, SolvedPoint};
|
||||
use crate::fit::{Model, ransac};
|
||||
|
||||
fn debug() -> bool {
|
||||
|
|
@ -142,6 +142,8 @@ struct Cfg {
|
|||
focal: FocalMode,
|
||||
/// Registration estimates a focal length per frame.
|
||||
per_frame_f: bool,
|
||||
/// The global adjustments also adjust the lens distortion.
|
||||
distortion: bool,
|
||||
}
|
||||
|
||||
const F_PRIOR: f64 = 30.0;
|
||||
|
|
@ -183,7 +185,7 @@ impl Recon {
|
|||
&mut cams,
|
||||
&mut pts,
|
||||
&obs,
|
||||
&BaOpts { focal, fix_points: false, fix_centers: cfg.kind == Kind::Tripod, fixed, huber: d.huber(), max_iter: iters },
|
||||
&BaOpts { focal, fix_points: false, fix_centers: cfg.kind == Kind::Tripod, fixed, huber: d.huber(), max_iter: iters, distortion: cfg.distortion },
|
||||
);
|
||||
for (k, c) in frames.iter().zip(&cams) {
|
||||
self.cams.insert(*k, *c);
|
||||
|
|
@ -289,6 +291,7 @@ impl Recon {
|
|||
fixed: vec![false],
|
||||
huber,
|
||||
max_iter: 40,
|
||||
distortion: false,
|
||||
};
|
||||
let mut best: Option<(usize, BaCam)> = None;
|
||||
// Robust start (large Huber threshold), then tighten; from the prediction and, failing
|
||||
|
|
@ -371,7 +374,11 @@ fn predict(r: &Recon, k: usize) -> Option<BaCam> {
|
|||
match (before, after) {
|
||||
(Some((ka, a)), Some((kb, b))) => {
|
||||
let t = (k - ka) as f64 / (kb - ka) as f64;
|
||||
Some(BaCam { pose: Pose { r: slerp(&a.pose.r, &b.pose.r, t), c: add(scale(a.pose.c, 1.0 - t), scale(b.pose.c, t)) }, f: a.f * (b.f / a.f).powf(t) })
|
||||
Some(BaCam {
|
||||
pose: Pose { r: slerp(&a.pose.r, &b.pose.r, t), c: add(scale(a.pose.c, 1.0 - t), scale(b.pose.c, t)) },
|
||||
f: a.f * (b.f / a.f).powf(t),
|
||||
dist: a.dist,
|
||||
})
|
||||
}
|
||||
(Some((ka, a)), None) => {
|
||||
// Constant velocity from the two previous cameras.
|
||||
|
|
@ -381,7 +388,7 @@ fn predict(r: &Recon, k: usize) -> Option<BaCam> {
|
|||
let dr = log_rot(&mm(&a.pose.r, &mt(&p.pose.r)));
|
||||
let rr = mm(&rodrigues(scale(dr, t.min(2.0))), &a.pose.r);
|
||||
let c = add(a.pose.c, scale(sub(a.pose.c, p.pose.c), t.min(2.0)));
|
||||
Some(BaCam { pose: Pose { r: rr, c }, f: a.f })
|
||||
Some(BaCam { pose: Pose { r: rr, c }, f: a.f, dist: a.dist })
|
||||
}
|
||||
None => Some(a),
|
||||
}
|
||||
|
|
@ -392,7 +399,7 @@ fn predict(r: &Recon, k: usize) -> Option<BaCam> {
|
|||
let dr = log_rot(&mm(&b.pose.r, &mt(&nx.pose.r)));
|
||||
let rr = mm(&rodrigues(scale(dr, t.min(2.0))), &b.pose.r);
|
||||
let c = add(b.pose.c, scale(sub(b.pose.c, nx.pose.c), t.min(2.0)));
|
||||
Some(BaCam { pose: Pose { r: rr, c }, f: b.f })
|
||||
Some(BaCam { pose: Pose { r: rr, c }, f: b.f, dist: b.dist })
|
||||
}
|
||||
None => Some(b),
|
||||
},
|
||||
|
|
@ -459,8 +466,8 @@ fn init_pair(d: &Data, a: usize, b: usize, f: f64, use_e: bool, use_h: bool) ->
|
|||
fn grow(d: &Data, kfs: &[usize], j: usize, init: &(Pose, HashMap<u32, V3>), f: f64, cfg: &Cfg, cancel: Option<&AtomicBool>) -> Option<Recon> {
|
||||
let (a, b) = (kfs[0], kfs[j]);
|
||||
let mut r = Recon::default();
|
||||
r.cams.insert(a, BaCam { pose: Pose::IDENTITY, f });
|
||||
r.cams.insert(b, BaCam { pose: init.0, f });
|
||||
r.cams.insert(a, BaCam { pose: Pose::IDENTITY, f, dist: Distortion::NONE });
|
||||
r.cams.insert(b, BaCam { pose: init.0, f, dist: Distortion::NONE });
|
||||
r.pts = init.1.clone();
|
||||
let inner = |r: &mut Recon, frames: &[usize]| {
|
||||
let focal = if cfg.per_frame_f && frames.len() > 2 { FocalMode::PerCamera(F_PRIOR) } else { FocalMode::Fixed };
|
||||
|
|
@ -495,7 +502,7 @@ fn grow(d: &Data, kfs: &[usize], j: usize, init: &(Pose, HashMap<u32, V3>), f: f
|
|||
/// Tripod reconstruction over keyframes `kfs` at focal length `f`.
|
||||
fn grow_tripod(d: &Data, kfs: &[usize], f: f64, cfg: &Cfg, cancel: Option<&AtomicBool>) -> Option<Recon> {
|
||||
let mut r = Recon::default();
|
||||
r.cams.insert(kfs[0], BaCam { pose: Pose::IDENTITY, f });
|
||||
r.cams.insert(kfs[0], BaCam { pose: Pose::IDENTITY, f, dist: Distortion::NONE });
|
||||
let ray = |p: [f64; 2], f: f64| normalize([p[0] / f, p[1] / f, 1.0]);
|
||||
for w in kfs.windows(2) {
|
||||
if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
|
||||
|
|
@ -519,7 +526,7 @@ fn grow_tripod(d: &Data, kfs: &[usize], f: f64, cfg: &Cfg, cancel: Option<&Atomi
|
|||
rel = geometry::procrustes(&ia, &ib)?;
|
||||
}
|
||||
let pa = r.cams[&a].pose.r;
|
||||
r.cams.insert(b, BaCam { pose: Pose { r: mm(&rel, &pa), c: [0.0; 3] }, f });
|
||||
r.cams.insert(b, BaCam { pose: Pose { r: mm(&rel, &pa), c: [0.0; 3] }, f, dist: Distortion::NONE });
|
||||
}
|
||||
r.triangulate(d, Kind::Tripod, 0.0, 20.0);
|
||||
let frames: Vec<usize> = r.cams.keys().copied().collect();
|
||||
|
|
@ -640,8 +647,101 @@ fn search_focal(w: f64, eval: &(dyn Fn(f64) -> f64 + Sync)) -> f64 {
|
|||
if best.1 <= errs[i] { best.0.exp() } else { fs[i] }
|
||||
}
|
||||
|
||||
/// A solve before its canonical output.
|
||||
struct Core {
|
||||
d: Data,
|
||||
r: Recon,
|
||||
method: SolveMethod,
|
||||
kind: Kind,
|
||||
cfg: Cfg,
|
||||
focal: FocalMode,
|
||||
kfs: Vec<usize>,
|
||||
}
|
||||
|
||||
/// `tracks` with every position undistorted by `dist`.
|
||||
fn undistorted(tracks: &CameraTracks, dist: &Distortion) -> CameraTracks {
|
||||
let c = [tracks.size[0] * 0.5, tracks.size[1] * 0.5];
|
||||
let mut t = tracks.clone();
|
||||
for tr in t.tracks.iter_mut() {
|
||||
for p in tr.pts.iter_mut() {
|
||||
let q = dist.undistort([p[0] as f64 - c[0], p[1] as f64 - c[1]]);
|
||||
*p = [(q[0] + c[0]) as f32, (q[1] + c[1]) as f32];
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Solve the camera of a tracked clip.
|
||||
pub fn solve(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBool>) -> Result<CameraSolve, SolveError> {
|
||||
if !s.lens_distortion {
|
||||
let c = solve_core(tracks, s, cancel)?;
|
||||
return Ok(finish(&c.d, tracks, s, c.r, c.method, c.kind));
|
||||
}
|
||||
// Lens distortion: solve, adjust (k1, k2) jointly on the raw tracks, then solve again on
|
||||
// tracks undistorted with the estimate and adjust jointly once more.
|
||||
let mut dist = Distortion::for_size(tracks.size);
|
||||
let mut out = None;
|
||||
for pass in 0..2 {
|
||||
let und = (pass > 0).then(|| undistorted(tracks, &dist));
|
||||
let c = solve_core(und.as_ref().unwrap_or(tracks), s, cancel)?;
|
||||
let d = Data::new(tracks, &s.deleted);
|
||||
d.set_noise(c.d.noise());
|
||||
let mut r = c.r;
|
||||
for cam in r.cams.values_mut() {
|
||||
cam.dist = dist;
|
||||
}
|
||||
let cfg = Cfg { distortion: true, ..c.cfg };
|
||||
let all: Vec<usize> = r.cams.keys().copied().collect();
|
||||
let frames: Vec<usize> = if all.len() <= 160 { all.clone() } else { c.kfs.iter().copied().filter(|k| r.cams.contains_key(k)).collect() };
|
||||
if frames.len() < 2 {
|
||||
return err("the camera could not be solved (not enough parallax or features)");
|
||||
}
|
||||
// The pinhole solve's outliers include the distorted frame edges: start from all
|
||||
// observations (the Huber loss keeps real outliers in check), then reject again.
|
||||
r.outliers.clear();
|
||||
r.adjust(&d, &frames, &cfg, c.focal, 30);
|
||||
r.outliers.clear();
|
||||
r.reject(&d);
|
||||
r.adjust(&d, &frames, &cfg, c.focal, 30);
|
||||
dist = r.cams[&frames[0]].dist;
|
||||
for cam in r.cams.values_mut() {
|
||||
cam.dist = dist;
|
||||
}
|
||||
if frames.len() < all.len() {
|
||||
// Refine the other frames against the adjusted points and distortion.
|
||||
let base = r.clone();
|
||||
let rcfg = Cfg { distortion: false, ..cfg };
|
||||
let refined: Vec<(usize, Option<BaCam>)> = all
|
||||
.par_iter()
|
||||
.filter(|k| !frames.contains(k))
|
||||
.map(|k| {
|
||||
let mut local = Recon { cams: base.cams.clone(), pts: base.pts.clone(), outliers: HashSet::new() };
|
||||
let start = local.cams[k];
|
||||
let ok = local.register(&d, *k, start, &rcfg);
|
||||
(*k, if ok { local.cams.get(k).copied() } else { None })
|
||||
})
|
||||
.collect();
|
||||
for (k, cam) in refined {
|
||||
if let Some(cam) = cam {
|
||||
r.cams.insert(k, cam);
|
||||
}
|
||||
}
|
||||
r.reject(&d);
|
||||
}
|
||||
if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
|
||||
return err("cancelled");
|
||||
}
|
||||
if debug() {
|
||||
eprintln!("lens distortion pass {pass}: k1 {:.5} k2 {:.5}", dist.k1, dist.k2);
|
||||
}
|
||||
let mut sol = finish(&d, tracks, s, r, c.method, c.kind);
|
||||
sol.distortion = Some(dist);
|
||||
out = Some(sol);
|
||||
}
|
||||
out.ok_or_else(|| SolveError("the camera could not be solved".into()))
|
||||
}
|
||||
|
||||
fn solve_core(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBool>) -> Result<Core, SolveError> {
|
||||
let d = Data::new(tracks, &s.deleted);
|
||||
if d.n < 2 {
|
||||
return err("the clip must be at least two frames long");
|
||||
|
|
@ -653,7 +753,7 @@ pub fn solve(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBoo
|
|||
let cancelled = || cancel.is_some_and(|c| c.load(Ordering::Relaxed));
|
||||
let fixed_f = (s.shot == ShotType::SpecifyAngle).then(|| focal_of(d.w, if s.hfov > 0.0 { s.hfov } else { 40.0 }));
|
||||
let variable = s.shot == ShotType::VariableZoom;
|
||||
let gcfg = Cfg { kind: Kind::General, focal: FocalMode::Fixed, per_frame_f: false };
|
||||
let gcfg = Cfg { kind: Kind::General, focal: FocalMode::Fixed, per_frame_f: false, distortion: false };
|
||||
let tcfg = Cfg { kind: Kind::Tripod, ..gcfg };
|
||||
// The focal-length search runs on the first keyframes only.
|
||||
let mini: Vec<usize> = kfs.iter().copied().take(10).collect();
|
||||
|
|
@ -738,7 +838,7 @@ pub fn solve(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBoo
|
|||
}
|
||||
let (att, _) = results.swap_remove(pick);
|
||||
let kind = if att.method == SolveMethod::TripodPan { Kind::Tripod } else { Kind::General };
|
||||
let cfg = Cfg { kind, focal: FocalMode::Fixed, per_frame_f: variable };
|
||||
let cfg = Cfg { kind, focal: FocalMode::Fixed, per_frame_f: variable, distortion: false };
|
||||
let mut r = att.recon;
|
||||
let focal = match (fixed_f, variable) {
|
||||
(Some(_), _) => FocalMode::Fixed,
|
||||
|
|
@ -845,7 +945,7 @@ pub fn solve(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBoo
|
|||
(m, _) => m,
|
||||
};
|
||||
lap("final adjustment");
|
||||
Ok(finish(&d, tracks, s, r, method, kind))
|
||||
Ok(Core { d, r, method, kind, cfg, focal, kfs: kf_frames })
|
||||
}
|
||||
|
||||
/// Thickness of a point cloud: √(smallest / middle eigenvalue of its covariance).
|
||||
|
|
@ -949,5 +1049,6 @@ fn finish(d: &Data, tracks: &CameraTracks, s: &SolveSettings, r: Recon, method:
|
|||
frames,
|
||||
points,
|
||||
ground: None,
|
||||
distortion: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ fn path_error(s: &CameraSolve, t: &Truth) -> (f64, f64) {
|
|||
}
|
||||
|
||||
fn settings(shot: ShotType, method: SolveMethod) -> SolveSettings {
|
||||
SolveSettings { shot, hfov: 0.0, method, deleted: vec![] }
|
||||
SolveSettings { shot, hfov: 0.0, method, deleted: vec![], lens_distortion: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -149,7 +149,8 @@ fn specified_angle_of_view_is_kept() {
|
|||
let pts = random_points(300, 5, [0.0, 0.0, 10.0], [14.0, 8.0, 8.0]);
|
||||
let tr = tracks_of(&pts, &t, 6, true);
|
||||
let hfov = 2.0 * (W * 0.5 / f).atan().to_degrees();
|
||||
let s = solve(&tr, &SolveSettings { shot: ShotType::SpecifyAngle, hfov, method: SolveMethod::Typical, deleted: vec![] }, None).unwrap();
|
||||
let s =
|
||||
solve(&tr, &SolveSettings { shot: ShotType::SpecifyAngle, hfov, method: SolveMethod::Typical, deleted: vec![], lens_distortion: false }, None).unwrap();
|
||||
assert!((s.focal() - f).abs() < 1e-6);
|
||||
assert!(s.average_error < 0.5);
|
||||
let (pe, _) = path_error(&s, &t);
|
||||
|
|
@ -226,7 +227,9 @@ fn mostly_flat_scene_and_ground_plane() {
|
|||
}
|
||||
// Ground plane from the solved (coplanar) points: every point lies on it.
|
||||
let hfov = 2.0 * (W * 0.5 / f).atan().to_degrees();
|
||||
let mut s = solve(&tr, &SolveSettings { shot: ShotType::SpecifyAngle, hfov, method: SolveMethod::MostlyFlat, deleted: vec![] }, None).expect("solve");
|
||||
let mut s =
|
||||
solve(&tr, &SolveSettings { shot: ShotType::SpecifyAngle, hfov, method: SolveMethod::MostlyFlat, deleted: vec![], lens_distortion: false }, None)
|
||||
.expect("solve");
|
||||
let ids: Vec<u32> = s.visible(0).map(|p| p.id).take(12).collect();
|
||||
let g = s.ground_from(&ids, 0).expect("ground");
|
||||
let spread = s.points.iter().map(|p| dot(sub(p.pos, g.origin), g.normal).abs()).fold(0.0, f64::max);
|
||||
|
|
@ -251,7 +254,9 @@ fn deleted_tracks_are_ignored_and_cancel_stops() {
|
|||
let tr = tracks_of(&pts, &t, 14, true);
|
||||
let deleted: Vec<u32> = (0..40).collect();
|
||||
let hfov = 2.0 * (W * 0.5 / 1000.0f64).atan().to_degrees();
|
||||
let s = solve(&tr, &SolveSettings { shot: ShotType::SpecifyAngle, hfov, method: SolveMethod::Typical, deleted: deleted.clone() }, None).unwrap();
|
||||
let s =
|
||||
solve(&tr, &SolveSettings { shot: ShotType::SpecifyAngle, hfov, method: SolveMethod::Typical, deleted: deleted.clone(), lens_distortion: false }, None)
|
||||
.unwrap();
|
||||
assert!(s.points.iter().all(|p| !deleted.contains(&p.id)));
|
||||
let stop = std::sync::atomic::AtomicBool::new(true);
|
||||
let r = solve(&tr, &settings(ShotType::FixedAngle, SolveMethod::Typical), Some(&stop));
|
||||
|
|
@ -436,3 +441,55 @@ fn solve_saved_tracks() {
|
|||
t0.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
/// Pass every track position through a lens distortion (around the frame centre).
|
||||
fn distort_tracks(t: &CameraTracks, d: &Distortion) -> CameraTracks {
|
||||
let mut t = t.clone();
|
||||
for tr in t.tracks.iter_mut() {
|
||||
for p in tr.pts.iter_mut() {
|
||||
let q = d.distort([p[0] as f64 - W / 2.0, p[1] as f64 - H / 2.0]);
|
||||
*p = [(q[0] + W / 2.0) as f32, (q[1] + H / 2.0) as f32];
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lens_distortion_is_recovered_in_the_bundle_adjustment() {
|
||||
let f = 1000.0;
|
||||
let t = moving_camera(36, |_| f);
|
||||
let pts = random_points(450, 7, [0.0, 0.0, 10.0], [16.0, 9.0, 8.0]);
|
||||
let truth = Distortion { k1: -0.12, k2: 0.03, radius: 0.5 * W.hypot(H) };
|
||||
let tr = distort_tracks(&tracks_of(&pts, &t, 8, true), &truth);
|
||||
let plain = solve(&tr, &settings(ShotType::FixedAngle, SolveMethod::Typical), None).expect("pinhole solve");
|
||||
let lens = solve(&tr, &SolveSettings { lens_distortion: true, ..settings(ShotType::FixedAngle, SolveMethod::Typical) }, None).expect("lens solve");
|
||||
let d = lens.distortion.expect("distortion solved");
|
||||
eprintln!(
|
||||
"pinhole error {:.3} px (focal {:.1}); with distortion {:.3} px (focal {:.1}), k1 {:.4} k2 {:.4}",
|
||||
plain.average_error,
|
||||
plain.focal(),
|
||||
lens.average_error,
|
||||
lens.focal(),
|
||||
d.k1,
|
||||
d.k2
|
||||
);
|
||||
assert!(plain.distortion.is_none());
|
||||
assert!((d.k1 - truth.k1).abs() < 0.005, "k1 {}", d.k1);
|
||||
assert!((d.k2 - truth.k2).abs() < 0.01, "k2 {}", d.k2);
|
||||
// The distortion model explains the tracks: about the noise level, far below the pinhole fit.
|
||||
assert!(lens.average_error < 0.3, "{}", lens.average_error);
|
||||
assert!(lens.average_error < 0.5 * plain.average_error, "{} vs {}", lens.average_error, plain.average_error);
|
||||
assert!((lens.focal() - f).abs() < 0.02 * f, "focal {}", lens.focal());
|
||||
let (path, rot) = path_error(&lens, &t);
|
||||
assert!(path < 0.01 && rot < 0.3, "path {path} rot {rot}");
|
||||
// Projection through the lens lands on the (distorted) tracks; undistorting inverts it.
|
||||
let p = [100.0, 80.0];
|
||||
let q = lens.to_image(p);
|
||||
let back = lens.to_ideal(q);
|
||||
assert!((back[0] - p[0]).abs() < 1e-6 && (back[1] - p[1]).abs() < 1e-6);
|
||||
let sp = &lens.points[0];
|
||||
let k = sp.first as usize;
|
||||
let obs = tr.track(sp.id).and_then(|t| t.at(k as u32)).expect("observed");
|
||||
let proj = lens.project(k, sp.pos, false).expect("in front");
|
||||
assert!((proj[0] - obs[0]).hypot(proj[1] - obs[1]) < 2.0, "{proj:?} vs {obs:?}");
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -19,6 +19,7 @@
|
|||
//! after subtracting the frame's `offset` (see [`Frame`]).
|
||||
|
||||
pub mod camtrack;
|
||||
pub mod face;
|
||||
pub mod fit;
|
||||
pub mod klt;
|
||||
pub mod mask;
|
||||
|
|
@ -26,6 +27,7 @@ pub mod plane;
|
|||
pub mod roto;
|
||||
pub mod solve;
|
||||
pub mod stabilize;
|
||||
pub mod subspace;
|
||||
|
||||
use effectcraft_raster::Image;
|
||||
use rayon::prelude::*;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ pub fn solve_linear<const N: usize>(mut a: [[f64; N]; N], mut b: [f64; N]) -> Op
|
|||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Homography(pub [[f64; 3]; 3]);
|
||||
|
||||
impl Default for Homography {
|
||||
fn default() -> Self {
|
||||
Homography::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
impl Homography {
|
||||
pub const IDENTITY: Homography = Homography([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,14 +18,23 @@
|
|||
//! Video Stabilization with Robust L1 Optimal Camera Paths" (CVPR 2011), applied here
|
||||
//! per frame rather than through a linear program.
|
||||
//!
|
||||
//!
|
||||
//! *Subspace Warp* additionally follows long feature trajectories through the clip
|
||||
//! ([`crate::camtrack::TrackAnalyzer`]) and stabilises each frame with a content-preserving mesh
|
||||
//! warp fitted to the subspace-smoothed trajectories ([`crate::subspace`]): scenes with depth
|
||||
//! (parallax) that one homography per frame cannot hold still are stabilised locally. The
|
||||
//! framing (crop, auto-scale, Maximum Scale relaxation) works the same on meshes.
|
||||
//!
|
||||
//! Coordinates are layer pixels; transforms map *source* frame pixels to *stabilized* pixels.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Frame;
|
||||
use crate::fit::{Model, ransac};
|
||||
use crate::camtrack::{Track2D, TrackAnalyzer};
|
||||
use crate::fit::{Model, least_squares, ransac};
|
||||
use crate::klt::{CornerOpts, GrayPyramid, LkOpts, analysis_factor, good_features, track};
|
||||
use crate::solve::Homography;
|
||||
use crate::subspace::{Mesh, MeshOpts, smooth_trajectories, solve_mesh};
|
||||
|
||||
/// Inter-frame motion of one frame (from the previous frame to this one) for each model.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -82,6 +91,10 @@ pub struct WarpAnalysis {
|
|||
pub detailed: bool,
|
||||
/// Per frame; frame 0's motion is the identity.
|
||||
pub frames: Vec<FrameMotion>,
|
||||
/// Long feature trajectories (layer pixels, 0.1 px) for Subspace Warp; empty in analyses
|
||||
/// made before it (which then stabilise like Perspective).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tracks: Vec<Track2D>,
|
||||
}
|
||||
|
||||
impl WarpAnalysis {
|
||||
|
|
@ -120,6 +133,7 @@ pub struct Analyzer {
|
|||
prev: Option<GrayPyramid>,
|
||||
frames: Vec<FrameMotion>,
|
||||
size: [f64; 2],
|
||||
trajectories: TrackAnalyzer,
|
||||
}
|
||||
|
||||
fn round_pts(p: &[[f64; 2]], n: usize) -> Vec<[f32; 2]> {
|
||||
|
|
@ -129,7 +143,8 @@ fn round_pts(p: &[[f64; 2]], n: usize) -> Vec<[f32; 2]> {
|
|||
|
||||
impl Analyzer {
|
||||
pub fn new(size: [f64; 2], opts: AnalyzeOpts) -> Analyzer {
|
||||
Analyzer { opts, prev: None, frames: vec![], size }
|
||||
let (side, want) = if opts.detailed { (640, 260) } else { (480, 160) };
|
||||
Analyzer { opts, prev: None, frames: vec![], size, trajectories: TrackAnalyzer::with_budget(size, side, want) }
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
|
|
@ -144,6 +159,7 @@ impl Analyzer {
|
|||
let (max_side, max_feat) = if self.opts.detailed { (960, 700) } else { (480, 350) };
|
||||
let factor = analysis_factor(frame.img.width, frame.img.height, max_side);
|
||||
let pyr = GrayPyramid::from_image(frame.img, frame.offset, factor, 3);
|
||||
self.trajectories.push(frame);
|
||||
let mut fm = FrameMotion::default();
|
||||
if let Some(prev) = &self.prev
|
||||
&& prev.width() == pyr.width()
|
||||
|
|
@ -184,7 +200,17 @@ impl Analyzer {
|
|||
}
|
||||
|
||||
pub fn finish(self, start: f64, frame_duration: f64) -> WarpAnalysis {
|
||||
WarpAnalysis { version: 1, start, frame_duration, size: self.size, detailed: self.opts.detailed, frames: self.frames }
|
||||
let mut tracks = self.trajectories.finish(start, frame_duration).tracks;
|
||||
// Short trajectories hardly constrain the subspace: keep those of at least 6 frames, at
|
||||
// 0.1 px (smaller JSON).
|
||||
tracks.retain(|t| t.pts.len() >= 6);
|
||||
for (i, t) in tracks.iter_mut().enumerate() {
|
||||
t.id = i as u32;
|
||||
for p in t.pts.iter_mut() {
|
||||
*p = [(p[0] * 10.0).round() / 10.0, (p[1] * 10.0).round() / 10.0];
|
||||
}
|
||||
}
|
||||
WarpAnalysis { version: 2, start, frame_duration, size: self.size, detailed: self.opts.detailed, frames: self.frames, tracks }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -205,11 +231,31 @@ pub enum Method {
|
|||
/// Position, Scale, Rotation.
|
||||
Similarity,
|
||||
Perspective,
|
||||
/// Approximated by Perspective (see the module docs of the effect).
|
||||
/// Content-preserving mesh warps fitted to subspace-smoothed trajectories.
|
||||
#[default]
|
||||
SubspaceWarp,
|
||||
}
|
||||
|
||||
/// Advanced ▸ Rolling Shutter Ripple: how freely Subspace Warp's mesh may bend row by row (a
|
||||
/// rolling-shutter camera exposes rows at different times, so shake wobbles the frame).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Ripple {
|
||||
/// A 12 × 8 mesh.
|
||||
#[default]
|
||||
Automatic,
|
||||
/// Twice the rows and a softer shape term: follows row-wise wobble more closely.
|
||||
Enhanced,
|
||||
}
|
||||
|
||||
impl Ripple {
|
||||
pub fn mesh(self) -> MeshOpts {
|
||||
match self {
|
||||
Ripple::Automatic => MeshOpts { cols: 12, rows: 8, alpha: 1.0, prior: 0.02 },
|
||||
Ripple::Enhanced => MeshOpts { cols: 12, rows: 16, alpha: 0.5, prior: 0.02 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Borders ▸ Framing.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Framing {
|
||||
|
|
@ -239,6 +285,7 @@ pub struct StabSettings {
|
|||
pub crop_less_smooth_more: f64,
|
||||
/// Frames per second (for Smoothness).
|
||||
pub fps: f64,
|
||||
pub ripple: Ripple,
|
||||
}
|
||||
|
||||
impl Default for StabSettings {
|
||||
|
|
@ -254,6 +301,7 @@ impl Default for StabSettings {
|
|||
additional_scale: 100.0,
|
||||
crop_less_smooth_more: 0.0,
|
||||
fps: 30.0,
|
||||
ripple: Ripple::Automatic,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -269,8 +317,15 @@ impl StabSettings {
|
|||
/// The per-frame stabilization of a clip.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Plan {
|
||||
/// Final source → output transform per frame (layer pixels), framing scale included.
|
||||
/// Final source → output transform per frame (layer pixels), framing scale included. For
|
||||
/// mesh warps, the closest homography (for overlays and edge synthesis).
|
||||
pub warps: Vec<Homography>,
|
||||
/// Subspace Warp: per frame, the mesh (stabilised pixels before framing → source pixels);
|
||||
/// empty for the other methods.
|
||||
pub meshes: Vec<Mesh>,
|
||||
/// The framing transform (auto-scale and Additional Scale about the centre) after the
|
||||
/// stabilisation.
|
||||
pub framing: Homography,
|
||||
/// The visible region in output pixels `[x0, y0, x1, y1]` (Stabilize, Crop / Auto-scale);
|
||||
/// `None` = everything the warp produces.
|
||||
pub crop: Option<[f64; 4]>,
|
||||
|
|
@ -280,6 +335,24 @@ pub struct Plan {
|
|||
pub valid_fraction: f64,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// The source position (frame `k`'s layer pixels) shown at output point `p`.
|
||||
pub fn source_of(&self, k: usize, p: [f64; 2]) -> Option<[f64; 2]> {
|
||||
if let Some(m) = self.meshes.get(k) {
|
||||
let inv = self.framing.inverse()?;
|
||||
return Some(m.sample(inv.apply(p)));
|
||||
}
|
||||
Some(self.warps.get(k)?.inverse()?.apply(p))
|
||||
}
|
||||
/// Where source point `q` of frame `k` lands in the output.
|
||||
pub fn output_of(&self, k: usize, q: [f64; 2]) -> Option<[f64; 2]> {
|
||||
if let Some(m) = self.meshes.get(k) {
|
||||
return m.forward(q).map(|p| self.framing.apply(p));
|
||||
}
|
||||
Some(self.warps.get(k)?.apply(q))
|
||||
}
|
||||
}
|
||||
|
||||
fn convex_contains(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool {
|
||||
let mut sign = 0.0;
|
||||
for i in 0..4 {
|
||||
|
|
@ -418,14 +491,100 @@ pub fn corrections(a: &WarpAnalysis, s: &StabSettings) -> Vec<Homography> {
|
|||
out
|
||||
}
|
||||
|
||||
/// One frame's stabilisation before framing.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Warp {
|
||||
H(Homography),
|
||||
M(Mesh),
|
||||
}
|
||||
|
||||
impl Warp {
|
||||
fn valid(&self, size: [f64; 2]) -> f64 {
|
||||
match self {
|
||||
Warp::H(h) => valid_fraction(h, size),
|
||||
Warp::M(m) => m.valid_fraction(),
|
||||
}
|
||||
}
|
||||
/// Blend towards the identity (`l` = 1 keeps the warp).
|
||||
fn relax(&self, l: f64) -> Warp {
|
||||
match self {
|
||||
Warp::H(h) => Warp::H(Homography::IDENTITY.lerp(h, l)),
|
||||
Warp::M(m) => Warp::M(m.lerp_identity(l)),
|
||||
}
|
||||
}
|
||||
fn homography(&self) -> Homography {
|
||||
match self {
|
||||
Warp::H(h) => *h,
|
||||
Warp::M(m) => m.homography(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the uniform scale of the correspondences' best similarity (Preserve Scale): the
|
||||
/// wanted positions are scaled back about their centroid.
|
||||
fn unscale(pairs: &mut [([f64; 2], [f64; 2])]) {
|
||||
let (src, dst): (Vec<[f64; 2]>, Vec<[f64; 2]>) = pairs.iter().copied().unzip();
|
||||
let Some(h) = least_squares(Model::Similarity, &src, &dst) else { return };
|
||||
let m = h.0;
|
||||
let k = (m[0][0] * m[1][1] - m[0][1] * m[1][0]).abs().sqrt();
|
||||
if k < 1e-6 {
|
||||
return;
|
||||
}
|
||||
let n = dst.len() as f64;
|
||||
let c = [dst.iter().map(|q| q[0]).sum::<f64>() / n, dst.iter().map(|q| q[1]).sum::<f64>() / n];
|
||||
for (_, d) in pairs.iter_mut() {
|
||||
*d = [c[0] + (d[0] - c[0]) / k, c[1] + (d[1] - c[1]) / k];
|
||||
}
|
||||
}
|
||||
|
||||
/// Subspace Warp's meshes (stabilised → source, before framing) for every frame; `None` for the
|
||||
/// other methods and for analyses without trajectories. `base`: the perspective corrections
|
||||
/// ([`corrections`]), the meshes' prior.
|
||||
pub fn meshes(a: &WarpAnalysis, s: &StabSettings, base: &[Homography]) -> Option<Vec<Mesh>> {
|
||||
if s.method != Method::SubspaceWarp || a.tracks.is_empty() || base.len() != a.frames.len() {
|
||||
return None;
|
||||
}
|
||||
let n = a.frames.len();
|
||||
let o = s.ripple.mesh();
|
||||
let size = a.size;
|
||||
let pairs: Vec<Vec<([f64; 2], [f64; 2])>> = match s.result {
|
||||
StabResult::NoMotion => (0..n).map(|t| a.tracks.iter().filter_map(|tr| tr.at(t as u32)).map(|p| (p, base[t].apply(p))).collect()).collect(),
|
||||
StabResult::SmoothMotion => {
|
||||
let sigma = s.sigma();
|
||||
if sigma < 0.3 {
|
||||
return Some((0..n).map(|_| Mesh::identity(o.cols, o.rows, size)).collect());
|
||||
}
|
||||
smooth_trajectories(&a.tracks, n, sigma, 9)
|
||||
}
|
||||
};
|
||||
use rayon::prelude::*;
|
||||
Some(
|
||||
pairs
|
||||
.into_par_iter()
|
||||
.enumerate()
|
||||
.map(|(t, mut pr)| {
|
||||
if s.preserve_scale && pr.len() >= 2 {
|
||||
unscale(&mut pr);
|
||||
}
|
||||
if pr.len() < 6 { Mesh::from_homography(o.cols, o.rows, size, &base[t]) } else { solve_mesh(size, &o, &pr, &base[t]) }
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Stabilization plan: corrections plus framing.
|
||||
pub fn plan(a: &WarpAnalysis, s: &StabSettings) -> Plan {
|
||||
let mut b = corrections(a, s);
|
||||
use rayon::prelude::*;
|
||||
let base = corrections(a, s);
|
||||
let mut b: Vec<Warp> = match meshes(a, s, &base) {
|
||||
Some(m) => m.into_iter().map(Warp::M).collect(),
|
||||
None => base.into_iter().map(Warp::H).collect(),
|
||||
};
|
||||
let size = a.size;
|
||||
let c = [size[0] / 2.0, size[1] / 2.0];
|
||||
let add = (s.additional_scale / 100.0).max(0.01);
|
||||
let z_add = Homography::scale_about(add, c);
|
||||
let fracs = |b: &[Homography]| b.iter().map(|h| valid_fraction(h, size)).collect::<Vec<f64>>();
|
||||
let fracs = |b: &[Warp]| b.par_iter().map(|w| w.valid(size)).collect::<Vec<f64>>();
|
||||
let mut fr = fracs(&b);
|
||||
let mut vmin = fr.iter().copied().fold(1.0, f64::min);
|
||||
let margin = (s.action_safe / 100.0).clamp(0.0, 0.45);
|
||||
|
|
@ -435,22 +594,24 @@ pub fn plan(a: &WarpAnalysis, s: &StabSettings) -> Plan {
|
|||
// Relax the correction on frames that need more than Maximum Scale.
|
||||
let target = need_cover / max_scale;
|
||||
let n = b.len();
|
||||
let mut lam = vec![1.0f64; n];
|
||||
for t in 0..n {
|
||||
if fr[t] >= target {
|
||||
continue;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0, 1.0);
|
||||
for _ in 0..24 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if valid_fraction(&Homography::IDENTITY.lerp(&b[t], mid), size) >= target {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
let lam: Vec<f64> = (0..n)
|
||||
.into_par_iter()
|
||||
.map(|t| {
|
||||
if fr[t] >= target {
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
lam[t] = lo;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0, 1.0);
|
||||
for _ in 0..24 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if b[t].relax(mid).valid(size) >= target {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo
|
||||
})
|
||||
.collect();
|
||||
// Spread the relaxation smoothly (min filter, then a Gaussian of the same radius) so
|
||||
// the camera doesn't jump.
|
||||
let r = ((s.sigma() * 0.5).ceil() as usize).max(1);
|
||||
|
|
@ -468,7 +629,7 @@ pub fn plan(a: &WarpAnalysis, s: &StabSettings) -> Plan {
|
|||
(acc / ws).min(lam[t])
|
||||
})
|
||||
.collect();
|
||||
b = b.iter().zip(&smooth).map(|(h, l)| if *l >= 1.0 { *h } else { Homography::IDENTITY.lerp(h, *l) }).collect();
|
||||
b = b.iter().zip(&smooth).map(|(w, l)| if *l >= 1.0 { w.clone() } else { w.relax(*l) }).collect();
|
||||
fr = fracs(&b);
|
||||
vmin = fr.iter().copied().fold(1.0, f64::min);
|
||||
}
|
||||
|
|
@ -483,13 +644,23 @@ pub fn plan(a: &WarpAnalysis, s: &StabSettings) -> Plan {
|
|||
}
|
||||
};
|
||||
let z = Homography::scale_about(auto, c);
|
||||
let warps = b.iter().map(|h| z_add.then_after(&z.then_after(h))).collect();
|
||||
Plan { warps, crop, auto_scale: auto, valid_fraction: vmin }
|
||||
let framing = z_add.then_after(&z);
|
||||
let warps = b.par_iter().map(|w| framing.then_after(&w.homography())).collect();
|
||||
let meshes = b
|
||||
.into_iter()
|
||||
.filter_map(|w| match w {
|
||||
Warp::M(m) => Some(m),
|
||||
Warp::H(_) => None,
|
||||
})
|
||||
.collect();
|
||||
Plan { warps, meshes, framing, crop, auto_scale: auto, valid_fraction: vmin }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_raster::Image;
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// A synthetic analysis: a slow pan plus per-frame jitter.
|
||||
fn shaky(n: usize) -> (WarpAnalysis, Vec<[f64; 2]>) {
|
||||
|
|
@ -497,7 +668,7 @@ mod tests {
|
|||
let j = [((k * 7919) % 13) as f64 - 6.0, ((k * 104729) % 11) as f64 - 5.0];
|
||||
[2.0 * k as f64 + j[0], 0.5 * k as f64 + j[1]]
|
||||
};
|
||||
let mut a = WarpAnalysis { version: 1, start: 0.0, frame_duration: 1.0 / 30.0, size: [640.0, 360.0], detailed: false, frames: vec![] };
|
||||
let mut a = WarpAnalysis { version: 1, start: 0.0, frame_duration: 1.0 / 30.0, size: [640.0, 360.0], detailed: false, frames: vec![], tracks: vec![] };
|
||||
let path: Vec<[f64; 2]> = (0..n).map(pos).collect();
|
||||
for k in 0..n {
|
||||
let mut f = FrameMotion::default();
|
||||
|
|
@ -573,4 +744,139 @@ mod tests {
|
|||
let j = WarpAnalysis::from_json(&a.to_json()).unwrap();
|
||||
assert_eq!(j, a);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ synthetic shaky footage
|
||||
|
||||
fn hash(i: i64, j: i64, k: i64) -> f64 {
|
||||
let mut v = (i.wrapping_mul(73_856_093) ^ j.wrapping_mul(19_349_663) ^ k.wrapping_mul(83_492_791)) as u64;
|
||||
v ^= v >> 13;
|
||||
v = v.wrapping_mul(0x5bd1_e995);
|
||||
v ^= v >> 15;
|
||||
(v % 10_000) as f64 / 10_000.0
|
||||
}
|
||||
|
||||
fn value_noise(x: f64, y: f64, cell: f64, seed: i64) -> f64 {
|
||||
let (fx, fy) = (x / cell, y / cell);
|
||||
let (i, j) = (fx.floor() as i64, fy.floor() as i64);
|
||||
let (u, v) = (fx - i as f64, fy - j as f64);
|
||||
let (u, v) = (u * u * (3.0 - 2.0 * u), v * v * (3.0 - 2.0 * v));
|
||||
let a = hash(i, j, seed) * (1.0 - u) + hash(i + 1, j, seed) * u;
|
||||
let b = hash(i, j + 1, seed) * (1.0 - u) + hash(i + 1, j + 1, seed) * u;
|
||||
a * (1.0 - v) + b * v
|
||||
}
|
||||
|
||||
fn texture(x: f64, y: f64) -> f32 {
|
||||
(0.55 * value_noise(x, y, 5.0, 1) + 0.45 * value_noise(x, y, 13.0, 2)) as f32
|
||||
}
|
||||
|
||||
/// A scene with depth: a near bump in the middle moves `1 + depth` times as much as the
|
||||
/// background when the camera shakes (parallax a homography cannot model).
|
||||
struct Scene {
|
||||
size: [f64; 2],
|
||||
cams: Vec<[f64; 2]>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
fn new(n: usize) -> Scene {
|
||||
let cams = (0..n)
|
||||
.map(|t| {
|
||||
let j = [5.0 * (hash(t as i64, 7, 3) - 0.5), 5.0 * (hash(t as i64, 11, 5) - 0.5)];
|
||||
[0.4 * t as f64 + j[0], 0.15 * t as f64 + j[1]]
|
||||
})
|
||||
.collect();
|
||||
Scene { size: [320.0, 180.0], cams }
|
||||
}
|
||||
fn depth(&self, s: [f64; 2]) -> f64 {
|
||||
1.0 + 1.2 * (-((s[0] - 160.0).powi(2) + (s[1] - 90.0).powi(2)) / (2.0 * 38.0f64.powi(2))).exp()
|
||||
}
|
||||
/// Where scene point `s` appears on frame `t`.
|
||||
fn image_of(&self, t: usize, s: [f64; 2]) -> [f64; 2] {
|
||||
let (c, d) = (self.cams[t], self.depth(s));
|
||||
[s[0] + c[0] * d, s[1] + c[1] * d]
|
||||
}
|
||||
fn frame(&self, t: usize) -> Image {
|
||||
let (w, h) = (self.size[0] as u32, self.size[1] as u32);
|
||||
let mut im = Image::new(w, h);
|
||||
let c = self.cams[t];
|
||||
im.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let p = [x as f64 + 0.5, y as f64 + 0.5];
|
||||
let mut s = p;
|
||||
for _ in 0..10 {
|
||||
let d = self.depth(s);
|
||||
s = [p[0] - c[0] * d, p[1] - c[1] * d];
|
||||
}
|
||||
let v = texture(s[0], s[1]);
|
||||
*px = [v, v, v, 1.0];
|
||||
}
|
||||
});
|
||||
im
|
||||
}
|
||||
}
|
||||
|
||||
fn residual_jitter(scene: &Scene, out: impl Fn(usize, [f64; 2]) -> Option<[f64; 2]>) -> f64 {
|
||||
let n = scene.cams.len();
|
||||
let mut tot = 0.0;
|
||||
let mut cnt = 0;
|
||||
for gy in 0..5 {
|
||||
for gx in 0..7 {
|
||||
let s = [70.0 + gx as f64 * 30.0, 45.0 + gy as f64 * 22.0];
|
||||
let ys: Vec<Option<[f64; 2]>> = (0..n).map(|t| out(t, scene.image_of(t, s))).collect();
|
||||
for t in 6..n - 6 {
|
||||
if let (Some(a), Some(b), Some(c)) = (ys[t - 1], ys[t], ys[t + 1]) {
|
||||
tot += (c[0] - 2.0 * b[0] + a[0]).hypot(c[1] - 2.0 * b[1] + a[1]);
|
||||
cnt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tot / cnt.max(1) as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subspace_warp_beats_perspective_on_parallax() {
|
||||
let n = 48;
|
||||
let scene = Scene::new(n);
|
||||
let mut an = Analyzer::new(scene.size, AnalyzeOpts::default());
|
||||
for t in 0..n {
|
||||
let img = scene.frame(t);
|
||||
an.push(&Frame { img: &img, offset: [0.0; 2] });
|
||||
}
|
||||
let a = an.finish(0.0, 1.0 / 24.0);
|
||||
assert!(a.tracks.len() >= 40, "trajectories: {}", a.tracks.len());
|
||||
// The analysis round-trips with its trajectories.
|
||||
assert_eq!(WarpAnalysis::from_json(&a.to_json()).unwrap(), a);
|
||||
let base = StabSettings { framing: Framing::StabilizeOnly, fps: 24.0, ..Default::default() };
|
||||
let before = residual_jitter(&scene, |_, x| Some(x));
|
||||
let ppl = plan(&a, &StabSettings { method: Method::Perspective, ..base });
|
||||
assert!(ppl.meshes.is_empty());
|
||||
let persp = residual_jitter(&scene, |t, x| ppl.output_of(t, x));
|
||||
let spl = plan(&a, &StabSettings { method: Method::SubspaceWarp, ..base });
|
||||
assert_eq!(spl.meshes.len(), n);
|
||||
let sub = residual_jitter(&scene, |t, x| spl.output_of(t, x));
|
||||
let epl = plan(&a, &StabSettings { method: Method::SubspaceWarp, ripple: Ripple::Enhanced, ..base });
|
||||
let enh = residual_jitter(&scene, |t, x| epl.output_of(t, x));
|
||||
eprintln!("jitter: before {before:.3}, perspective {persp:.3}, subspace {sub:.3}, enhanced {enh:.3}");
|
||||
assert!(persp < 0.8 * before, "perspective {persp} vs {before}");
|
||||
assert!(sub < 0.6 * persp, "subspace {sub} vs perspective {persp}");
|
||||
assert!(enh < 0.6 * persp, "enhanced {enh} vs perspective {persp}");
|
||||
// source_of and output_of agree.
|
||||
let q = [150.0, 80.0];
|
||||
let o = spl.output_of(10, q).unwrap();
|
||||
let back = spl.source_of(10, o).unwrap();
|
||||
assert!((back[0] - q[0]).abs() < 1e-3 && (back[1] - q[1]).abs() < 1e-3);
|
||||
// Auto-scale framing covers the frame with mesh warps too.
|
||||
let fpl = plan(&a, &StabSettings { method: Method::SubspaceWarp, fps: 24.0, ..Default::default() });
|
||||
assert!(fpl.auto_scale >= 1.0);
|
||||
for k in [0, n / 2, n - 1] {
|
||||
for p in [[1.0, 1.0], [319.0, 1.0], [319.0, 179.0], [1.0, 179.0]] {
|
||||
let q = fpl.source_of(k, p).unwrap();
|
||||
let inside = q[0] >= -0.5 && q[1] >= -0.5 && q[0] <= 320.5 && q[1] <= 180.5;
|
||||
assert!(inside || fpl.crop.is_some(), "frame {k}: {p:?} → {q:?}");
|
||||
}
|
||||
}
|
||||
// Preserve Scale keeps working with meshes.
|
||||
let psp = plan(&a, &StabSettings { method: Method::SubspaceWarp, preserve_scale: true, ..base });
|
||||
assert_eq!(psp.meshes.len(), n);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,447 @@
|
|||
//! Warp Stabilizer ▸ Method ▸ **Subspace Warp**: content-preserving warps fitted to smoothed
|
||||
//! feature trajectories, clean-room from the published method:
|
||||
//!
|
||||
//! - Liu, Gleicher, Wang, Jin & Agarwala, "Subspace Video Stabilization", ACM TOG 30(1), 2011:
|
||||
//! the feature trajectories of a short window form a matrix `M` (two rows per trajectory, one
|
||||
//! column per frame) that is close to low rank (rank 9 for a moving camera over a static
|
||||
//! scene); its factorisation `M ≈ W C` gives *eigen-trajectories* `C`, which are smoothed (here
|
||||
//! with a Gaussian) instead of the trajectories themselves. Every trajectory's smoothed
|
||||
//! position is `W_i Ĉ_t`; trajectories that do not span the whole window get their
|
||||
//! coefficients `W_i` by least squares over the frames they cover (the paper's handling of
|
||||
//! incomplete trajectories in a moving factorisation). Smoothing in the subspace keeps the
|
||||
//! smoothed trajectories consistent with one another, so the warp does not tear the scene.
|
||||
//! - Liu, Gleicher, Jin & Agarwala, "Content-Preserving Warps for 3D Video Stabilization", ACM
|
||||
//! TOG 28(3), 2009: each frame is warped by a grid mesh fitted to the feature correspondences
|
||||
//! (original → smoothed position) in least squares, with a **similarity term** asking every
|
||||
//! grid triangle to keep its shape (`V1 = V2 + u (V3 − V2) + v R90 (V3 − V2)` with `u, v` from
|
||||
//! the undeformed grid) so the warp bends where the scene's depth demands it but stays locally
|
||||
//! rigid elsewhere.
|
||||
//!
|
||||
//! The mesh is stored *inversely*: a regular grid over the stabilised frame whose vertices hold
|
||||
//! the source position they show ([`Mesh`]), so rendering is a bilinear lookup per output pixel.
|
||||
//! The data term is the same linear constraint read the other way (the smoothed position's grid
|
||||
//! cell, bilinearly, must land on the original position). A weak prior ties every vertex to the
|
||||
//! frame's perspective correction, which fills feature-less regions and keeps the system well
|
||||
//! posed.
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::camtrack::Track2D;
|
||||
use crate::camtrack::linalg::{cholesky_solve, sym_eigen};
|
||||
use crate::fit::{Model, least_squares};
|
||||
use crate::solve::Homography;
|
||||
|
||||
/// A warp mesh: a regular `cols × rows` grid over the stabilised frame (`size`), each vertex
|
||||
/// holding the source-frame position it shows.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Mesh {
|
||||
pub cols: usize,
|
||||
pub rows: usize,
|
||||
pub size: [f64; 2],
|
||||
/// `(rows + 1) × (cols + 1)` source positions, row-major.
|
||||
pub verts: Vec<[f64; 2]>,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// The identity mesh.
|
||||
pub fn identity(cols: usize, rows: usize, size: [f64; 2]) -> Mesh {
|
||||
let mut m = Mesh { cols: cols.max(1), rows: rows.max(1), size, verts: vec![] };
|
||||
m.verts = (0..=m.rows).flat_map(|j| (0..=m.cols).map(move |i| (i, j))).map(|(i, j)| m.grid(i, j)).collect();
|
||||
m
|
||||
}
|
||||
|
||||
/// The mesh of a homography (`h`: source → stabilised).
|
||||
pub fn from_homography(cols: usize, rows: usize, size: [f64; 2], h: &Homography) -> Mesh {
|
||||
let inv = h.inverse().unwrap_or(Homography::IDENTITY);
|
||||
let mut m = Mesh::identity(cols, rows, size);
|
||||
for v in m.verts.iter_mut() {
|
||||
*v = inv.apply(*v);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn cell(&self) -> [f64; 2] {
|
||||
[self.size[0] / self.cols as f64, self.size[1] / self.rows as f64]
|
||||
}
|
||||
|
||||
/// The undeformed position of vertex `(i, j)` (column, row).
|
||||
pub fn grid(&self, i: usize, j: usize) -> [f64; 2] {
|
||||
let c = self.cell();
|
||||
[i as f64 * c[0], j as f64 * c[1]]
|
||||
}
|
||||
|
||||
fn idx(&self, i: usize, j: usize) -> usize {
|
||||
j * (self.cols + 1) + i
|
||||
}
|
||||
|
||||
/// Cell and bilinear coordinates of a stabilised point (the outer cells extend beyond the
|
||||
/// frame, so points outside it extrapolate).
|
||||
fn locate(&self, p: [f64; 2]) -> (usize, usize, f64, f64) {
|
||||
let c = self.cell();
|
||||
let fx = p[0] / c[0];
|
||||
let fy = p[1] / c[1];
|
||||
let i = (fx.floor().max(0.0) as usize).min(self.cols - 1);
|
||||
let j = (fy.floor().max(0.0) as usize).min(self.rows - 1);
|
||||
(i, j, fx - i as f64, fy - j as f64)
|
||||
}
|
||||
|
||||
/// The source position shown at stabilised point `p`.
|
||||
pub fn sample(&self, p: [f64; 2]) -> [f64; 2] {
|
||||
let (i, j, u, v) = self.locate(p);
|
||||
let a = self.verts[self.idx(i, j)];
|
||||
let b = self.verts[self.idx(i + 1, j)];
|
||||
let c = self.verts[self.idx(i, j + 1)];
|
||||
let d = self.verts[self.idx(i + 1, j + 1)];
|
||||
let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v];
|
||||
[0, 1].map(|k| w[0] * a[k] + w[1] * b[k] + w[2] * c[k] + w[3] * d[k])
|
||||
}
|
||||
|
||||
/// Where source point `q` lands in the stabilised frame (Newton iterations on
|
||||
/// [`Mesh::sample`]); `None` when it does not converge (a folded mesh).
|
||||
pub fn forward(&self, q: [f64; 2]) -> Option<[f64; 2]> {
|
||||
// Start from the mesh's own offset at q.
|
||||
let s = self.sample(q);
|
||||
let mut p = [2.0 * q[0] - s[0], 2.0 * q[1] - s[1]];
|
||||
for _ in 0..30 {
|
||||
let f = self.sample(p);
|
||||
let e = [f[0] - q[0], f[1] - q[1]];
|
||||
if e[0].hypot(e[1]) < 1e-6 {
|
||||
return Some(p);
|
||||
}
|
||||
let h = 0.5;
|
||||
let fx = self.sample([p[0] + h, p[1]]);
|
||||
let fy = self.sample([p[0], p[1] + h]);
|
||||
let j = [[(fx[0] - f[0]) / h, (fy[0] - f[0]) / h], [(fx[1] - f[1]) / h, (fy[1] - f[1]) / h]];
|
||||
let det = j[0][0] * j[1][1] - j[0][1] * j[1][0];
|
||||
if det.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
p[0] -= (j[1][1] * e[0] - j[0][1] * e[1]) / det;
|
||||
p[1] -= (-j[1][0] * e[0] + j[0][0] * e[1]) / det;
|
||||
}
|
||||
let f = self.sample(p);
|
||||
((f[0] - q[0]).hypot(f[1] - q[1]) < 1e-3).then_some(p)
|
||||
}
|
||||
|
||||
/// `(1 − t) · identity + t · self` per vertex.
|
||||
pub fn lerp_identity(&self, t: f64) -> Mesh {
|
||||
let mut m = self.clone();
|
||||
for j in 0..=self.rows {
|
||||
for i in 0..=self.cols {
|
||||
let g = self.grid(i, j);
|
||||
let k = self.idx(i, j);
|
||||
m.verts[k] = [g[0] + (self.verts[k][0] - g[0]) * t, g[1] + (self.verts[k][1] - g[1]) * t];
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Largest `s ∈ [0, 1]` such that the centred `s·W × s·H` rectangle of the stabilised frame
|
||||
/// shows only source pixels (its outline, sampled densely, maps inside the source frame).
|
||||
pub fn valid_fraction(&self) -> f64 {
|
||||
let [w, h] = self.size;
|
||||
let c = [w / 2.0, h / 2.0];
|
||||
let tol = 1e-6;
|
||||
let inside = |q: [f64; 2]| q[0] >= -tol && q[1] >= -tol && q[0] <= w + tol && q[1] <= h + tol;
|
||||
let fits = |s: f64| {
|
||||
let (hw, hh) = (s * w / 2.0, s * h / 2.0);
|
||||
let n = 48;
|
||||
(0..=n).all(|k| {
|
||||
let t = k as f64 / n as f64;
|
||||
let x = c[0] - hw + 2.0 * hw * t;
|
||||
let y = c[1] - hh + 2.0 * hh * t;
|
||||
inside(self.sample([x, c[1] - hh]))
|
||||
&& inside(self.sample([x, c[1] + hh]))
|
||||
&& inside(self.sample([c[0] - hw, y]))
|
||||
&& inside(self.sample([c[0] + hw, y]))
|
||||
})
|
||||
};
|
||||
if fits(1.0) {
|
||||
return 1.0;
|
||||
}
|
||||
if !fits(0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0, 1.0);
|
||||
for _ in 0..24 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if fits(mid) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
/// The homography (source → stabilised) closest to the mesh in least squares.
|
||||
pub fn homography(&self) -> Homography {
|
||||
let mut src = vec![];
|
||||
let mut dst = vec![];
|
||||
for j in 0..=self.rows {
|
||||
for i in 0..=self.cols {
|
||||
src.push(self.verts[self.idx(i, j)]);
|
||||
dst.push(self.grid(i, j));
|
||||
}
|
||||
}
|
||||
least_squares(Model::Homography, &src, &dst).unwrap_or(Homography::IDENTITY)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh resolution and rigidity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MeshOpts {
|
||||
pub cols: usize,
|
||||
pub rows: usize,
|
||||
/// Weight of the similarity (shape-preserving) term relative to one feature.
|
||||
pub alpha: f64,
|
||||
/// Weight of the prior towards the frame's perspective correction, per vertex.
|
||||
pub prior: f64,
|
||||
}
|
||||
|
||||
impl Default for MeshOpts {
|
||||
fn default() -> Self {
|
||||
MeshOpts { cols: 12, rows: 8, alpha: 1.0, prior: 0.02 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Normal equations `AᵀA x = Aᵀb`, accumulated row by row.
|
||||
struct Normal {
|
||||
n: usize,
|
||||
a: Vec<f64>,
|
||||
b: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Normal {
|
||||
fn row(&mut self, coefs: &[(usize, f64)], rhs: f64, w: f64) {
|
||||
for (ia, ca) in coefs {
|
||||
self.b[*ia] += w * ca * rhs;
|
||||
for (ib, cb) in coefs {
|
||||
self.a[ia * self.n + ib] += w * ca * cb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit the content-preserving warp of one frame: `pairs` are (source position, wanted
|
||||
/// stabilised position) of its features; `base` is the frame's perspective correction (source →
|
||||
/// stabilised), the prior.
|
||||
pub fn solve_mesh(size: [f64; 2], o: &MeshOpts, pairs: &[([f64; 2], [f64; 2])], base: &Homography) -> Mesh {
|
||||
let mut m = Mesh::from_homography(o.cols, o.rows, size, base);
|
||||
let nv = (m.cols + 1) * (m.rows + 1);
|
||||
let n = 2 * nv;
|
||||
let mut ne = Normal { n, a: vec![0.0; n * n], b: vec![0.0; n] };
|
||||
// Prior.
|
||||
for k in 0..nv {
|
||||
let v = m.verts[k];
|
||||
ne.row(&[(2 * k, 1.0)], v[0], o.prior);
|
||||
ne.row(&[(2 * k + 1, 1.0)], v[1], o.prior);
|
||||
}
|
||||
// Data: the bilinear combination of the wanted position's cell lands on the source position.
|
||||
let mut used = 0;
|
||||
for (src, dst) in pairs {
|
||||
if !(dst[0] >= 0.0 && dst[1] >= 0.0 && dst[0] <= size[0] && dst[1] <= size[1] && src[0].is_finite() && src[1].is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let (i, j, u, v) = m.locate(*dst);
|
||||
let ks = [m.idx(i, j), m.idx(i + 1, j), m.idx(i, j + 1), m.idx(i + 1, j + 1)];
|
||||
let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v];
|
||||
for d in 0..2 {
|
||||
let coefs: Vec<(usize, f64)> = ks.iter().zip(w).map(|(k, w)| (2 * k + d, w)).collect();
|
||||
ne.row(&coefs, src[d], 1.0);
|
||||
}
|
||||
used += 1;
|
||||
}
|
||||
if used == 0 {
|
||||
return m;
|
||||
}
|
||||
// Similarity: four triangles per cell, each corner against its two cell neighbours.
|
||||
let a2 = o.alpha * o.alpha;
|
||||
for j in 0..m.rows {
|
||||
for i in 0..m.cols {
|
||||
let (a, b, c, d) = ((i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1));
|
||||
for (v1, v2, v3) in [(a, b, d), (b, c, a), (c, d, b), (d, a, c)] {
|
||||
let (g1, g2, g3) = (m.grid(v1.0, v1.1), m.grid(v2.0, v2.1), m.grid(v3.0, v3.1));
|
||||
let dv = [g3[0] - g2[0], g3[1] - g2[1]];
|
||||
let e = [g1[0] - g2[0], g1[1] - g2[1]];
|
||||
let l2 = dv[0] * dv[0] + dv[1] * dv[1];
|
||||
// R90(d) = (−d.y, d.x).
|
||||
let u = (e[0] * dv[0] + e[1] * dv[1]) / l2;
|
||||
let v = (e[0] * -dv[1] + e[1] * dv[0]) / l2;
|
||||
let (k1, k2, k3) = (m.idx(v1.0, v1.1), m.idx(v2.0, v2.1), m.idx(v3.0, v3.1));
|
||||
// x: V1x − V2x − u (V3x − V2x) + v (V3y − V2y) = 0
|
||||
ne.row(&[(2 * k1, 1.0), (2 * k2, u - 1.0), (2 * k3, -u), (2 * k3 + 1, v), (2 * k2 + 1, -v)], 0.0, a2);
|
||||
// y: V1y − V2y − u (V3y − V2y) − v (V3x − V2x) = 0
|
||||
ne.row(&[(2 * k1 + 1, 1.0), (2 * k2 + 1, u - 1.0), (2 * k3 + 1, -u), (2 * k3, -v), (2 * k2, v)], 0.0, a2);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut x = ne.b;
|
||||
if cholesky_solve(&mut ne.a, n, &mut x) {
|
||||
for k in 0..nv {
|
||||
m.verts[k] = [x[2 * k], x[2 * k + 1]];
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Gaussian weights of the frames `lo..=hi` around `t`.
|
||||
fn gauss(lo: usize, hi: usize, t: usize, sigma: f64) -> Vec<f64> {
|
||||
let g: Vec<f64> = (lo..=hi).map(|j| (-((j as f64 - t as f64).powi(2)) / (2.0 * sigma * sigma)).exp()).collect();
|
||||
let s: f64 = g.iter().sum();
|
||||
g.into_iter().map(|v| v / s.max(1e-300)).collect()
|
||||
}
|
||||
|
||||
/// Subspace-smoothed feature positions: for every frame `t < frames`, the (original, smoothed)
|
||||
/// positions of the trajectories seen on it. `sigma`: Gaussian smoothing in frames; `rank`: the
|
||||
/// subspace dimension (9 in the paper). Frames without enough trajectories get no pairs.
|
||||
pub fn smooth_trajectories(tracks: &[Track2D], frames: usize, sigma: f64, rank: usize) -> Vec<Vec<([f64; 2], [f64; 2])>> {
|
||||
let max_r = ((3.0 * sigma).ceil() as usize).clamp(1, 30);
|
||||
(0..frames)
|
||||
.into_par_iter()
|
||||
.map(|t| {
|
||||
let t32 = t as u32;
|
||||
let mut r = max_r;
|
||||
// The window around t: shrink it until enough trajectories span it.
|
||||
let (lo, hi, complete) = loop {
|
||||
let lo = t.saturating_sub(r);
|
||||
let hi = (t + r).min(frames.saturating_sub(1));
|
||||
let complete: Vec<&Track2D> = tracks.iter().filter(|tr| tr.start as usize <= lo && tr.end() as usize > hi).collect();
|
||||
if complete.len() >= 8 || r <= 2 {
|
||||
break (lo, hi, complete);
|
||||
}
|
||||
r = (r * 2 / 3).max(2);
|
||||
};
|
||||
if complete.len() < 4 {
|
||||
return vec![];
|
||||
}
|
||||
let m = hi - lo + 1;
|
||||
let rows = 2 * complete.len();
|
||||
let k = rank.min(m).min(rows).max(1);
|
||||
// M: rows × m (row-major).
|
||||
let mut mat = vec![0.0; rows * m];
|
||||
for (i, tr) in complete.iter().enumerate() {
|
||||
for c in 0..m {
|
||||
let p = tr.at((lo + c) as u32).unwrap_or([0.0; 2]);
|
||||
mat[(2 * i) * m + c] = p[0];
|
||||
mat[(2 * i + 1) * m + c] = p[1];
|
||||
}
|
||||
}
|
||||
// Gram MᵀM and its top eigenvectors: the eigen-trajectories (orthonormal rows of C).
|
||||
let mut gram = vec![0.0; m * m];
|
||||
for a in 0..m {
|
||||
for b in a..m {
|
||||
let s: f64 = (0..rows).map(|r| mat[r * m + a] * mat[r * m + b]).sum();
|
||||
gram[a * m + b] = s;
|
||||
gram[b * m + a] = s;
|
||||
}
|
||||
}
|
||||
let (_, vecs) = sym_eigen(&gram, m);
|
||||
// C[q][c] = vecs[c][m − 1 − q] (largest first).
|
||||
let cmat: Vec<Vec<f64>> = (0..k).map(|q| (0..m).map(|c| vecs[c * m + (m - 1 - q)]).collect()).collect();
|
||||
let g = gauss(lo, hi, t, sigma.max(1e-6));
|
||||
let chat: Vec<f64> = cmat.iter().map(|row| row.iter().zip(&g).map(|(a, b)| a * b).sum()).collect();
|
||||
let tc = t - lo;
|
||||
let mut out = Vec::new();
|
||||
// Complete trajectories: W = M Cᵀ.
|
||||
for (i, _) in complete.iter().enumerate() {
|
||||
let src = [mat[(2 * i) * m + tc], mat[(2 * i + 1) * m + tc]];
|
||||
let mut dst = [0.0; 2];
|
||||
for (d, slot) in dst.iter_mut().enumerate() {
|
||||
let row = &mat[(2 * i + d) * m..(2 * i + d + 1) * m];
|
||||
for q in 0..k {
|
||||
let w: f64 = row.iter().zip(&cmat[q]).map(|(a, b)| a * b).sum();
|
||||
*slot += w * chat[q];
|
||||
}
|
||||
}
|
||||
out.push((src, dst));
|
||||
}
|
||||
// Incomplete trajectories seen on t: coefficients by least squares over the frames
|
||||
// they cover in the window.
|
||||
let need = (k + 3).max(m / 2);
|
||||
for tr in tracks.iter().filter(|tr| tr.at(t32).is_some() && !(tr.start as usize <= lo && tr.end() as usize > hi)) {
|
||||
let cols: Vec<usize> = (0..m).filter(|c| tr.at((lo + c) as u32).is_some()).collect();
|
||||
if cols.len() < need {
|
||||
continue;
|
||||
}
|
||||
let mut ata = vec![0.0; k * k];
|
||||
let mut atb = [vec![0.0; k], vec![0.0; k]];
|
||||
for &c in &cols {
|
||||
let p = tr.at((lo + c) as u32).expect("covered");
|
||||
for a in 0..k {
|
||||
for d in 0..2 {
|
||||
atb[d][a] += cmat[a][c] * p[d];
|
||||
}
|
||||
for b in 0..k {
|
||||
ata[a * k + b] += cmat[a][c] * cmat[b][c];
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in 0..k {
|
||||
ata[a * k + a] += 1e-9;
|
||||
}
|
||||
let mut dst = [0.0; 2];
|
||||
let mut ok = true;
|
||||
for d in 0..2 {
|
||||
let mut x = atb[d].clone();
|
||||
let mut aa = ata.clone();
|
||||
if !cholesky_solve(&mut aa, k, &mut x) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
dst[d] = x.iter().zip(&chat).map(|(a, b)| a * b).sum();
|
||||
}
|
||||
if ok {
|
||||
out.push((tr.at(t32).expect("seen"), dst));
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mesh_identity_sample_and_forward() {
|
||||
let m = Mesh::identity(4, 3, [100.0, 60.0]);
|
||||
assert_eq!(m.sample([37.0, 21.0]), [37.0, 21.0]);
|
||||
assert_eq!(m.valid_fraction(), 1.0);
|
||||
let h = Homography([[1.0, 0.02, 5.0], [-0.01, 1.0, -3.0], [0.0, 0.0, 1.0]]);
|
||||
let mh = Mesh::from_homography(4, 3, [100.0, 60.0], &h);
|
||||
// Affine maps are exact on a bilinear mesh.
|
||||
let q = [40.0, 30.0];
|
||||
let p = mh.forward(q).unwrap();
|
||||
let e = h.apply(q);
|
||||
assert!((p[0] - e[0]).abs() < 1e-6 && (p[1] - e[1]).abs() < 1e-6, "{p:?} {e:?}");
|
||||
assert!(mh.homography().max_diff(&h) < 1e-6);
|
||||
assert!(mh.valid_fraction() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_fits_a_translation_and_bends_for_local_motion() {
|
||||
let size = [200.0, 120.0];
|
||||
let mut pairs = vec![];
|
||||
for y in 0..12 {
|
||||
for x in 0..20 {
|
||||
let p = [5.0 + x as f64 * 10.0, 5.0 + y as f64 * 10.0];
|
||||
// Everything moves by (3, −2); a blob near the centre by (8, −2).
|
||||
let bump = (-((p[0] - 100.0).powi(2) + (p[1] - 60.0).powi(2)) / (2.0 * 20.0f64.powi(2))).exp();
|
||||
pairs.push((p, [p[0] + 3.0 + 5.0 * bump, p[1] - 2.0]));
|
||||
}
|
||||
}
|
||||
let base = Homography::translation([3.0, -2.0]);
|
||||
let m = solve_mesh(size, &MeshOpts { cols: 10, rows: 6, alpha: 0.5, prior: 0.01 }, &pairs, &base);
|
||||
let mut err_mesh = 0.0;
|
||||
let mut err_h = 0.0;
|
||||
for (s, d) in &pairs {
|
||||
let q = m.sample(*d);
|
||||
err_mesh += (q[0] - s[0]).hypot(q[1] - s[1]);
|
||||
let qh = base.inverse().unwrap().apply(*d);
|
||||
err_h += (qh[0] - s[0]).hypot(qh[1] - s[1]);
|
||||
}
|
||||
assert!(err_mesh < 0.35 * err_h, "{err_mesh} vs {err_h}");
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,8 @@ pub enum PanelKind {
|
|||
MaskInterpolation,
|
||||
ScriptConsole,
|
||||
EssentialGraphics,
|
||||
/// A dockable ScriptUI panel (a script from the ScriptUI Panels folder), by script window id.
|
||||
ScriptPanel(u32),
|
||||
LumetriScopes,
|
||||
Footage,
|
||||
MediaBrowser,
|
||||
|
|
@ -107,6 +109,7 @@ impl PanelKind {
|
|||
PanelKind::MaskInterpolation => "Mask Interpolation",
|
||||
PanelKind::ScriptConsole => "Script Console",
|
||||
PanelKind::EssentialGraphics => "Essential Graphics",
|
||||
PanelKind::ScriptPanel(_) => "ScriptUI Panel",
|
||||
PanelKind::LumetriScopes => "Lumetri Scopes",
|
||||
PanelKind::Footage => "Footage",
|
||||
PanelKind::MediaBrowser => "Media Browser",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ impl EffectcraftApp {
|
|||
/// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp.
|
||||
fn tab_titles(&self) -> Vec<(PanelKind, String)> {
|
||||
let mut out = Vec::new();
|
||||
// ScriptUI panels are named after their script.
|
||||
for w in self.session.script_ui.windows.iter().filter(|w| w.kind == effectcraft_engine::scriptui::WindowKind::Panel) {
|
||||
if let Some(t) = panels::scriptui_view::panel_title(self, w.id) {
|
||||
out.push((PanelKind::ScriptPanel(w.id), t));
|
||||
}
|
||||
}
|
||||
let Some(comp) = self.session.active_comp() else { return out };
|
||||
let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default();
|
||||
out.push((PanelKind::Composition, format!("Composition {cname}")));
|
||||
|
|
@ -69,6 +75,13 @@ impl EffectcraftApp {
|
|||
|
||||
/// Close a panel wherever it is (docked or floating).
|
||||
pub fn close_panel(&mut self, p: PanelKind) {
|
||||
// Closing a ScriptUI panel closes its script window (its script's onClose runs).
|
||||
if let PanelKind::ScriptPanel(id) = p
|
||||
&& self.session.script_ui.window(id).is_some()
|
||||
&& let Err(e) = self.session.execute("scriptui.close", serde_json::json!({"window": id}))
|
||||
{
|
||||
self.ui.status = e.to_string();
|
||||
}
|
||||
self.ui.dock.close(p);
|
||||
self.edit_layout(|l| l.unfloat(p));
|
||||
if self.ui.maximized == Some(p) {
|
||||
|
|
|
|||
|
|
@ -223,7 +223,21 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
|||
}
|
||||
x = r.max.x + 12.0;
|
||||
}
|
||||
x + 4.0
|
||||
// Record Options… (⌘/Ctrl-drag a pin records its motion in real time).
|
||||
let label = "Record Options...";
|
||||
let w = 104.0;
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(w, 20.0));
|
||||
let resp = ui.interact(r, egui::Id::new("puppet-record-options"), egui::Sense::click()).on_hover_text("⌘/Ctrl-drag a pin to record its motion");
|
||||
p.rect_stroke(r, 3.0, egui::Stroke::new(1.0, if resp.hovered() { t.accent } else { t.field_border }), egui::StrokeKind::Inside);
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), t.text);
|
||||
app.auto.add("header.puppet.recordOptions", r, label);
|
||||
if resp.clicked() {
|
||||
let ctx = ui.ctx().clone();
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "puppet.recordOptions", json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
r.max.x + 16.0
|
||||
}
|
||||
|
||||
fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ pub enum Dialog {
|
|||
Precompose,
|
||||
/// Layer ▸ Layer Styles ▸ Layer Style Options…
|
||||
LayerStyles,
|
||||
/// Edit ▸ Templates ▸ Render Settings… / Output Module….
|
||||
RenderTemplates,
|
||||
}
|
||||
|
||||
/// Host hooks provided by the native app (file pickers etc.).
|
||||
|
|
@ -855,6 +857,8 @@ impl EffectcraftApp {
|
|||
panels::precomp::mini_flowchart(self, &ctx);
|
||||
panels::home::capture_thumbnail(self);
|
||||
panels::dialogs::show(self, &ctx);
|
||||
panels::scriptui_view::sync_panels(self);
|
||||
panels::scriptui_view::show_windows(self, &ctx);
|
||||
panels::learn::coach(self, &ctx);
|
||||
self.draw_toast(ui, full);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -456,6 +456,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
|||
app.dialog = Some(crate::Dialog::About);
|
||||
Value::Null
|
||||
}
|
||||
// Window ▸ <ScriptUI panel>: dock (or bring forward) the panel the script built.
|
||||
"window.scriptPanel" => {
|
||||
let id = p.get("window").and_then(Value::as_u64).ok_or("no ScriptUI panel window")? as u32;
|
||||
app.show_panel(PanelKind::ScriptPanel(id));
|
||||
json!({"window": id})
|
||||
}
|
||||
"layer.style.options" => {
|
||||
crate::panels::layer_styles_dialog::open(app, &p)?;
|
||||
Value::Null
|
||||
|
|
@ -506,8 +512,18 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
|||
}
|
||||
"app.templates" => {
|
||||
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("renderSettings");
|
||||
let title = if kind == "outputModule" { "Output Module Templates" } else { "Render Settings Templates" };
|
||||
crate::panels::dialogs::info(app, title, "Render and output templates are managed from the Render Queue panel (Window ▸ Render Queue).");
|
||||
crate::panels::rq_templates::open(app, ctx, kind);
|
||||
Value::Null
|
||||
}
|
||||
"renderQueue.notify" => {
|
||||
// Render finished with Notify on: a toast, the dock/taskbar attention request, and the
|
||||
// host's system sound (the desktop app handles `renderQueue.notify`).
|
||||
let msg = p.get("message").and_then(Value::as_str).unwrap_or("Render finished").to_string();
|
||||
app.toast = Some((msg, now));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
|
||||
if let Some(f) = app.hooks.app_action.as_ref() {
|
||||
f("renderQueue.notify");
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
"app.find" => {
|
||||
|
|
@ -822,7 +838,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
|||
"file.exportLottie" => ("path", Ask::Save("Animation.json")),
|
||||
"file.importLottie" => ("path", Ask::Open(&["json", "lottie"])),
|
||||
"render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")),
|
||||
"file.runScript" => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
|
||||
"file.runScript" if params.get("name").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
|
||||
"file.installScript" | "file.installScriptUIPanel" => ("path", Ask::Open(&["jsx", "js"])),
|
||||
"effect.plugins.load" if params.get("folder").is_none() => ("path", Ask::Open(&["wasm", "wat"])),
|
||||
"file.replaceFootage" => ("path", Ask::Import),
|
||||
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
|
||||
"file.saveCopyAsXml" => ("path", Ask::Save("Untitled Project.ecprojx")),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ struct Active {
|
|||
show_3d: bool,
|
||||
point_size: f64,
|
||||
target_size: f64,
|
||||
/// Undistort Footage is on: points project without the lens distortion.
|
||||
undistort: bool,
|
||||
/// Layer pixels → comp pixels.
|
||||
l2c: Mat3,
|
||||
frame: usize,
|
||||
|
|
@ -66,6 +68,7 @@ fn active(s: &Session, comp: &Comp, ectx: &EvalCtx, l2c: &dyn Fn(&EvalCtx, &Laye
|
|||
let t = tracks.as_ref()?;
|
||||
(t.frame_duration > 0.0).then(|| ((lt - t.start) / t.frame_duration).round().clamp(0.0, t.frames.saturating_sub(1) as f64) as usize)
|
||||
});
|
||||
let undistort = solve.as_ref().is_some_and(|sv| ct::undistorts(¶ms, sv));
|
||||
return Some(Active {
|
||||
layer: *lid,
|
||||
uid: *uid,
|
||||
|
|
@ -74,6 +77,7 @@ fn active(s: &Session, comp: &Comp, ectx: &EvalCtx, l2c: &dyn Fn(&EvalCtx, &Laye
|
|||
show_3d: val("showTrackPoints").map(|v| v.as_enum()).unwrap_or(1) == 1,
|
||||
point_size: val("trackPointSize").map(|v| v.as_f64()).unwrap_or(100.0),
|
||||
target_size: val("targetSize").map(|v| v.as_f64()).unwrap_or(100.0),
|
||||
undistort,
|
||||
l2c: l2c(ectx, l),
|
||||
frame: frame.unwrap_or(0),
|
||||
});
|
||||
|
|
@ -98,18 +102,20 @@ fn l2c_scale(m: &Mat3) -> f32 {
|
|||
|
||||
/// Draw the target disc (concentric rings in perspective) of `t` on frame `k`.
|
||||
fn draw_target(p: &egui::Painter, a: &Active, map: &ViewerMap, sv: &CameraSolve, t: &Target) {
|
||||
let Some(cam) = sv.frames.get(a.frame) else { return };
|
||||
if sv.frames.get(a.frame).is_none() {
|
||||
return;
|
||||
}
|
||||
let r = t.size * 0.5 * a.target_size / 100.0;
|
||||
for (i, frac) in [1.0, 0.66, 0.33].into_iter().enumerate() {
|
||||
let ring = ct::target_circle(t.center, t.normal, r * frac, 48);
|
||||
let pts: Vec<Pos2> = ring.iter().filter_map(|x| cam.project(sv.size, *x)).map(|q| to_screen(a, map, q)).collect();
|
||||
let pts: Vec<Pos2> = ring.iter().filter_map(|x| sv.project(a.frame, *x, a.undistort)).map(|q| to_screen(a, map, q)).collect();
|
||||
if pts.len() < 3 {
|
||||
return;
|
||||
}
|
||||
let fill = if i % 2 == 0 { Color32::from_rgba_unmultiplied(0xd0, 0x30, 0x30, 70) } else { Color32::from_rgba_unmultiplied(0xff, 0xff, 0xff, 50) };
|
||||
p.add(egui::Shape::convex_polygon(pts.clone(), fill, Stroke::new(1.0, Color32::from_rgb(0xe0, 0x40, 0x40))));
|
||||
}
|
||||
if let Some(c) = cam.project(sv.size, t.center) {
|
||||
if let Some(c) = sv.project(a.frame, t.center, a.undistort) {
|
||||
let c = to_screen(a, map, c);
|
||||
p.circle_filled(c, 3.0, Color32::from_rgb(0xff, 0xd0, 0x40));
|
||||
}
|
||||
|
|
@ -168,7 +174,7 @@ pub fn viewer_hook(
|
|||
let mut hits: Vec<Hit> = vec![];
|
||||
match (&a.solve, a.show_3d) {
|
||||
(Some(sv), true) => {
|
||||
let pts = ct::projected(sv, a.frame);
|
||||
let pts = ct::projected_with(sv, a.frame, a.undistort);
|
||||
let med = ct::median_depth(&pts);
|
||||
for (id, q, z) in pts {
|
||||
let sp = to_screen(&a, map, q);
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
Dialog::LayerStyles => super::layer_styles_dialog::show(app, ctx, &t),
|
||||
Dialog::TrackTarget => super::tracker::target_dialog(app, ctx, &t),
|
||||
Dialog::TrackApply => super::tracker::apply_dialog(app, ctx, &t),
|
||||
Dialog::RenderTemplates => super::rq_templates::show(app, ctx, &t),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let searching = !query.is_empty();
|
||||
let mut y = list.min.y - scroll;
|
||||
let mut apply: Option<Apply> = None;
|
||||
let reg = effectcraft_engine::effects::registry();
|
||||
let reg = effectcraft_engine::effects::all();
|
||||
let keep = |e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || e.name.to_lowercase().contains(&query));
|
||||
let lookup = |id: &str| reg.iter().find(|e| e.id == id);
|
||||
|
||||
|
|
@ -381,7 +381,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
effect_row(app, ui, &lp, list, &mut y, e, -16.0, &query, "effects.item", &mut apply);
|
||||
}
|
||||
} else {
|
||||
let mut cats: Vec<&str> = effectcraft_engine::effects::CATEGORIES.to_vec();
|
||||
let mut cats: Vec<&str> = effectcraft_engine::effects::categories();
|
||||
cats.retain(|c| reg.iter().any(|e| e.category == *c));
|
||||
for cat in cats {
|
||||
let items: Vec<_> = reg.iter().filter(|e| e.category == cat && keep(e)).collect();
|
||||
|
|
|
|||
|
|
@ -136,6 +136,19 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
|||
1,
|
||||
)],
|
||||
),
|
||||
// Puppet tool ▸ Record Options.
|
||||
"puppet.recordOptions" if !has(p, &["speed", "smoothing", "useDraftDeformation", "showMesh"]) => {
|
||||
let o = &s.state.puppet;
|
||||
(
|
||||
"Puppet Record Options".into(),
|
||||
vec![
|
||||
Field::num("speed", "Speed (%)", o.record_speed),
|
||||
Field::num("smoothing", "Smoothing", o.record_smoothing),
|
||||
Field::bool("useDraftDeformation", "Use Draft Deformation", o.record_draft),
|
||||
Field::bool("showMesh", "Show Mesh", o.record_show_mesh),
|
||||
],
|
||||
)
|
||||
}
|
||||
"layer.setTransform" if !has(p, &["value"]) => {
|
||||
let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
|
||||
let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
|
||||
|
|
|
|||
|
|
@ -172,34 +172,43 @@ pub fn audio(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
// The branching history (EffectCraft's History panel): every state, branches indented under
|
||||
// the state they grew from; click any state to go there (`edit.history.goto`).
|
||||
let nodes = app.session.history_tree();
|
||||
let row_h = 22.0;
|
||||
let cur = nodes.iter().position(|n| n.current).unwrap_or(0);
|
||||
// Keep the current state in view.
|
||||
let visible = ((rect.height() - 12.0) / row_h).max(1.0) as usize;
|
||||
let first = (cur + 2).saturating_sub(visible).min(nodes.len().saturating_sub(visible));
|
||||
let mut y = rect.min.y + 6.0;
|
||||
let undo: Vec<String> = app.session.history.undo.iter().map(|u| u.0.clone()).collect();
|
||||
let redo: Vec<String> = app.session.history.redo.iter().rev().map(|u| u.0.clone()).collect();
|
||||
let n = undo.len();
|
||||
let mut jump: Option<i64> = None;
|
||||
for (i, label) in undo.iter().chain(redo.iter()).enumerate() {
|
||||
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), 22.0));
|
||||
y += 22.0;
|
||||
let current = i + 1 == n;
|
||||
let future = i >= n;
|
||||
let resp = ui.interact(r, egui::Id::new(("hist", i)), Sense::click());
|
||||
if current {
|
||||
let mut jump: Option<usize> = None;
|
||||
for n in nodes.iter().skip(first).take(visible + 1) {
|
||||
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), row_h));
|
||||
y += row_h;
|
||||
let resp = ui.interact(r, egui::Id::new(("hist", n.id.as_str())), Sense::click());
|
||||
if n.current {
|
||||
p.rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
p.rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
p.text(pos2(r.min.x + 12.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), if future { t.text_faint } else { t.text });
|
||||
let x = r.min.x + 12.0 + n.depth as f32 * 14.0;
|
||||
if n.depth > 0 {
|
||||
// Branch marker.
|
||||
let c = pos2(x - 7.0, r.center().y);
|
||||
p.line_segment([pos2(c.x, r.min.y), c], egui::Stroke::new(1.0, t.text_faint));
|
||||
p.line_segment([c, pos2(c.x + 5.0, c.y)], egui::Stroke::new(1.0, t.text_faint));
|
||||
}
|
||||
let col = if !n.current && (n.future || !n.line) { t.text_faint } else { t.text };
|
||||
p.text(pos2(x, r.center().y), Align2::LEFT_CENTER, &n.label, Tokens::ui(12.0), col);
|
||||
app.auto.add(&format!("history.state.{}", n.index), r, &n.label);
|
||||
if resp.clicked() {
|
||||
jump = Some(i as i64 + 1 - n as i64);
|
||||
jump = Some(n.index);
|
||||
}
|
||||
}
|
||||
if let Some(d) = jump {
|
||||
for _ in 0..d.unsigned_abs() {
|
||||
if d < 0 {
|
||||
app.session.undo();
|
||||
} else {
|
||||
app.session.redo();
|
||||
}
|
||||
if let Some(i) = jump {
|
||||
let ctx = ui.ctx().clone();
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "edit.history.goto", serde_json::json!({"index": i})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,8 +34,10 @@ pub mod properties;
|
|||
pub mod puppet_tool;
|
||||
pub mod render_queue;
|
||||
pub mod roto_tool;
|
||||
pub mod rq_templates;
|
||||
pub mod scopes_panel;
|
||||
pub mod script_console;
|
||||
pub mod scriptui_view;
|
||||
pub mod settings;
|
||||
pub mod shortcut_editor;
|
||||
pub mod text_panels;
|
||||
|
|
@ -107,6 +109,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
|
|||
PanelKind::Paint => paint_panels::paint(app, ui, rect),
|
||||
PanelKind::Brushes => paint_panels::brushes(app, ui, rect),
|
||||
PanelKind::EssentialGraphics => essential::show(app, ui, rect),
|
||||
PanelKind::ScriptPanel(id) => scriptui_view::panel(app, ui, rect, id),
|
||||
PanelKind::Flowchart => flowchart::show(app, ui, rect),
|
||||
PanelKind::LumetriScopes => scopes_panel::show(app, ui, rect),
|
||||
PanelKind::Footage => footage_panel::show(app, ui, rect),
|
||||
|
|
|
|||
|
|
@ -4,10 +4,18 @@
|
|||
//! Output Module and Output To rows, and a status footer.
|
||||
//!
|
||||
//! Every control dispatches an engine `renderQueue.*` command (so agents can do the same) and
|
||||
//! registers an automation id: `renderQueue.render`, `renderQueue.stop`,
|
||||
//! `renderQueue.item.<n>.{row,twirl,render,renderSettings,outputModule,outputTo,outputPath}`.
|
||||
//! registers an automation id: `renderQueue.render`, `renderQueue.stop`, `renderQueue.notify`,
|
||||
//! `renderQueue.item.<n>.{row,twirl,render,renderSettings,outputModule,outputTo,outputPath,log}`.
|
||||
//!
|
||||
//! The Render Settings and Output Module menus list the templates first (Edit ▸ Templates), then
|
||||
//! every setting; entries needing numbers (custom time span and frame rate, crop, resize, bitrate,
|
||||
//! key frame interval, Make Template) open a form that runs the same command.
|
||||
|
||||
use effectcraft_engine::project::render_queue::{AudioOutput, Channels, OutputFormat, ProResProfile, RenderQuality, RenderQueueItem, RenderStatus, TimeSpan};
|
||||
use effectcraft_engine::project::render_queue::{
|
||||
AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, EffectsMode, FieldRender, OutputFormat, PostRenderAction, ProResProfile, Pulldown,
|
||||
RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderStatus, ResizeQuality, SwitchOverride, TimeSpan,
|
||||
};
|
||||
use effectcraft_engine::project::render_templates::RenderTemplates;
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -53,42 +61,82 @@ fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Render Settings menu: (label, params).
|
||||
fn settings_menu(it: &RenderQueueItem) -> Vec<(String, Value)> {
|
||||
fn mark(on: bool, l: &str) -> String {
|
||||
if on { format!("✓ {l}") } else { format!(" {l}") }
|
||||
}
|
||||
|
||||
/// Render Settings menu: (label, params). `{"form": …}` entries open a form.
|
||||
fn settings_menu(it: &RenderQueueItem, templates: &RenderTemplates) -> Vec<(String, Value)> {
|
||||
let s = &it.settings;
|
||||
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
|
||||
let mut v = vec![
|
||||
(mark(s.quality == RenderQuality::Best, "Best Settings"), json!({"quality": "best"})),
|
||||
(mark(s.quality == RenderQuality::Draft, "Draft Settings"), json!({"quality": "draft"})),
|
||||
("-".into(), Value::Null),
|
||||
];
|
||||
let mut v = vec![];
|
||||
for t in templates.render_settings_list() {
|
||||
v.push((mark(s.name == t.name, &t.name), json!({"template": t.name})));
|
||||
}
|
||||
v.push((" Make Template…".into(), json!({"form": "makeRenderTemplate"})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(s.quality == RenderQuality::Best, "Quality: Best"), json!({"quality": "best"})));
|
||||
v.push((mark(s.quality == RenderQuality::Draft, "Quality: Draft"), json!({"quality": "draft"})));
|
||||
for (l, k) in [("Full", "full"), ("Half", "half"), ("Third", "third"), ("Quarter", "quarter")] {
|
||||
v.push((mark(s.resolution_label() == l, &format!("Resolution: {l}")), json!({"resolution": k})));
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (u, k) in effectcraft_engine::project::render_queue::ProxyUse::ALL.iter().zip(["current", "all", "comp", "none"]) {
|
||||
v.push((mark(s.proxy_use == *u, &format!("Proxy Use: {}", u.label())), json!({"proxyUse": k})));
|
||||
}
|
||||
for (e, k) in EffectsMode::ALL.iter().zip(["current", "allOn", "allOff"]) {
|
||||
v.push((mark(s.effects == *e, &format!("Effects: {}", e.label())), json!({"effects": k})));
|
||||
}
|
||||
for (e, k) in [(CurrentOrOff::Current, "current"), (CurrentOrOff::AllOff, "allOff")] {
|
||||
v.push((mark(s.solo == e, &format!("Solo Switches: {}", e.label())), json!({"solo": k})));
|
||||
}
|
||||
for (e, k) in [(CurrentOrOff::Current, "current"), (CurrentOrOff::AllOff, "allOff")] {
|
||||
v.push((mark(s.guide_layers == e, &format!("Guide Layers: {}", e.label())), json!({"guideLayers": k})));
|
||||
}
|
||||
for (d, k) in ColorDepth::ALL.iter().zip(["current", "8", "16", "32"]) {
|
||||
v.push((mark(s.color_depth == *d, &format!("Color Depth: {}", d.label())), json!({"colorDepth": k})));
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (m, k) in SwitchOverride::ALL.iter().zip(["current", "onForChecked", "offForAll"]) {
|
||||
v.push((mark(s.frame_blending == *m, &format!("Frame Blending: {}", m.label())), json!({"frameBlending": k})));
|
||||
}
|
||||
for (m, k) in SwitchOverride::ALL.iter().zip(["current", "onForChecked", "offForAll"]) {
|
||||
v.push((mark(s.motion_blur_override() == *m, &format!("Motion Blur: {}", m.label())), json!({"motionBlur": k})));
|
||||
}
|
||||
for (f, k) in FieldRender::ALL.iter().zip(["off", "upper", "lower"]) {
|
||||
v.push((mark(s.field_render == *f, &format!("Field Render: {}", f.label())), json!({"fieldRender": k})));
|
||||
}
|
||||
if s.field_render != FieldRender::Off {
|
||||
for pd in Pulldown::ALL {
|
||||
v.push((mark(s.pulldown == pd, &format!("3:2 Pulldown: {}", pd.label())), json!({"pulldown": pd.label()})));
|
||||
}
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(s.time_span == TimeSpan::WorkArea, "Time Span: Work Area Only"), json!({"timeSpan": "workArea"})));
|
||||
v.push((mark(s.time_span == TimeSpan::LengthOfComp, "Time Span: Length of Comp"), json!({"timeSpan": "comp"})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(matches!(s.time_span, TimeSpan::Custom { .. }), "Time Span: Custom…"), json!({"form": "timeSpan"})));
|
||||
v.push((mark(s.frame_rate.is_none(), "Frame Rate: Use comp's frame rate"), json!({"frameRate": null})));
|
||||
for fps in [12.0, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0] {
|
||||
let on = s.frame_rate.is_some_and(|r| (r.as_f64() - fps).abs() < 0.01);
|
||||
v.push((mark(on, &format!("Frame Rate: {fps}")), json!({"frameRate": fps})));
|
||||
}
|
||||
v.push((" Frame Rate: Custom…".into(), json!({"form": "frameRate"})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(s.motion_blur, "Motion Blur: On for Checked Layers"), json!({"motionBlur": !s.motion_blur})));
|
||||
v.push((mark(s.skip_existing, "Skip Existing Files"), json!({"skipExisting": !s.skip_existing})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (u, k) in effectcraft_engine::project::render_queue::ProxyUse::ALL.iter().zip(["current", "all", "comp", "none"]) {
|
||||
v.push((mark(s.proxy_use == *u, &format!("Proxy Use: {}", u.label())), json!({"proxyUse": k})));
|
||||
}
|
||||
v.push((mark(s.storage_overflow, "Use Storage Overflow"), json!({"storageOverflow": !s.storage_overflow})));
|
||||
v
|
||||
}
|
||||
|
||||
/// Output Module menu: (label, params).
|
||||
fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String, Value)> {
|
||||
/// Output Module menu: (label, params). `{"form": …}` entries open a form.
|
||||
fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &RenderTemplates) -> Vec<(String, Value)> {
|
||||
let o = &it.output;
|
||||
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
|
||||
let mut v = Vec::new();
|
||||
for t in templates.output_module_list() {
|
||||
if available.contains(&t.format) {
|
||||
v.push((mark(o.name == t.name, &t.name), json!({"template": t.name})));
|
||||
}
|
||||
}
|
||||
v.push((" Make Template…".into(), json!({"form": "makeOutputTemplate"})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
for f in OutputFormat::ALL {
|
||||
if available.contains(&f) {
|
||||
v.push((mark(o.format == f, &format!("Format: {}", f.label())), json!({"format": format!("{f:?}")})));
|
||||
|
|
@ -99,6 +147,13 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String,
|
|||
if o.format.supports_alpha() {
|
||||
v.push((mark(o.channels == Channels::Rgba, "Channels: RGB + Alpha"), json!({"channels": "rgba"})));
|
||||
}
|
||||
if !o.format.is_audio_only() {
|
||||
v.push((mark(o.channels == Channels::Alpha, "Channels: Alpha"), json!({"channels": "alpha"})));
|
||||
}
|
||||
if o.channels == Channels::Rgba {
|
||||
v.push((mark(o.alpha_mode == AlphaMode::Straight, "Color: Straight (Unmatted)"), json!({"color": "straight"})));
|
||||
v.push((mark(o.alpha_mode == AlphaMode::Premultiplied, "Color: Premultiplied (Matted)"), json!({"color": "premultiplied"})));
|
||||
}
|
||||
match o.format {
|
||||
OutputFormat::ProRes => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
|
|
@ -108,14 +163,18 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String,
|
|||
}
|
||||
OutputFormat::H264 => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for kbps in [2_000, 5_000, 10_000, 20_000, 40_000] {
|
||||
for kbps in [2_000, 5_000, 10_000, 15_000, 20_000, 40_000] {
|
||||
v.push((mark(o.bitrate_kbps == kbps, &format!("Bitrate: {} Mbps", kbps / 1000)), json!({"bitrate": kbps})));
|
||||
}
|
||||
}
|
||||
OutputFormat::JpegSequence | OutputFormat::WebM => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for q in [60u8, 80, 90, 100] {
|
||||
v.push((mark(o.quality == q, &format!("Quality: {q}")), json!({"quality": q})));
|
||||
v.push((mark(o.quality == q && !o.webm_bitrate, &format!("Quality: {q}")), json!({"quality": q, "webmBitrate": false})));
|
||||
}
|
||||
if o.format == OutputFormat::WebM {
|
||||
v.push((mark(o.webm_bitrate, &format!("Target Bitrate ({} kbps)…", o.bitrate_kbps)), json!({"form": "bitrate"})));
|
||||
v.push((mark(o.keyframe_interval > 0, "Key Frame Interval…"), json!({"form": "keyframes"})));
|
||||
}
|
||||
}
|
||||
OutputFormat::Gif => {
|
||||
|
|
@ -124,20 +183,131 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String,
|
|||
}
|
||||
_ => {}
|
||||
}
|
||||
if !o.format.is_audio_only() {
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(!o.crop.enabled, "Crop: Off"), json!({"crop": false})));
|
||||
v.push((mark(o.crop.enabled && o.crop.use_roi, "Crop: Use Region of Interest"), json!({"crop": {"useRoi": true}})));
|
||||
v.push((mark(o.crop.enabled && !o.crop.use_roi, "Crop: Edges…"), json!({"form": "crop"})));
|
||||
v.push((mark(!o.resize.enabled, "Resize: Off"), json!({"resize": false})));
|
||||
for (n, w, h) in RESIZE_PRESETS {
|
||||
let on = o.resize.enabled && o.resize.width == w && o.resize.height == h && !o.resize.lock_aspect;
|
||||
v.push((mark(on, &format!("Resize to: {n} ({w}×{h})")), json!({"resize": {"preset": n}})));
|
||||
}
|
||||
v.push((mark(o.resize.enabled, "Resize to: Custom…"), json!({"form": "resize"})));
|
||||
v.push((
|
||||
mark(o.resize.lock_aspect, "Resize: Lock Aspect Ratio"),
|
||||
json!({"resize": {"enabled": o.resize.enabled, "lockAspect": !o.resize.lock_aspect}}),
|
||||
));
|
||||
for (q, k) in [(ResizeQuality::Low, "low"), (ResizeQuality::High, "high")] {
|
||||
v.push((mark(o.resize.quality == q, &format!("Resize Quality: {q:?}")), json!({"resize": {"enabled": o.resize.enabled, "quality": k}})));
|
||||
}
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (a, k) in [
|
||||
(PostRenderAction::None, "none"),
|
||||
(PostRenderAction::Import, "import"),
|
||||
(PostRenderAction::ImportAndReplace, "importAndReplace"),
|
||||
(PostRenderAction::SetProxy, "setProxy"),
|
||||
] {
|
||||
v.push((mark(it.post_render == a, &format!("Post-Render Action: {}", a.label())), json!({"postRenderAction": k})));
|
||||
}
|
||||
v.push((mark(o.include_project_link, "Include Project Link"), json!({"includeProjectLink": !o.include_project_link})));
|
||||
if o.format.supports_audio() {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (a, l) in [(AudioOutput::Auto, "auto"), (AudioOutput::On, "on"), (AudioOutput::Off, "off")] {
|
||||
let label = match a {
|
||||
AudioOutput::Auto => "Audio Output: Auto",
|
||||
AudioOutput::On => "Audio Output: On (48 kHz stereo)",
|
||||
AudioOutput::On => "Audio Output: On",
|
||||
AudioOutput::Off => "Audio Output: Off",
|
||||
};
|
||||
v.push((mark(o.audio == a, label), json!({"audio": l})));
|
||||
}
|
||||
for sr in [44_100u32, 48_000, 96_000] {
|
||||
v.push((mark(o.audio_sample_rate == sr, &format!("Sample Rate: {:.1} kHz", sr as f64 / 1000.0)), json!({"sampleRate": sr})));
|
||||
}
|
||||
v.push((mark(o.audio_channels == 1, "Audio Channels: Mono"), json!({"audioChannels": "mono"})));
|
||||
v.push((mark(o.audio_channels != 1, "Audio Channels: Stereo"), json!({"audioChannels": "stereo"})));
|
||||
if matches!(o.format, OutputFormat::ProRes | OutputFormat::Wav | OutputFormat::Aiff) {
|
||||
for f in [AudioFormat::S16, AudioFormat::S24, AudioFormat::F32] {
|
||||
v.push((mark(o.audio_format == f, &format!("Sample Format: {}", f.label())), json!({"audioFormat": f.label()})));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Open the form behind a `{"form": …}` menu entry.
|
||||
fn open_menu_form(app: &mut EffectcraftApp, it: &RenderQueueItem, form: &str) {
|
||||
use crate::panels::forms::{Field, form as open};
|
||||
let id = it.id;
|
||||
let o = &it.output;
|
||||
match form {
|
||||
"makeRenderTemplate" => open(
|
||||
app,
|
||||
"Make Render Settings Template",
|
||||
"renderQueue.saveTemplate",
|
||||
json!({"kind": "renderSettings", "item": id}),
|
||||
vec![Field::text("name", "Template name", "")],
|
||||
),
|
||||
"makeOutputTemplate" => open(
|
||||
app,
|
||||
"Make Output Module Template",
|
||||
"renderQueue.saveTemplate",
|
||||
json!({"kind": "outputModule", "item": id}),
|
||||
vec![Field::text("name", "Template name", "")],
|
||||
),
|
||||
"timeSpan" => {
|
||||
let comp = app.session.project.comp(it.comp);
|
||||
let (a, b) = comp.map(|c| it.settings.span(c)).unwrap_or_default();
|
||||
open(
|
||||
app,
|
||||
"Custom Time Span",
|
||||
"renderQueue.setRenderSettings",
|
||||
json!({"item": id, "timeSpan": "custom"}),
|
||||
vec![Field::num("start", "Start (s)", a.seconds()), Field::num("duration", "Duration (s)", (b.seconds() - a.seconds()).max(0.04))],
|
||||
)
|
||||
}
|
||||
"frameRate" => {
|
||||
let r = app.session.project.comp(it.comp).map(|c| it.settings.rate(c).as_f64()).unwrap_or(30.0);
|
||||
open(app, "Use This Frame Rate", "renderQueue.setRenderSettings", json!({"item": id}), vec![Field::num("frameRate", "Frames per second", r)])
|
||||
}
|
||||
"crop" => open(
|
||||
app,
|
||||
"Crop",
|
||||
"renderQueue.setOutputModule",
|
||||
json!({"item": id}),
|
||||
vec![
|
||||
Field::num("cropTop", "Top", o.crop.top as f64),
|
||||
Field::num("cropLeft", "Left", o.crop.left as f64),
|
||||
Field::num("cropBottom", "Bottom", o.crop.bottom as f64),
|
||||
Field::num("cropRight", "Right", o.crop.right as f64),
|
||||
],
|
||||
),
|
||||
"resize" => open(
|
||||
app,
|
||||
"Resize to",
|
||||
"renderQueue.setOutputModule",
|
||||
json!({"item": id}),
|
||||
vec![Field::num("resizeWidth", "Width", o.resize.width as f64), Field::num("resizeHeight", "Height", o.resize.height as f64)],
|
||||
),
|
||||
"bitrate" => open(
|
||||
app,
|
||||
"WebM Target Bitrate",
|
||||
"renderQueue.setOutputModule",
|
||||
json!({"item": id, "webmBitrate": true}),
|
||||
vec![Field::num("bitrate", "Bitrate (kbps)", o.bitrate_kbps as f64)],
|
||||
),
|
||||
"keyframes" => open(
|
||||
app,
|
||||
"Key Frame Interval",
|
||||
"renderQueue.setOutputModule",
|
||||
json!({"item": id}),
|
||||
vec![Field::num("keyframeInterval", "Frames between key frames (0 = every 2 s)", o.keyframe_interval as f64)],
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
const OUTPUT_TEMPLATES: [&str; 4] = [
|
||||
"[compName].[fileExtension]",
|
||||
"[compName]_[width]x[height].[fileExtension]",
|
||||
|
|
@ -166,6 +336,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let rendering = app.session.is_rendering();
|
||||
let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
|
||||
let mut actions: Vec<(&'static str, Value)> = Vec::new();
|
||||
let mut forms: Vec<(RenderQueueItem, String)> = Vec::new();
|
||||
let state_id = egui::Id::new("rq-ui");
|
||||
let (mut open, mut selected): (Vec<u64>, Option<u64>) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
|
||||
|
||||
|
|
@ -194,6 +365,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
actions.push(("renderQueue.render", json!({"wait": false})));
|
||||
}
|
||||
app.auto.add("renderQueue.render", render_b, "Render");
|
||||
// Notify (sound + notification when the queue finishes).
|
||||
let notify = app.session.project.render_prefs.notify;
|
||||
let nb = Rect::from_min_size(pos2(render_b.min.x - 8.0 - 2.0 * bw - 8.0, render_b.min.y + 4.0), vec2(18.0, 18.0));
|
||||
if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).clicked() {
|
||||
actions.push(("renderQueue.setNotify", json!({"notify": !notify})));
|
||||
}
|
||||
p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, "Notify", Tokens::ui(11.5), t.text_dim);
|
||||
app.auto.add("renderQueue.notify", nb, if notify { "Notify: on" } else { "Notify: off" });
|
||||
if rendering {
|
||||
if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::new("rq-stop")).clicked() {
|
||||
actions.push(("renderQueue.stop", json!({})));
|
||||
|
|
@ -201,8 +380,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
app.auto.add("renderQueue.stop", stop_b, "Stop");
|
||||
}
|
||||
// Progress bar.
|
||||
let bar =
|
||||
Rect::from_min_max(pos2(x0, render_b.min.y + 4.0), pos2(if rendering { stop_b.min.x - 16.0 } else { render_b.min.x - 16.0 }, render_b.max.y - 4.0));
|
||||
let bar = Rect::from_min_max(pos2(x0, render_b.min.y + 4.0), pos2(nb.min.x - 16.0, render_b.max.y - 4.0));
|
||||
p.rect_filled(bar, 3.0, t.field_bg);
|
||||
p.rect_stroke(bar, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
let frac = progress.as_ref().filter(|p| p.total > 0).map(|p| (p.done as f32 / p.total as f32).clamp(0.0, 1.0)).unwrap_or(0.0);
|
||||
|
|
@ -355,22 +533,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H));
|
||||
y += ROW_H;
|
||||
lp.rect_filled(r, 0.0, t.panel_bg);
|
||||
let templates = &app.session.project.render_templates;
|
||||
let (label, summary, key, menu): (&str, String, &str, Vec<(String, Value)>) = if sub == 0 {
|
||||
("Render Settings:", it.settings.summary(), "renderSettings", settings_menu(it))
|
||||
("Render Settings:", it.settings.summary(), "renderSettings", settings_menu(it, templates))
|
||||
} else {
|
||||
("Output Module:", it.output.summary(), "outputModule", output_menu(it, &available))
|
||||
("Output Module:", it.output.summary(), "outputModule", output_menu(it, &available, templates))
|
||||
};
|
||||
lp.text(pos2(cx_num - 6.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
|
||||
let dd = Rect::from_min_size(pos2(ix + 70.0, r.min.y + 3.0), vec2(220.0, ROW_H - 6.0));
|
||||
let did = egui::Id::new(("rq-dd", it.id, sub));
|
||||
if widgets::dropdown(ui, dd, &summary, &t, did).clicked() && !rendering {
|
||||
let shown: String = if summary.chars().count() > 31 { summary.chars().take(30).chain(std::iter::once('…')).collect() } else { summary.clone() };
|
||||
if widgets::dropdown(ui, dd, &shown, &t, did).clicked() && !rendering {
|
||||
widgets::open_popup(ui, did);
|
||||
}
|
||||
let labels: Vec<String> = menu.iter().map(|(l, _)| l.clone()).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &labels, None) {
|
||||
let mut params = menu[i].1.clone();
|
||||
params["item"] = json!(it.id);
|
||||
actions.push((if sub == 0 { "renderQueue.setRenderSettings" } else { "renderQueue.setOutputModule" }, params));
|
||||
if let Some(f) = params.get("form").and_then(Value::as_str) {
|
||||
forms.push((it.clone(), f.to_string()));
|
||||
} else {
|
||||
params["item"] = json!(it.id);
|
||||
actions.push((if sub == 0 { "renderQueue.setRenderSettings" } else { "renderQueue.setOutputModule" }, params));
|
||||
}
|
||||
}
|
||||
app.auto.add(&aid(key), dd, &summary);
|
||||
if sub == 0 {
|
||||
|
|
@ -387,6 +571,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
);
|
||||
lp.text(pos2(dd.max.x + 14.0, r.center().y), Align2::LEFT_CENTER, info, Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
// Log: Errors Only / Plus Settings / Plus Per Frame Info.
|
||||
let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
|
||||
lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, "Log:", Tokens::ui(11.5), t.text_dim);
|
||||
let lid = egui::Id::new(("rq-log", it.id));
|
||||
if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
|
||||
widgets::open_popup(ui, lid);
|
||||
}
|
||||
let llabels: Vec<String> = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) {
|
||||
actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()})));
|
||||
}
|
||||
app.auto.add(&aid("log"), ld, it.log.label());
|
||||
continue;
|
||||
}
|
||||
// Output To.
|
||||
|
|
@ -490,6 +686,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
open.retain(|id| queue.iter().any(|i| i.id == *id));
|
||||
ui.data_mut(|d| d.insert_temp(state_id, (open, selected)));
|
||||
let ctx = ui.ctx().clone();
|
||||
for (it, f) in forms {
|
||||
open_menu_form(app, &it, &f);
|
||||
}
|
||||
for (id, params) in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, id, params) {
|
||||
app.ui.status = e;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
//! Edit ▸ Templates ▸ Render Settings… / Output Module…: the Defaults (which template new
|
||||
//! Render Queue items, frames, pre-renders and proxies start from) and the template list
|
||||
//! (settings of the selected template; Duplicate, Delete, New from a Render Queue item).
|
||||
//!
|
||||
//! Every action runs a `renderQueue.*Template*` engine command. Automation ids:
|
||||
//! `templates.default.<Slot>`, `templates.select`, `templates.duplicate`, `templates.delete`,
|
||||
//! `templates.newName`, `templates.saveFromItem`, `templates.ok`.
|
||||
|
||||
use effectcraft_engine::project::render_templates::{RenderTemplates, TemplateKind, TemplateSlot};
|
||||
use egui::{Color32, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct State {
|
||||
output: bool,
|
||||
selected: String,
|
||||
new_name: String,
|
||||
}
|
||||
|
||||
fn state_id() -> egui::Id {
|
||||
egui::Id::new("rq-templates-dialog")
|
||||
}
|
||||
|
||||
/// Open the dialog for `kind` (`renderSettings` | `outputModule`).
|
||||
pub fn open(app: &mut EffectcraftApp, ctx: &egui::Context, kind: &str) {
|
||||
let output = TemplateKind::parse(kind) == Some(TemplateKind::OutputModule);
|
||||
let t = &app.session.project.render_templates;
|
||||
let k = if output { TemplateKind::OutputModule } else { TemplateKind::RenderSettings };
|
||||
let selected = t.default_name(k, TemplateSlot::Movie);
|
||||
ctx.data_mut(|d| d.insert_temp(state_id(), State { output, selected, new_name: String::new() }));
|
||||
app.dialog = Some(Dialog::RenderTemplates);
|
||||
}
|
||||
|
||||
fn names(t: &RenderTemplates, output: bool) -> Vec<String> {
|
||||
if output { t.output_module_list().into_iter().map(|m| m.name).collect() } else { t.render_settings_list().into_iter().map(|r| r.name).collect() }
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut st: State = ctx.data(|d| d.get_temp(state_id())).unwrap_or_default();
|
||||
let kind = if st.output { "outputModule" } else { "renderSettings" };
|
||||
let k = if st.output { TemplateKind::OutputModule } else { TemplateKind::RenderSettings };
|
||||
let templates = app.session.project.render_templates.clone();
|
||||
let list = names(&templates, st.output);
|
||||
if !list.contains(&st.selected) {
|
||||
st.selected = list.first().cloned().unwrap_or_default();
|
||||
}
|
||||
let mut actions: Vec<(&'static str, Value)> = vec![];
|
||||
let mut close = false;
|
||||
let title = if st.output { "Output Module Templates" } else { "Render Settings Templates" };
|
||||
super::dialogs::modal(ctx, title, vec2(600.0, 560.0), t, |ui| {
|
||||
ui.label(egui::RichText::new("Defaults").font(Tokens::semibold(12.5)).color(t.text));
|
||||
egui::Grid::new("tpl-defaults").num_columns(2).spacing(vec2(14.0, 6.0)).show(ui, |ui| {
|
||||
for slot in TemplateSlot::ALL {
|
||||
ui.label(slot.label());
|
||||
let cur = templates.default_name(k, slot);
|
||||
let mut pick = cur.clone();
|
||||
let r = egui::ComboBox::from_id_salt(("tpl-def", slot.label())).selected_text(&cur).width(300.0).show_ui(ui, |ui| {
|
||||
for n in &list {
|
||||
ui.selectable_value(&mut pick, n.clone(), n);
|
||||
}
|
||||
});
|
||||
app.auto.add(&format!("templates.default.{slot:?}"), r.response.rect, &cur);
|
||||
if pick != cur {
|
||||
actions.push(("renderQueue.setTemplateDefault", json!({"kind": kind, "slot": format!("{slot:?}"), "name": pick})));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Settings").font(Tokens::semibold(12.5)).color(t.text));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Settings Name:");
|
||||
let r = egui::ComboBox::from_id_salt("tpl-select").selected_text(&st.selected).width(300.0).show_ui(ui, |ui| {
|
||||
for n in &list {
|
||||
ui.selectable_value(&mut st.selected, n.clone(), n);
|
||||
}
|
||||
});
|
||||
app.auto.add("templates.select", r.response.rect, &st.selected);
|
||||
});
|
||||
let builtin = RenderTemplates::is_builtin(k, &st.selected)
|
||||
&& !match k {
|
||||
TemplateKind::RenderSettings => templates.render_settings.iter().any(|r| r.name.eq_ignore_ascii_case(&st.selected)),
|
||||
TemplateKind::OutputModule => templates.output_modules.iter().any(|m| m.name.eq_ignore_ascii_case(&st.selected)),
|
||||
};
|
||||
// The selected template's settings.
|
||||
let lines: Vec<(&'static str, String)> = if st.output {
|
||||
templates.output_module(&st.selected).map(|m| m.describe()).unwrap_or_default()
|
||||
} else {
|
||||
templates.render_settings(&st.selected).map(|r| r.describe(None)).unwrap_or_default()
|
||||
};
|
||||
egui::Frame::new().fill(t.field_bg).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
|
||||
ui.set_min_height(180.0);
|
||||
ui.set_width(ui.available_width());
|
||||
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
|
||||
egui::Grid::new("tpl-lines").num_columns(2).spacing(vec2(12.0, 2.0)).show(ui, |ui| {
|
||||
for (k, v) in &lines {
|
||||
ui.label(egui::RichText::new(*k).color(t.text_dim).size(11.5));
|
||||
ui.label(egui::RichText::new(v).size(11.5));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.button("Duplicate");
|
||||
app.auto.add("templates.duplicate", r.rect, "Duplicate");
|
||||
if r.clicked() {
|
||||
let mut n = format!("{} copy", st.selected);
|
||||
let mut i = 2;
|
||||
while list.iter().any(|x| x.eq_ignore_ascii_case(&n)) {
|
||||
n = format!("{} copy {i}", st.selected);
|
||||
i += 1;
|
||||
}
|
||||
actions.push(("renderQueue.saveTemplate", json!({"kind": kind, "name": n, "from": st.selected})));
|
||||
st.selected = n;
|
||||
}
|
||||
let r = ui.add_enabled(!builtin, egui::Button::new("Delete"));
|
||||
app.auto.add("templates.delete", r.rect, "Delete");
|
||||
if r.clicked() {
|
||||
actions.push(("renderQueue.deleteTemplate", json!({"kind": kind, "name": st.selected})));
|
||||
}
|
||||
if builtin {
|
||||
ui.label(egui::RichText::new("built-in").color(t.text_faint).size(11.0));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
// New template from the selected (or only) Render Queue item.
|
||||
let queue = &app.session.project.render_queue;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("New from Render Queue item:");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut st.new_name).hint_text("template name").desired_width(180.0));
|
||||
app.auto.add("templates.newName", r.rect, &st.new_name);
|
||||
let item = queue.last().map(|i| i.id);
|
||||
let ok = item.is_some() && !st.new_name.trim().is_empty();
|
||||
let r = ui.add_enabled(ok, egui::Button::new(format!("Save from #{}", queue.len().max(1))));
|
||||
app.auto.add("templates.saveFromItem", r.rect, "Save from item");
|
||||
if r.clicked()
|
||||
&& let Some(id) = item
|
||||
{
|
||||
actions.push(("renderQueue.saveTemplate", json!({"kind": kind, "name": st.new_name.trim(), "item": id})));
|
||||
st.selected = st.new_name.trim().to_string();
|
||||
st.new_name.clear();
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
|
||||
app.auto.add("templates.ok", r.rect, "OK");
|
||||
close = r.clicked();
|
||||
});
|
||||
});
|
||||
for (id, p) in actions {
|
||||
if let Err(e) = app.session.execute(id, p) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(state_id(), st));
|
||||
if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
//! ScriptUI windows: the dialogs, palettes and dockable panels scripts build
|
||||
//! (`Session::script_ui`). Each control is drawn at the bounds the engine's ScriptUI layout
|
||||
//! computed; what the user does goes back through the `scriptui.click` / `scriptui.set` /
|
||||
//! `scriptui.close` commands, which run the script's handlers. Every control registers an
|
||||
//! automation id `scriptui.<window>.<control>` (and `scriptui.<window>.<name>` when the script
|
||||
//! named it).
|
||||
|
||||
use effectcraft_engine::scriptui::{ScriptWindow, Widget, WidgetKind, WindowKind, layout};
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, UiBuilder, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::EffectcraftApp;
|
||||
use crate::dock::PanelKind;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
enum Act {
|
||||
Click(u32, u32),
|
||||
Set(u32, u32, Value),
|
||||
Close(u32),
|
||||
}
|
||||
|
||||
fn rect_of(origin: egui::Pos2, b: [f64; 4]) -> Rect {
|
||||
Rect::from_min_size(origin + vec2(b[0] as f32, b[1] as f32), vec2(b[2] as f32, b[3] as f32))
|
||||
}
|
||||
|
||||
fn register(app: &mut EffectcraftApp, win: u32, w: &Widget, rect: Rect) {
|
||||
let label = if w.text.is_empty() { format!("{:?}", w.kind) } else { w.text.clone() };
|
||||
app.auto.add(&format!("scriptui.{win}.{}", w.id), rect, &label);
|
||||
if !w.name.is_empty() {
|
||||
app.auto.add(&format!("scriptui.{win}.{}", w.name), rect, &label);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw `w`'s children (and, for containers, their children) at `origin` + bounds.
|
||||
fn draw_children(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, win: u32, w: &Widget, acts: &mut Vec<Act>) {
|
||||
let t = app.tokens;
|
||||
if w.kind == WidgetKind::TabbedPanel {
|
||||
// Tab headers, then the active tab only.
|
||||
let r = rect_of(origin, w.bounds);
|
||||
let mut x = r.min.x;
|
||||
for (i, tab) in w.children.iter().enumerate() {
|
||||
let tw = (tab.text.chars().count() as f32 * 7.0 + 20.0).max(48.0);
|
||||
let hr = Rect::from_min_size(pos2(x, r.min.y), vec2(tw, 22.0));
|
||||
let active = i == w.active_tab;
|
||||
let resp = ui.interact(hr, egui::Id::new(("sui-tab", win, tab.id)), Sense::click());
|
||||
ui.painter().rect_filled(
|
||||
hr,
|
||||
3.0,
|
||||
if active {
|
||||
t.row_selected
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.field_bg
|
||||
},
|
||||
);
|
||||
ui.painter().text(hr.center(), Align2::CENTER_CENTER, &tab.text, Tokens::ui(12.0), t.text);
|
||||
register(app, win, tab, hr);
|
||||
if resp.clicked() && !active && tab.enabled {
|
||||
acts.push(Act::Click(win, tab.id));
|
||||
}
|
||||
x += tw + 2.0;
|
||||
}
|
||||
ui.painter().rect_stroke(Rect::from_min_max(pos2(r.min.x, r.min.y + 22.0), r.max), 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
if let Some(tab) = w.children.get(w.active_tab) {
|
||||
draw_children(app, ui, origin, win, tab, acts);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for c in w.children.iter().filter(|c| c.visible) {
|
||||
draw_widget(app, ui, origin, win, c, acts);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, win: u32, w: &Widget, acts: &mut Vec<Act>) {
|
||||
let t = app.tokens;
|
||||
let rect = rect_of(origin, w.bounds);
|
||||
register(app, win, w, rect);
|
||||
let id = egui::Id::new(("sui", win, w.id));
|
||||
let tip = |r: egui::Response| if w.help_tip.is_empty() { r } else { r.on_hover_text(&w.help_tip) };
|
||||
match w.kind {
|
||||
WidgetKind::Group | WidgetKind::Tab | WidgetKind::Window | WidgetKind::TabbedPanel => draw_children(app, ui, origin, win, w, acts),
|
||||
WidgetKind::Panel => {
|
||||
let p = ui.painter();
|
||||
p.rect_stroke(rect.shrink(1.0), 4.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
if !w.text.is_empty() {
|
||||
let g = p.layout_no_wrap(w.text.clone(), Tokens::ui(11.5), t.text_dim);
|
||||
let at = pos2(rect.min.x + 10.0, rect.min.y - 1.0);
|
||||
p.rect_filled(Rect::from_min_size(at - vec2(3.0, 0.0), g.size() + vec2(6.0, 0.0)), 0.0, t.panel_bg);
|
||||
p.galley(at, g, t.text_dim);
|
||||
}
|
||||
draw_children(app, ui, origin, win, w, acts);
|
||||
}
|
||||
WidgetKind::Button | WidgetKind::IconButton => {
|
||||
let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, egui::Button::new(&w.text))).inner;
|
||||
if tip(r).clicked() {
|
||||
acts.push(Act::Click(win, w.id));
|
||||
}
|
||||
}
|
||||
WidgetKind::StaticText => {
|
||||
let col = if w.enabled { t.text } else { t.text_faint };
|
||||
ui.painter().with_clip_rect(rect.expand(2.0)).text(pos2(rect.min.x, rect.min.y + 1.0), Align2::LEFT_TOP, &w.text, Tokens::ui(12.0), col);
|
||||
}
|
||||
WidgetKind::EditText => {
|
||||
// The text being typed lives here until the field loses focus (then onChange).
|
||||
let mut buf: String = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| w.text.clone());
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
if !focused {
|
||||
buf = w.text.clone();
|
||||
}
|
||||
let te = if w.multiline { egui::TextEdit::multiline(&mut buf) } else { egui::TextEdit::singleline(&mut buf) };
|
||||
let te = te.id(id).interactive(!w.read_only).font(Tokens::ui(12.0));
|
||||
let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, te)).inner;
|
||||
let r = tip(r);
|
||||
if r.lost_focus() && buf != w.text {
|
||||
acts.push(Act::Set(win, w.id, json!(buf)));
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, buf));
|
||||
}
|
||||
WidgetKind::Checkbox => {
|
||||
let mut v = w.checked;
|
||||
let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(snug(rect, &w.text), egui::Checkbox::new(&mut v, &w.text))).inner;
|
||||
if tip(r).clicked() {
|
||||
acts.push(Act::Click(win, w.id));
|
||||
}
|
||||
}
|
||||
WidgetKind::RadioButton => {
|
||||
let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(snug(rect, &w.text), egui::RadioButton::new(w.checked, &w.text))).inner;
|
||||
if tip(r).clicked() && !w.checked {
|
||||
acts.push(Act::Click(win, w.id));
|
||||
}
|
||||
}
|
||||
WidgetKind::Slider | WidgetKind::Scrollbar => {
|
||||
// While dragging, the value lives here; the release sends it (onChanging / onChange).
|
||||
let mut v: f64 = ui.data(|d| d.get_temp::<f64>(id)).unwrap_or(w.value);
|
||||
let (lo, hi) = if w.max > w.min { (w.min, w.max) } else { (0.0, 100.0) };
|
||||
let r = ui
|
||||
.add_enabled_ui(w.enabled, |ui| {
|
||||
ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
|
||||
ui.spacing_mut().slider_width = rect.width() - 4.0;
|
||||
ui.add(egui::Slider::new(&mut v, lo..=hi).show_value(false))
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
let r = tip(r);
|
||||
if r.dragged() {
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<f64>(id));
|
||||
if r.drag_stopped() || r.changed() {
|
||||
acts.push(Act::Set(win, w.id, json!(v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
WidgetKind::Progressbar => {
|
||||
let span = if w.max > w.min { w.max - w.min } else { 100.0 };
|
||||
let f = ((w.value - w.min) / span).clamp(0.0, 1.0) as f32;
|
||||
ui.put(rect, egui::ProgressBar::new(f).desired_height(rect.height()));
|
||||
}
|
||||
WidgetKind::DropDownList => {
|
||||
let cur = w.selection.first().and_then(|i| w.items.get(*i)).cloned().unwrap_or_default();
|
||||
let mut pick: Option<usize> = None;
|
||||
ui.add_enabled_ui(w.enabled, |ui| {
|
||||
ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
|
||||
egui::ComboBox::from_id_salt(id).selected_text(cur).width(rect.width() - 8.0).show_ui(ui, |ui| {
|
||||
for (i, it) in w.items.iter().enumerate() {
|
||||
if it == "-" {
|
||||
ui.separator();
|
||||
} else if ui.selectable_label(w.selection.contains(&i), it).clicked() {
|
||||
pick = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(i) = pick
|
||||
&& !w.selection.contains(&i)
|
||||
{
|
||||
acts.push(Act::Set(win, w.id, json!(i)));
|
||||
}
|
||||
}
|
||||
WidgetKind::ListBox => {
|
||||
let mut pick: Option<usize> = None;
|
||||
ui.painter().rect_filled(rect, 2.0, t.field_bg);
|
||||
ui.add_enabled_ui(w.enabled, |ui| {
|
||||
ui.scope_builder(UiBuilder::new().max_rect(rect.shrink(2.0)), |ui| {
|
||||
egui::ScrollArea::vertical().id_salt(id).max_height(rect.height() - 4.0).show(ui, |ui| {
|
||||
ui.set_width(rect.width() - 8.0);
|
||||
for (i, it) in w.items.iter().enumerate() {
|
||||
if ui.selectable_label(w.selection.contains(&i), it).clicked() {
|
||||
pick = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(i) = pick {
|
||||
acts.push(Act::Set(win, w.id, json!(i)));
|
||||
}
|
||||
}
|
||||
WidgetKind::Image | WidgetKind::Unknown => {
|
||||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkboxes and radio buttons sit at the left of their (possibly filled) bounds.
|
||||
fn snug(r: Rect, text: &str) -> Rect {
|
||||
let w = (text.chars().count() as f32 * 7.0 + 26.0).min(r.width());
|
||||
Rect::from_min_size(r.min, vec2(w, r.height()))
|
||||
}
|
||||
|
||||
fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec<Act>) {
|
||||
for a in acts {
|
||||
let (cmd, p) = match a {
|
||||
Act::Click(w, id) => ("scriptui.click", json!({"window": w, "widget": id})),
|
||||
Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})),
|
||||
Act::Close(w) => ("scriptui.close", json!({"window": w})),
|
||||
};
|
||||
match crate::menus::invoke(app, ctx, cmd, p) {
|
||||
Ok(r) => {
|
||||
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
|
||||
let msg = e["message"].as_str().unwrap_or("script error").to_string();
|
||||
app.toast = Some((msg, ctx.input(|i| i.time)));
|
||||
} else if let Some(o) = r["output"].as_str().filter(|o| !o.is_empty()) {
|
||||
app.ui.status = o.lines().last().unwrap_or_default().to_string();
|
||||
}
|
||||
}
|
||||
Err(e) => app.ui.status = e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The floating script windows (dialogs, palettes, windows), drawn over the app.
|
||||
pub fn show_windows(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let windows: Vec<ScriptWindow> = app.session.script_ui.windows.iter().filter(|w| w.kind != WindowKind::Panel).cloned().collect();
|
||||
if windows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let t = app.tokens;
|
||||
let mut acts = vec![];
|
||||
let screen = ctx.content_rect();
|
||||
if windows.iter().any(|w| w.modal) {
|
||||
// A modal dialog blocks the app behind it, as in After Effects.
|
||||
let r = egui::Area::new(egui::Id::new("scriptui-modal-block")).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| {
|
||||
ui.allocate_exact_size(screen.size(), Sense::click_and_drag());
|
||||
ui.painter().rect_filled(screen, 0.0, Color32::from_black_alpha(90));
|
||||
});
|
||||
let _ = r;
|
||||
}
|
||||
for w in &windows {
|
||||
let size = vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32);
|
||||
let mut open = true;
|
||||
let order = if w.modal { egui::Order::Foreground } else { egui::Order::Middle };
|
||||
let resp = egui::Window::new(if w.title.is_empty() { &w.script } else { &w.title })
|
||||
.id(egui::Id::new(("scriptui-window", w.id)))
|
||||
.order(order)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.open(&mut open)
|
||||
.default_pos(screen.center() - size / 2.0)
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel_bg))
|
||||
.show(ctx, |ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
|
||||
draw_children(app, ui, rect.min, w.id, &w.root, &mut acts);
|
||||
rect
|
||||
});
|
||||
if let Some(r) = resp {
|
||||
app.auto.add(&format!("scriptui.{}", w.id), r.response.rect, &w.title);
|
||||
}
|
||||
if !open {
|
||||
acts.push(Act::Close(w.id));
|
||||
}
|
||||
}
|
||||
perform(app, ctx, acts);
|
||||
}
|
||||
|
||||
/// A dockable ScriptUI panel's content (Window ▸ <panel>.jsx).
|
||||
pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) {
|
||||
let t = app.tokens;
|
||||
let Some(mut w) = app.session.script_ui.window(id).cloned() else {
|
||||
ui.painter().text(rect.center(), Align2::CENTER_CENTER, "This ScriptUI panel has closed", Tokens::ui(12.0), t.text_faint);
|
||||
return;
|
||||
};
|
||||
// Docked panels fill their frame (ScriptUI lays them out at the panel's size).
|
||||
let (pw, ph) = (rect.width() as f64, rect.height() as f64);
|
||||
w.root.fixed_bounds = Some([0.0, 0.0, pw.max(w.root.bounds[2]), ph.max(w.root.bounds[3])]);
|
||||
layout(&mut w);
|
||||
let mut acts = vec![];
|
||||
let content = rect;
|
||||
egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover());
|
||||
draw_children(app, ui, r.min, id, &w.root, &mut acts);
|
||||
});
|
||||
app.auto.add(&format!("scriptui.{id}"), rect, &w.title);
|
||||
let ctx = ui.ctx().clone();
|
||||
perform(app, &ctx, acts);
|
||||
}
|
||||
|
||||
/// Tab title of a script panel.
|
||||
pub fn panel_title(app: &EffectcraftApp, id: u32) -> Option<String> {
|
||||
app.session.script_ui.window(id).map(|w| if w.title.is_empty() { w.script.clone() } else { w.title.clone() })
|
||||
}
|
||||
|
||||
/// Close the dock panels whose script window has gone (the script closed it, or a saved
|
||||
/// workspace from an earlier run).
|
||||
pub fn sync_panels(app: &mut EffectcraftApp) {
|
||||
let mut all = vec![];
|
||||
app.ui.dock.panels(&mut all);
|
||||
for f in &app.ui.floating {
|
||||
all.extend(f.panels.iter().copied());
|
||||
}
|
||||
for p in all {
|
||||
if let PanelKind::ScriptPanel(id) = p
|
||||
&& app.session.script_ui.window(id).is_none()
|
||||
{
|
||||
app.close_panel(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -404,6 +404,27 @@ fn mask_mode(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, p
|
|||
bx += 34.0;
|
||||
}
|
||||
y += 34.0;
|
||||
// Face Tracking (Detailed Features): Extract & Copy Face Measurements.
|
||||
let has_points = app
|
||||
.session
|
||||
.active_comp()
|
||||
.and_then(|c| c.layer(layer))
|
||||
.and_then(|l| l.effects())
|
||||
.is_some_and(|fx| fx.groups().any(|g| g.match_id == effectcraft_engine::effects::face_track::POINTS_ID));
|
||||
if method == MaskMethod::FaceDetailed || has_points {
|
||||
let r = Rect::from_min_size(pos2(x0, y), vec2(w.min(260.0), 24.0));
|
||||
if button(app, ui, r, "Extract & Copy Face Measurements", has_points && !running, "tracker.mask.extractFace") {
|
||||
match crate::menus::invoke(app, ctx, "track.extractFaceMeasurements", json!({"layer": layer.0})) {
|
||||
Ok(v) => {
|
||||
if let Some(text) = v.get("clipboard").and_then(|t| t.as_str()) {
|
||||
ctx.copy_text(text.to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => app.ui.status = e,
|
||||
}
|
||||
}
|
||||
y += 32.0;
|
||||
}
|
||||
if let Some(pr) = app.session.mask_track_progress() {
|
||||
let bar = Rect::from_min_size(pos2(x0, y), vec2(w, 6.0));
|
||||
p.rect_filled(bar, 3.0, t.field_bg);
|
||||
|
|
@ -419,7 +440,12 @@ fn mask_mode(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, p
|
|||
);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
} else {
|
||||
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Tracks the pixels inside the mask and keys its Mask Path.", Tokens::ui(11.0), t.text_faint);
|
||||
let hint = if method.is_face() {
|
||||
"Finds the face inside the mask and keys its outline (and, with Detailed Features, Face Track Points)."
|
||||
} else {
|
||||
"Tracks the pixels inside the mask and keys its Mask Path."
|
||||
};
|
||||
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, hint, Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,17 @@ enum Gesture {
|
|||
pin: u64,
|
||||
inv: Mat3,
|
||||
},
|
||||
/// Puppet tool Record mode (⌘/Ctrl-drag a pin): the drag is sampled in real time (seconds
|
||||
/// since it began, layer space) and becomes keyframes on release (`puppet.recordPin`); the
|
||||
/// current time runs along while recording.
|
||||
PuppetRecord {
|
||||
layer: LayerId,
|
||||
pin: u64,
|
||||
inv: Mat3,
|
||||
began: f64,
|
||||
cti: Tick,
|
||||
samples: Vec<(f64, [f64; 2])>,
|
||||
},
|
||||
/// Pen tool: pulling the tangents of the vertex just placed.
|
||||
Pen {
|
||||
layer: LayerId,
|
||||
|
|
@ -934,7 +945,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }),
|
||||
t if t.puppet_kind().is_some() => pin_hits.iter().rev().find(|h| h.pos.distance(press) < 8.0).and_then(|h| {
|
||||
let l = comp.layer(h.layer)?;
|
||||
Some(Gesture::PuppetPin { layer: h.layer, pin: h.pin, inv: l2c(&ectx, l).0.inverse()? })
|
||||
let inv = l2c(&ectx, l).0.inverse()?;
|
||||
if mods.command {
|
||||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
let began = ui.input(|i| i.time);
|
||||
return Some(Gesture::PuppetRecord { layer: h.layer, pin: h.pin, inv, began, cti: time, samples: vec![(0.0, [lp.x, lp.y])] });
|
||||
}
|
||||
Some(Gesture::PuppetPin { layer: h.layer, pin: h.pin, inv })
|
||||
}),
|
||||
Tool::Orbit | Tool::PanCamera | Tool::Dolly => Some(Gesture::Camera { tool }),
|
||||
Tool::Selection if super::tracker::hit_at(&track_hits, press).is_some() => {
|
||||
|
|
@ -1170,6 +1187,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
let _ = app.session.execute("mask.setVertex", params);
|
||||
}
|
||||
Gesture::PuppetRecord { layer, pin, inv, began, cti, mut samples } => {
|
||||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
let now = ui.input(|i| i.time) - began;
|
||||
if samples.last().is_none_or(|s| now > s.0) {
|
||||
samples.push((now, [lp.x, lp.y]));
|
||||
}
|
||||
// The current time runs with the recording (Record Options ▸ Speed).
|
||||
let speed = app.session.state.puppet.record_speed.max(1.0) / 100.0;
|
||||
app.session.set_time(cti + Tick::from_seconds_f64(now / speed));
|
||||
painter.circle_filled(pos, 6.0, Color32::from_rgb(0xe0, 0x30, 0x30));
|
||||
painter.text(pos + vec2(10.0, -10.0), Align2::LEFT_BOTTOM, "Recording", Tokens::ui(11.0), Color32::from_rgb(0xff, 0x60, 0x60));
|
||||
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::PuppetRecord { layer, pin, inv, began, cti, samples }));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
Gesture::PuppetPin { layer, pin, inv } => {
|
||||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
if let Err(e) = app.session.execute("puppet.movePin", json!({"layer": layer.0, "pin": pin, "position": [lp.x, lp.y], "merge": merge})) {
|
||||
|
|
@ -1202,6 +1233,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if resp.drag_stopped() {
|
||||
if let (Some(g), Some(pos)) = (gesture, resp.interact_pointer_pos()) {
|
||||
match g {
|
||||
Gesture::PuppetRecord { layer, pin, cti, samples, .. } => {
|
||||
let pts: Vec<serde_json::Value> = samples.iter().map(|(t, p)| json!([t, p[0], p[1]])).collect();
|
||||
if let Err(e) = app.session.execute("puppet.recordPin", json!({"layer": layer.0, "pin": pin, "samples": pts, "start": cti.seconds()})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
Gesture::Create { tool, start } => {
|
||||
let end = map.to_comp(pos);
|
||||
create_shape(app, tool, start, end, mods.shift);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue