Merge branch 'main' into worktree-agent-af1868dabbf03697e
# Conflicts: # docs/parity.md
This commit is contained in:
commit
8f4546fca4
|
|
@ -1158,6 +1158,7 @@ name = "effectcraft-automation"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-script",
|
||||
"image",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -1301,6 +1302,7 @@ dependencies = [
|
|||
"effectcraft-expr",
|
||||
"effectcraft-media",
|
||||
"effectcraft-project",
|
||||
"effectcraft-script",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
|
|
@ -1342,6 +1344,7 @@ dependencies = [
|
|||
"effectcraft-raster",
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"exr",
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
|
|
@ -1350,6 +1353,7 @@ dependencies = [
|
|||
"image",
|
||||
"log",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
|
@ -1419,6 +1423,21 @@ dependencies = [
|
|||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
"effectcraft-expr",
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-path",
|
||||
"effectcraft-project",
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1795,6 +1814,7 @@ dependencies = [
|
|||
"miniz_oxide 0.8.9",
|
||||
"num-complex",
|
||||
"pulp",
|
||||
"rayon-core",
|
||||
"smallvec",
|
||||
"zune-inflate",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ effectcraft-engine = { path = "crates/engine" }
|
|||
effectcraft-host = { path = "crates/host" }
|
||||
effectcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
effectcraft-automation = { path = "crates/automation" }
|
||||
effectcraft-script = { path = "crates/script" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
|
|
@ -53,6 +54,7 @@ kurbo = { version = "0.13", features = ["serde"] }
|
|||
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd"] }
|
||||
skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
harfrust = { version = "0.12", default-features = false, features = ["std"] }
|
||||
exr = "1.74"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001).
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
|
||||
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] render timings
|
||||
//! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half)
|
||||
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
|
||||
//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio
|
||||
//!
|
||||
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
|
||||
|
|
@ -32,7 +33,7 @@ use effectcraft_automation::tools::{self, Reply};
|
|||
use effectcraft_automation::{Backend, McpServer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|mcp> [args] [--json]
|
||||
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|script|mcp> [args] [--json]
|
||||
info project + engine summary
|
||||
commands [--filter TEXT] [--enabled] list engine commands
|
||||
exec <command-id> [--params JSON] run one engine command
|
||||
|
|
@ -47,6 +48,9 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
|||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] per-layer/effect render timings;
|
||||
--play N renders N consecutive frames with/without the layer cache;
|
||||
--gpu compares CPU and GPU ms/frame for every comp at Full and Half
|
||||
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
|
||||
(app.project, comps, layers, properties…); prints writeLn
|
||||
output and the result; errors exit 1 with file:line:col
|
||||
mcp [--bridge PORT] MCP server (JSON-RPC over stdio)
|
||||
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json --gpu
|
||||
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: JSON or bare string";
|
||||
|
|
@ -68,6 +72,7 @@ const VALUED: &[&str] = &[
|
|||
"--out",
|
||||
"--expression",
|
||||
"--depth",
|
||||
"--eval",
|
||||
// render
|
||||
"--format",
|
||||
"--start",
|
||||
|
|
@ -243,6 +248,49 @@ fn tool(b: &mut Backend, name: &str, a: Value) -> Result<Value, Failure> {
|
|||
}
|
||||
}
|
||||
|
||||
/// `script FILE.jsx [project]` / `script --eval CODE`: run JavaScript against a headless session
|
||||
/// (an empty project unless one is given, or `--demo`) or the running app (`--bridge`).
|
||||
fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
let (code, name) = match (args.opt("--eval"), args.pos.first()) {
|
||||
(Some(c), _) => (c.to_string(), "eval".to_string()),
|
||||
(None, Some(f)) => (std::fs::read_to_string(f).map_err(|e| Failure::Error(format!("cannot read {f}: {e}")))?, f.clone()),
|
||||
(None, None) => return usage_err("script needs a .jsx/.js file or --eval CODE"),
|
||||
};
|
||||
let mut b = backend(args, false)?;
|
||||
let r = b.exec("script.run", json!({"code": code, "name": name}))?;
|
||||
let saved = maybe_save(&mut b, args)?;
|
||||
if json_out {
|
||||
let mut o = r.clone();
|
||||
if let Some(p) = &saved {
|
||||
o["saved"] = json!(p);
|
||||
}
|
||||
emit(&o, true);
|
||||
} else {
|
||||
if let Some(out) = r["output"].as_str().filter(|o| !o.is_empty()) {
|
||||
println!("{out}");
|
||||
}
|
||||
if !r["result"].is_null() && r["error"].is_null() {
|
||||
println!("{}", r["result"]);
|
||||
}
|
||||
if let Some(p) = saved {
|
||||
eprintln!("saved {p}");
|
||||
}
|
||||
}
|
||||
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
|
||||
let pos = match (e["line"].as_u64(), e["column"].as_u64()) {
|
||||
(Some(l), Some(c)) => format!(":{l}:{c}"),
|
||||
(Some(l), None) => format!(":{l}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
let msg = format!("{}{pos}: {}", e["file"].as_str().unwrap_or(&name), e["message"].as_str().unwrap_or("script error"));
|
||||
if json_out {
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Err(Failure::Error(msg));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print a result: compact JSON with `--json`, else pretty JSON.
|
||||
fn emit(v: &Value, json_out: bool) {
|
||||
if json_out {
|
||||
|
|
@ -315,6 +363,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
}
|
||||
}
|
||||
}
|
||||
"script" => script_cmd(args, json_out)?,
|
||||
"exec" => {
|
||||
let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") };
|
||||
let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) {
|
||||
|
|
|
|||
|
|
@ -131,3 +131,34 @@ fn mcp_over_stdio() {
|
|||
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_file_and_eval() {
|
||||
let proj = tmp("scripted.ecproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
let jsx = tmp("build.jsx");
|
||||
std::fs::write(
|
||||
&jsx,
|
||||
"app.beginUndoGroup('Build');\nvar c = app.project.items.addComp('FromJsx', 160, 90, 1, 2, 24);\nc.layers.addSolid([1, 0, 0], 'Red', 160, 90, 1);\napp.endUndoGroup();\nwriteLn('built ' + c.name);\nc.numLayers",
|
||||
)
|
||||
.unwrap();
|
||||
let v = ok_json(&["script", jsx.to_str().unwrap(), "--save-as", p]);
|
||||
assert_eq!(v["ok"], json!(true), "{v}");
|
||||
assert_eq!(v["result"], json!(1));
|
||||
assert_eq!(v["output"], json!("built FromJsx"));
|
||||
assert_eq!(v["saved"], p);
|
||||
// Run against the saved project (positional), plain output.
|
||||
let out = bin().args(["script", "--eval", "app.project.item(1).name + ' has ' + app.project.item(1).numLayers", p]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "\"FromJsx has 1\"");
|
||||
// Errors: exit 1 with file:line:col.
|
||||
let bad = tmp("bad.jsx");
|
||||
std::fs::write(&bad, "var a = 1;\n\nmissingFunction();\n").unwrap();
|
||||
let out = bin().args(["script", bad.to_str().unwrap()]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("bad.jsx:3:1") && err.contains("missingFunction"), "{err}");
|
||||
let (code, v) = run_json(&["script", "--eval", "\nnope()"]);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(v["error"]["line"], json!(2));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ fn query_flag(name: &str) -> bool {
|
|||
web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).is_some_and(|p| p.has(name))
|
||||
}
|
||||
|
||||
/// A session wired for the browser: media decoding from memory, expressions, Render Queue export
|
||||
/// to downloads.
|
||||
/// A session wired for the browser: media decoding from memory, expressions, scripting, Render
|
||||
/// Queue export to downloads.
|
||||
pub fn session() -> Session {
|
||||
let pool = Arc::new(effectcraft_media::MediaPool::new());
|
||||
Session {
|
||||
|
|
@ -115,6 +115,7 @@ pub fn session() -> Session {
|
|||
importer: Some(Arc::new(files::WebImporter { pool })),
|
||||
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
||||
expr_check: Some(effectcraft_expr::check_syntax),
|
||||
script: Some(effectcraft_host::script::runner),
|
||||
exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })),
|
||||
..Default::default()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,3 +14,6 @@ effectcraft-engine = { workspace = true }
|
|||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
effectcraft-script = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ fn initialize_and_list_tools() {
|
|||
for want in [
|
||||
"list_commands",
|
||||
"execute_command",
|
||||
"run_script",
|
||||
"get_project",
|
||||
"get_comp",
|
||||
"get_layer",
|
||||
|
|
@ -263,3 +264,30 @@ fn bridge_rejects_non_loopback() {
|
|||
assert!(Backend::bridge("10.0.0.1:9877").is_err());
|
||||
assert!(Backend::bridge("localhost:9877").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_script_round_trip() {
|
||||
let mut sess = Session::default();
|
||||
effectcraft_script::install(&mut sess);
|
||||
let mut s = McpServer::new(Backend::headless(sess));
|
||||
let r = call_json(
|
||||
&mut s,
|
||||
"run_script",
|
||||
json!({"code": "var c = app.project.items.addComp('Scripted', 320, 180, 1, 2, 24);\nc.layers.addText('Hi');\nwriteLn('made ' + c.name);\nc.numLayers"}),
|
||||
);
|
||||
assert_eq!(r["ok"], json!(true), "{r}");
|
||||
assert_eq!(r["result"], json!(1));
|
||||
assert_eq!(r["output"], json!("made Scripted"));
|
||||
// The edit is visible to the other tools.
|
||||
let p = call_json(&mut s, "get_project", json!({}));
|
||||
assert!(p.to_string().contains("Scripted"), "{p}");
|
||||
// Errors come back with their line.
|
||||
let r = call_json(&mut s, "run_script", json!({"code": "var a = 1;\nundefinedFn();", "name": "bad.jsx"}));
|
||||
assert_eq!(r["ok"], json!(false));
|
||||
assert_eq!(r["error"]["line"], json!(2));
|
||||
assert_eq!(r["error"]["file"], json!("bad.jsx"));
|
||||
// Without a scripting engine the command explains itself.
|
||||
let mut bare = server();
|
||||
let (c, err) = call(&mut bare, "run_script", json!({"code": "1"}));
|
||||
assert!(err && c[0]["text"].as_str().unwrap().contains("not available"), "{c:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,15 @@ fn execute_command(b: &mut Backend, a: &Value) -> Result<Reply> {
|
|||
json_reply(b.exec(id, params)?)
|
||||
}
|
||||
|
||||
fn run_script(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
let code = get(a, "code").and_then(Value::as_str).ok_or_else(|| Error::BadArgs("missing `code` (JavaScript)".into()))?;
|
||||
let mut p = json!({"code": code});
|
||||
if let Some(n) = get(a, "name") {
|
||||
p["name"] = n.clone();
|
||||
}
|
||||
json_reply(b.exec("script.run", p)?)
|
||||
}
|
||||
|
||||
fn get_project(b: &mut Backend, _: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("project.summary", json!({}))?)
|
||||
}
|
||||
|
|
@ -386,6 +395,21 @@ static TOOLS: &[ToolDef] = &[
|
|||
},
|
||||
run: execute_command,
|
||||
},
|
||||
ToolDef {
|
||||
name: "run_script",
|
||||
description: "Run JavaScript with an After Effects-style scripting object model (app, app.project, items.addComp, comp.layers.addSolid/addText/addShape/addNull/addCamera/addLight, layer.property(\"ADBE Transform Group\").property(\"ADBE Position\").setValueAtTime(t, v), Effects.addProperty(\"ADBE Gaussian Blur 2\"), expressions, markers, renderQueue…). Every edit is an undoable engine command; app.beginUndoGroup/endUndoGroup make one undo step. Returns {ok, result (last expression value), output (writeLn/$.writeln/alert text), error: {message, line, column} | null}. File access is limited to the project folder unless the user allows scripts to write files.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"code": {"type": "string", "description": "JavaScript source, e.g. `var c = app.project.items.addComp(\"Main\", 1920, 1080, 1, 5, 30); c.layers.addText(\"Hi\"); c.numLayers`."},
|
||||
"name": {"type": "string", "description": "Script name used in error messages (default `script`)."}
|
||||
}),
|
||||
&["code"],
|
||||
)
|
||||
},
|
||||
run: run_script,
|
||||
},
|
||||
ToolDef {
|
||||
name: "get_project",
|
||||
description: "Project overview: file path, dirty flag, bit depth, every item (id, name, type Composition/Footage/Solid/Folder, size, duration, layer count), the active comp id, current time (s), selected layers and the undo stack.",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,5 @@ effectcraft-project = { workspace = true }
|
|||
effectcraft-raster = { workspace = true }
|
||||
effectcraft-track = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Audio effects (Effect > Audio): Backwards, Bass & Treble, Delay, Flange & Chorus,
|
||||
//! High-Low Pass, Modulator, Parametric EQ, Reverb, Stereo Mixer and Tone.
|
||||
//! Audio effects (Effect > Audio): Backwards, Bass & Treble, Compressor, Delay, Distortion,
|
||||
//! Flange & Chorus, Gate, High-Low Pass, Modulator, Parametric EQ, Reverb, Stereo Mixer and Tone.
|
||||
//!
|
||||
//! They are ordinary effect instances on a layer (so their parameters animate and show in
|
||||
//! Effect Controls), pass video through unchanged, and are applied to the layer's audio by the
|
||||
|
|
@ -12,7 +12,10 @@
|
|||
//! DSP is textbook: second-order sections use the public RBJ "Audio EQ Cookbook" formulas;
|
||||
//! Reverb is a Schroeder/Moorer network (parallel low-pass feedback combs into series
|
||||
//! all-passes, as in the published Freeverb description); chorus/flange/vibrato are modulated
|
||||
//! fractional delay lines.
|
||||
//! fractional delay lines. Compressor and Gate are feed-forward dynamics processors on a
|
||||
//! stereo-linked peak envelope (one-pole attack/release smoothing, the textbook quadratic soft-knee
|
||||
//! gain computer); Distortion is a static waveshaper (soft clip, hard clip, fold-back, bit crush)
|
||||
//! followed by a one-pole tone filter.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
|
|
@ -60,6 +63,20 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("treble", "Treble", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
|
||||
],
|
||||
),
|
||||
spec(
|
||||
"ec.audio.compressor",
|
||||
"Compressor",
|
||||
vec![
|
||||
p("threshold", "Threshold (dB)", num(-20.0), slider(-60.0, 0.0, -60.0, 0.0, 2)),
|
||||
p("ratio", "Ratio", num(4.0), slider(1.0, 30.0, 1.0, 20.0, 2)),
|
||||
p("attack", "Attack (ms)", num(10.0), slider(0.0, 500.0, 0.0, 200.0, 2)),
|
||||
p("release", "Release (ms)", num(100.0), slider(1.0, 5000.0, 1.0, 1000.0, 2)),
|
||||
p("knee", "Knee (dB)", num(0.0), slider(0.0, 24.0, 0.0, 24.0, 2)),
|
||||
p("makeupGain", "Makeup Gain (dB)", num(0.0), slider(-24.0, 36.0, 0.0, 24.0, 2)),
|
||||
pp("dryOut", "Dry Out", pct(0.0)),
|
||||
pp("wetOut", "Wet Out", pct(100.0)),
|
||||
],
|
||||
),
|
||||
spec(
|
||||
"ec.audio.delay",
|
||||
"Delay",
|
||||
|
|
@ -71,6 +88,19 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
pp("wetOut", "Wet Out", pct(75.0)),
|
||||
],
|
||||
),
|
||||
spec(
|
||||
"ec.audio.distortion",
|
||||
"Distortion",
|
||||
vec![
|
||||
p("distortionType", "Distortion Type", Value::Enum(0), popup(&["Soft Clip", "Hard Clip", "Fold Back", "Bit Crush"])),
|
||||
p("drive", "Drive (dB)", num(12.0), slider(0.0, 48.0, 0.0, 48.0, 2)),
|
||||
p("bitDepth", "Bit Depth", num(8.0), slider(1.0, 16.0, 1.0, 16.0, 0)),
|
||||
p("tone", "Tone (Hz)", num(8000.0), slider(200.0, 20000.0, 200.0, 20000.0, 1)),
|
||||
p("outputGain", "Output Gain (dB)", num(-6.0), slider(-48.0, 12.0, -24.0, 12.0, 2)),
|
||||
pp("dryOut", "Dry Out", pct(0.0)),
|
||||
pp("wetOut", "Wet Out", pct(100.0)),
|
||||
],
|
||||
),
|
||||
spec(
|
||||
"ec.audio.flangechorus",
|
||||
"Flange & Chorus",
|
||||
|
|
@ -86,6 +116,17 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
pp("wetOut", "Wet Out", pct(50.0)),
|
||||
],
|
||||
),
|
||||
spec(
|
||||
"ec.audio.gate",
|
||||
"Gate",
|
||||
vec![
|
||||
p("threshold", "Threshold (dB)", num(-40.0), slider(-90.0, 0.0, -80.0, 0.0, 2)),
|
||||
p("attack", "Attack (ms)", num(1.0), slider(0.0, 500.0, 0.0, 100.0, 2)),
|
||||
p("hold", "Hold (ms)", num(50.0), slider(0.0, 5000.0, 0.0, 500.0, 2)),
|
||||
p("release", "Release (ms)", num(100.0), slider(1.0, 5000.0, 1.0, 1000.0, 2)),
|
||||
p("range", "Range (dB)", num(-80.0), slider(-120.0, 0.0, -90.0, 0.0, 2)),
|
||||
],
|
||||
),
|
||||
spec(
|
||||
"ec.audio.highlowpass",
|
||||
"High-Low Pass",
|
||||
|
|
@ -167,6 +208,14 @@ pub fn preroll(id: &str, params: &Params) -> f64 {
|
|||
match id {
|
||||
"ec.audio.basstreble" | "ec.audio.highlowpass" | "ec.audio.parametriceq" => 0.05,
|
||||
"ec.audio.flangechorus" | "ec.audio.modulator" => 0.06,
|
||||
"ec.audio.distortion" => 0.01,
|
||||
// The envelope settles within a few attack/release time constants.
|
||||
"ec.audio.compressor" | "ec.audio.gate" => {
|
||||
let rel = params.f("release").max(1.0) / 1000.0;
|
||||
let att = params.f("attack").max(0.0) / 1000.0;
|
||||
let hold = if id == "ec.audio.gate" { params.f("hold").max(0.0) / 1000.0 } else { 0.0 };
|
||||
(9.0 * rel + 9.0 * att + hold + 0.01).min(10.0)
|
||||
}
|
||||
"ec.audio.delay" => {
|
||||
let d = params.f("delayTime").max(1.0) / 1000.0;
|
||||
let fb = (params.f("feedback") / 100.0).clamp(0.0, 0.99);
|
||||
|
|
@ -231,6 +280,9 @@ pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64
|
|||
}
|
||||
}
|
||||
"ec.audio.delay" => delay(params, buf, sr),
|
||||
"ec.audio.compressor" => compressor(params, buf, sr),
|
||||
"ec.audio.distortion" => distortion(params, buf, sr),
|
||||
"ec.audio.gate" => gate(params, buf, sr),
|
||||
"ec.audio.flangechorus" => flange_chorus(params, buf, sr, start),
|
||||
"ec.audio.modulator" => modulator(params, buf, sr, start),
|
||||
"ec.audio.reverb" => reverb(params, buf, sr),
|
||||
|
|
@ -362,6 +414,126 @@ fn delay(params: &Params, buf: &mut [f32], sr: f64) {
|
|||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn db_to_lin(db: f64) -> f64 {
|
||||
10f64.powf(db / 20.0)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn lin_to_db(v: f64) -> f64 {
|
||||
20.0 * v.max(1e-12).log10()
|
||||
}
|
||||
|
||||
/// One-pole smoothing coefficient for a time constant of `ms` milliseconds.
|
||||
fn smoothing(ms: f64, sr: f64) -> f64 {
|
||||
if ms <= 0.0 { 0.0 } else { (-1.0 / (ms / 1000.0 * sr)).exp() }
|
||||
}
|
||||
|
||||
/// Static compressor curve: gain change (dB, ≤ 0) for an input level `x` dB (quadratic soft
|
||||
/// knee of width `knee` around the threshold).
|
||||
pub fn compressor_gain_db(x: f64, threshold: f64, ratio: f64, knee: f64) -> f64 {
|
||||
let r = ratio.max(1.0);
|
||||
let over = x - threshold;
|
||||
let y = if knee > 0.0 && 2.0 * over.abs() <= knee {
|
||||
x + (1.0 / r - 1.0) * (over + knee / 2.0).powi(2) / (2.0 * knee)
|
||||
} else if over > 0.0 {
|
||||
threshold + over / r
|
||||
} else {
|
||||
x
|
||||
};
|
||||
y - x
|
||||
}
|
||||
|
||||
fn compressor(params: &Params, buf: &mut [f32], sr: f64) {
|
||||
let th = params.f("threshold");
|
||||
let ratio = params.f("ratio").max(1.0);
|
||||
let knee = params.f("knee").max(0.0);
|
||||
let att = smoothing(params.f("attack"), sr);
|
||||
let rel = smoothing(params.f("release").max(0.001), sr);
|
||||
let makeup = params.f("makeupGain");
|
||||
let dry = (params.f("dryOut") / 100.0) as f32;
|
||||
let wet = (params.f("wetOut") / 100.0) as f32;
|
||||
// Smoothed gain change in dB (≤ 0): attack while the reduction grows, release as it shrinks.
|
||||
let mut g = 0.0f64;
|
||||
for f in buf.chunks_exact_mut(2) {
|
||||
let peak = f[0].abs().max(f[1].abs()) as f64;
|
||||
let target = compressor_gain_db(lin_to_db(peak), th, ratio, knee);
|
||||
let k = if target < g { att } else { rel };
|
||||
g = target + (g - target) * k;
|
||||
let lin = db_to_lin(g + makeup) as f32;
|
||||
for v in f.iter_mut() {
|
||||
*v = *v * dry + *v * lin * wet;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gate(params: &Params, buf: &mut [f32], sr: f64) {
|
||||
let th = db_to_lin(params.f("threshold"));
|
||||
let att = smoothing(params.f("attack"), sr);
|
||||
let rel = smoothing(params.f("release").max(0.001), sr);
|
||||
let hold = (params.f("hold").max(0.0) / 1000.0 * sr) as usize;
|
||||
let floor = db_to_lin(params.f("range").min(0.0));
|
||||
// Openness 0..1 (closed..open), mapped to floor..1.
|
||||
let mut g = 0.0f64;
|
||||
let mut held = 0usize;
|
||||
for f in buf.chunks_exact_mut(2) {
|
||||
let peak = f[0].abs().max(f[1].abs()) as f64;
|
||||
let open = if peak >= th {
|
||||
held = hold;
|
||||
true
|
||||
} else if held > 0 {
|
||||
held -= 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let target = if open { 1.0 } else { 0.0 };
|
||||
let k = if target > g { att } else { rel };
|
||||
g = target + (g - target) * k;
|
||||
let lin = (floor + (1.0 - floor) * g) as f32;
|
||||
f[0] *= lin;
|
||||
f[1] *= lin;
|
||||
}
|
||||
}
|
||||
|
||||
/// The Distortion effect's waveshaper for one sample (`drive` linear gain, `bits` for Bit Crush).
|
||||
pub fn distort_sample(kind: u32, x: f32, drive: f32, bits: f32) -> f32 {
|
||||
let v = x * drive;
|
||||
match kind {
|
||||
1 => v.clamp(-1.0, 1.0),
|
||||
2 => {
|
||||
// Fold back: reflect at ±1 (a triangle function of the input).
|
||||
let t = (v + 1.0).rem_euclid(4.0);
|
||||
if t < 2.0 { t - 1.0 } else { 3.0 - t }
|
||||
}
|
||||
3 => {
|
||||
let q = 2f32.powf(bits.clamp(1.0, 16.0) - 1.0);
|
||||
(v.clamp(-1.0, 1.0) * q).round() / q
|
||||
}
|
||||
_ => v.tanh(),
|
||||
}
|
||||
}
|
||||
|
||||
fn distortion(params: &Params, buf: &mut [f32], sr: f64) {
|
||||
let kind = params.e("distortionType");
|
||||
let drive = db_to_lin(params.f("drive")) as f32;
|
||||
let bits = params.f("bitDepth") as f32;
|
||||
let out = db_to_lin(params.f("outputGain")) as f32;
|
||||
let dry = (params.f("dryOut") / 100.0) as f32;
|
||||
let wet = (params.f("wetOut") / 100.0) as f32;
|
||||
let fc = params.f("tone").clamp(20.0, sr * 0.49);
|
||||
let a = (-2.0 * PI * fc / sr).exp() as f32;
|
||||
let mut lp = [0.0f32; 2];
|
||||
for f in buf.chunks_exact_mut(2) {
|
||||
for ch in 0..2 {
|
||||
let x = f[ch];
|
||||
let y = distort_sample(kind, x, drive, bits);
|
||||
lp[ch] = y + (lp[ch] - y) * a;
|
||||
f[ch] = x * dry + lp[ch] * out * wet;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lfo(kind: u32, phase: f64) -> f64 {
|
||||
let ph = phase.rem_euclid(1.0);
|
||||
match kind {
|
||||
|
|
@ -546,7 +718,10 @@ mod tests {
|
|||
for id in [
|
||||
"ec.audio.backwards",
|
||||
"ec.audio.basstreble",
|
||||
"ec.audio.compressor",
|
||||
"ec.audio.delay",
|
||||
"ec.audio.distortion",
|
||||
"ec.audio.gate",
|
||||
"ec.audio.flangechorus",
|
||||
"ec.audio.highlowpass",
|
||||
"ec.audio.modulator",
|
||||
|
|
@ -679,4 +854,64 @@ mod tests {
|
|||
assert_eq!(b, vec![2.0, 1.0]);
|
||||
assert!(reverses("ec.audio.backwards"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressor_reduces_gain_above_threshold() {
|
||||
// Static curve: 10 dB over a −20 dB threshold at 4:1 comes out 7.5 dB lower.
|
||||
assert!((compressor_gain_db(-10.0, -20.0, 4.0, 0.0) + 7.5).abs() < 1e-9);
|
||||
assert_eq!(compressor_gain_db(-30.0, -20.0, 4.0, 0.0), 0.0);
|
||||
// The soft knee meets the hard curve at its edges.
|
||||
let k = 6.0;
|
||||
assert!(compressor_gain_db(-23.0, -20.0, 4.0, k).abs() < 1e-9);
|
||||
assert!((compressor_gain_db(-17.0, -20.0, 4.0, k) - compressor_gain_db(-17.0, -20.0, 4.0, 0.0)).abs() < 1e-9);
|
||||
let sr = 48000.0;
|
||||
let mut p = defaults("ec.audio.compressor");
|
||||
p.values.insert("attack".into(), num(1.0));
|
||||
let mut loud = sine(1000.0, sr, 9600);
|
||||
process("ec.audio.compressor", &p, &mut loud, 48000, 0.0);
|
||||
// A full-scale sine (peak 0 dB) is pulled down by ~15 dB once the envelope settles.
|
||||
let peak = loud[18000..].iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
let want = db_to_lin(compressor_gain_db(0.0, -20.0, 4.0, 0.0)) as f32;
|
||||
assert!((peak - want).abs() < 0.05, "{peak} vs {want}");
|
||||
// Quiet input passes untouched.
|
||||
let mut quiet: Vec<f32> = sine(1000.0, sr, 4800).iter().map(|v| v * 0.01).collect();
|
||||
let orig = quiet.clone();
|
||||
process("ec.audio.compressor", &p, &mut quiet, 48000, 0.0);
|
||||
assert!(quiet.iter().zip(&orig).all(|(a, b)| (a - b).abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_mutes_below_threshold() {
|
||||
let sr = 48000.0;
|
||||
let p = defaults("ec.audio.gate");
|
||||
// A −60 dB signal under the −40 dB threshold is attenuated by the −80 dB range.
|
||||
let mut quiet: Vec<f32> = sine(500.0, sr, 9600).iter().map(|v| v * 0.001).collect();
|
||||
process("ec.audio.gate", &p, &mut quiet, 48000, 0.0);
|
||||
assert!(rms(&quiet, 4800) < 1e-6, "{}", rms(&quiet, 4800));
|
||||
// A loud signal opens the gate.
|
||||
let mut loud: Vec<f32> = sine(500.0, sr, 9600).iter().map(|v| v * 0.5).collect();
|
||||
let r0 = rms(&loud, 4800);
|
||||
process("ec.audio.gate", &p, &mut loud, 48000, 0.0);
|
||||
assert!((rms(&loud, 4800) - r0).abs() < 0.01);
|
||||
// Deterministic.
|
||||
let mut a = sine(300.0, sr, 2000);
|
||||
let mut b = a.clone();
|
||||
process("ec.audio.gate", &p, &mut a, 48000, 0.0);
|
||||
process("ec.audio.gate", &p, &mut b, 48000, 0.0);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distortion_shapes_and_bounds() {
|
||||
assert!((distort_sample(0, 0.5, 1.0, 8.0) - 0.5f32.tanh()).abs() < 1e-6);
|
||||
assert_eq!(distort_sample(1, 0.8, 2.0, 8.0), 1.0);
|
||||
assert!((distort_sample(2, 0.7, 2.0, 8.0) - 0.6).abs() < 1e-5);
|
||||
assert_eq!(distort_sample(3, 0.3, 1.0, 2.0), 0.5);
|
||||
let p = defaults("ec.audio.distortion");
|
||||
let mut b = sine(200.0, 48000.0, 4800);
|
||||
process("ec.audio.distortion", &p, &mut b, 48000, 0.0);
|
||||
assert!(b.iter().all(|v| v.abs() <= 1.0));
|
||||
assert!(rms(&b, 100) > 0.1);
|
||||
assert!(preroll("ec.audio.compressor", &defaults("ec.audio.compressor")) > 0.4);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,338 @@
|
|||
//! Blur & Sharpen: CC Radial Blur and Camera-Shake Deblur.
|
||||
//!
|
||||
//! * **CC Radial Blur** — zoom (straight, fading, centred both ways) and rotational (plain or
|
||||
//! fading) blurs around a centre point, sampled along the ray / arc through each pixel with a
|
||||
//! sample count set by Quality.
|
||||
//! * **Camera-Shake Deblur** — frame-based restoration in the spirit of published video deblurring
|
||||
//! by "lucky frames": the frame is split into patches; for each patch the sharpness (mean
|
||||
//! squared gradient) is compared with the same patch in the neighbouring frames within Blur
|
||||
//! Duration; when a neighbour is clearly sharper, its patch is aligned to the current frame by
|
||||
//! a small translation search (sum of squared differences on luma) and substituted with
|
||||
//! feathered patch blending. Deterministic; neighbours come from [`crate::EffectHost::self_at`].
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::{Image, Px};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::util::Plane;
|
||||
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
|
||||
|
||||
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Blur & Sharpen", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Radial Blur
|
||||
|
||||
pub const RADIAL_TYPES: [&str; 5] = ["Straight Zoom", "Fading Zoom", "Centered", "Rotate", "Rotate Fading"];
|
||||
|
||||
fn cc_radial_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let amount = ctx.params.f("amount");
|
||||
if amount == 0.0 {
|
||||
return b;
|
||||
}
|
||||
let kind = ctx.params.e("type");
|
||||
let quality = ctx.params.f("quality").clamp(1.0, 100.0);
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let src = b.img.clone();
|
||||
// Zoom: fraction of the distance to the centre; rotate: degrees of arc.
|
||||
let zoom = (amount / 100.0).clamp(-1.0, 1.0) * 0.5;
|
||||
let arc = amount.to_radians() * 0.25;
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1);
|
||||
let r = (dx * dx + dy * dy).sqrt();
|
||||
let span = if kind >= 3 { r * arc.abs() } else { r * zoom.abs() };
|
||||
let n = ((span * quality / 50.0).ceil() as usize).clamp(1, 256);
|
||||
if n <= 1 {
|
||||
*o = src.get(x as i64, y as i64);
|
||||
continue;
|
||||
}
|
||||
let mut acc = [0.0f32; 4];
|
||||
let mut wsum = 0.0f32;
|
||||
for i in 0..n {
|
||||
let t = i as f64 / (n - 1) as f64;
|
||||
let (sx, sy, w) = match kind {
|
||||
// Toward the centre, equal weights.
|
||||
0 => {
|
||||
let k = 1.0 - zoom * t;
|
||||
(c.0 + dx * k, c.1 + dy * k, 1.0)
|
||||
}
|
||||
// Toward the centre, weights fading with distance along the streak.
|
||||
1 => {
|
||||
let k = 1.0 - zoom * t;
|
||||
(c.0 + dx * k, c.1 + dy * k, (1.0 - t) as f32 + 0.05)
|
||||
}
|
||||
// Both ways around the pixel.
|
||||
2 => {
|
||||
let k = 1.0 + zoom * (t - 0.5);
|
||||
(c.0 + dx * k, c.1 + dy * k, 1.0)
|
||||
}
|
||||
_ => {
|
||||
let a = arc * (t - 0.5);
|
||||
let (s, co) = a.sin_cos();
|
||||
let w = if kind == 4 { (1.0 - (2.0 * t - 1.0).abs()) as f32 + 0.05 } else { 1.0 };
|
||||
(c.0 + dx * co - dy * s, c.1 + dx * s + dy * co, w)
|
||||
}
|
||||
};
|
||||
let q = src.sample_bilinear(sx, sy);
|
||||
for k in 0..4 {
|
||||
acc[k] += q[k] * w;
|
||||
}
|
||||
wsum += w;
|
||||
}
|
||||
*o = acc.map(|v| v / wsum);
|
||||
}
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Camera-Shake Deblur
|
||||
|
||||
/// Mean squared luma gradient over a rectangle (sharpness).
|
||||
fn sharpness(l: &Plane, x0: usize, y0: usize, x1: usize, y1: usize) -> f64 {
|
||||
let mut s = 0.0f64;
|
||||
let mut n = 0usize;
|
||||
for y in y0..y1.saturating_sub(1) {
|
||||
for x in x0..x1.saturating_sub(1) {
|
||||
let v = l.get(x, y);
|
||||
let gx = l.get(x + 1, y) - v;
|
||||
let gy = l.get(x, y + 1) - v;
|
||||
s += (gx * gx + gy * gy) as f64;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if n == 0 { 0.0 } else { s / n as f64 }
|
||||
}
|
||||
|
||||
/// Best integer translation (dx, dy) within ±`r` aligning `b` to `a` on a patch (SSD).
|
||||
fn align(a: &Plane, bp: &Plane, x0: usize, y0: usize, x1: usize, y1: usize, r: i64) -> (i64, i64) {
|
||||
let mut best = (f64::INFINITY, 0i64, 0i64);
|
||||
let step = ((x1 - x0).max(y1 - y0) / 24).max(1);
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let mut s = 0.0f64;
|
||||
for y in (y0..y1).step_by(step) {
|
||||
for x in (x0..x1).step_by(step) {
|
||||
let d = a.get(x, y) - bp.get_clamped(x as i64 + dx, y as i64 + dy);
|
||||
s += (d * d) as f64;
|
||||
}
|
||||
}
|
||||
// Prefer the smallest shift on ties.
|
||||
let s = s * (1.0 + 1e-9 * (dx * dx + dy * dy) as f64);
|
||||
if s < best.0 {
|
||||
best = (s, dx, dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
(best.1, best.2)
|
||||
}
|
||||
|
||||
fn camera_shake_deblur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let Some(host) = ctx.env.host else { return b };
|
||||
let dur = ctx.params.f("blurDuration").clamp(1.0, 10.0).round() as i64;
|
||||
let search = (ctx.params.f("searchRange").max(0.0) * b.scale).round() as i64;
|
||||
let patch = (ctx.params.f("patchSize").max(8.0) * b.scale).round().max(4.0) as usize;
|
||||
let thresh = 1.0 + ctx.params.f("threshold").max(0.0) / 100.0;
|
||||
let show = ctx.params.b("showBlurryFrames");
|
||||
let fps = ctx.fps();
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
if w == 0 || h == 0 {
|
||||
return b;
|
||||
}
|
||||
// Neighbouring frames in the effect's own input, resampled into this buffer's grid.
|
||||
let mut neigh: Vec<(Image, Plane)> = vec![];
|
||||
for k in (-dur..=dur).filter(|k| *k != 0) {
|
||||
let Some(nb) = host.self_at(ctx.time + k as f64 / fps, ctx.env.effect_index) else { continue };
|
||||
let img = if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale {
|
||||
nb.img
|
||||
} else {
|
||||
let k = nb.scale / b.scale.max(1e-9);
|
||||
crate::util::gen_image(b.img.width, b.img.height, |x, y| {
|
||||
let lx = (x as f64 + 0.5 - b.offset[0]) * k + nb.offset[0];
|
||||
let ly = (y as f64 + 0.5 - b.offset[1]) * k + nb.offset[1];
|
||||
nb.img.sample_bilinear(lx, ly)
|
||||
})
|
||||
};
|
||||
let l = Plane::luma(&img);
|
||||
neigh.push((img, l));
|
||||
}
|
||||
if neigh.is_empty() {
|
||||
return b;
|
||||
}
|
||||
let cur_l = Plane::luma(&b.img);
|
||||
let (nx, ny) = (w.div_ceil(patch), h.div_ceil(patch));
|
||||
// Per patch: (neighbour index, shift) of the substitute, or None to keep.
|
||||
let choice: Vec<Option<(usize, i64, i64)>> = (0..nx * ny)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let (px, py) = (i % nx, i / nx);
|
||||
let (x0, y0) = (px * patch, py * patch);
|
||||
let (x1, y1) = ((x0 + patch).min(w), (y0 + patch).min(h));
|
||||
let s0 = sharpness(&cur_l, x0, y0, x1, y1);
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
for (k, (_, l)) in neigh.iter().enumerate() {
|
||||
let s = sharpness(l, x0, y0, x1, y1);
|
||||
if s > s0 * thresh && best.is_none_or(|(bs, _)| s > bs) {
|
||||
best = Some((s, k));
|
||||
}
|
||||
}
|
||||
let (_, k) = best?;
|
||||
let (dx, dy) = align(&cur_l, &neigh[k].1, x0, y0, x1, y1, search);
|
||||
Some((k, dx, dy))
|
||||
})
|
||||
.collect();
|
||||
if choice.iter().all(Option::is_none) {
|
||||
return b;
|
||||
}
|
||||
let src = b.img.clone();
|
||||
// Feathered blending between patch decisions: each pixel mixes the four nearest patch
|
||||
// centres bilinearly.
|
||||
let pick = |i: usize, x: usize, y: usize| -> Px {
|
||||
match choice[i] {
|
||||
None => src.data[y * w + x],
|
||||
Some((k, dx, dy)) => neigh[k].0.get_clamped(x as i64 + dx, y as i64 + dy),
|
||||
}
|
||||
};
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
let fy = ((y as f64 + 0.5) / patch as f64 - 0.5).clamp(0.0, (ny - 1) as f64);
|
||||
let (y0, ty) = (fy.floor() as usize, (fy - fy.floor()) as f32);
|
||||
let y1 = (y0 + 1).min(ny - 1);
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let fx = ((x as f64 + 0.5) / patch as f64 - 0.5).clamp(0.0, (nx - 1) as f64);
|
||||
let (x0, tx) = (fx.floor() as usize, (fx - fx.floor()) as f32);
|
||||
let x1 = (x0 + 1).min(nx - 1);
|
||||
let ids = [y0 * nx + x0, y0 * nx + x1, y1 * nx + x0, y1 * nx + x1];
|
||||
if ids.iter().all(|&i| choice[i].is_none()) {
|
||||
continue;
|
||||
}
|
||||
let ws = [(1.0 - tx) * (1.0 - ty), tx * (1.0 - ty), (1.0 - tx) * ty, tx * ty];
|
||||
let mut acc = [0.0f32; 4];
|
||||
for (i, wgt) in ids.iter().zip(ws) {
|
||||
let q = pick(*i, x, y);
|
||||
for c in 0..4 {
|
||||
acc[c] += q[c] * wgt;
|
||||
}
|
||||
}
|
||||
if show {
|
||||
acc[0] = (acc[0] + 0.3 * acc[3]).min(acc[3].max(acc[0]));
|
||||
}
|
||||
*o = acc;
|
||||
}
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.blur.ccradial",
|
||||
"CC Radial Blur",
|
||||
vec![
|
||||
p("type", "Type", Value::Enum(1), popup(&RADIAL_TYPES)),
|
||||
p("amount", "Amount", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
|
||||
p("quality", "Quality", num(50.0), slider(1.0, 100.0, 1.0, 100.0, 1)),
|
||||
p("center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
|
||||
],
|
||||
cc_radial_blur,
|
||||
),
|
||||
spec(
|
||||
"ec.blur.camerashakedeblur",
|
||||
"Camera-Shake Deblur",
|
||||
vec![
|
||||
p("blurDuration", "Blur Duration", num(2.0), slider(1.0, 10.0, 1.0, 10.0, 0)),
|
||||
p("searchRange", "Search Range", num(8.0), slider(0.0, 64.0, 0.0, 32.0, 0)),
|
||||
p("patchSize", "Patch Size", num(32.0), slider(8.0, 256.0, 8.0, 128.0, 0)),
|
||||
p("threshold", "Threshold", num(20.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
|
||||
p("showBlurryFrames", "Show Blurry Frames", Value::Bool(false), ParamUi::Checkbox),
|
||||
],
|
||||
camera_shake_deblur,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, EffectHost, LayerPixels, run_fx};
|
||||
|
||||
fn checker(w: u32, h: u32, cell: u32) -> Image {
|
||||
crate::util::gen_image(w, h, |x, y| {
|
||||
let v = if ((x as u32 / cell) + (y as u32 / cell)).is_multiple_of(2) { 1.0 } else { 0.0 };
|
||||
[v, v, v, 1.0]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_radial_blur_keeps_centre_and_smears_edges() {
|
||||
let img = checker(64, 64, 4);
|
||||
for kind in 0..5u32 {
|
||||
let out = run_fx(
|
||||
"ec.blur.ccradial",
|
||||
&[("type", Value::Enum(kind)), ("amount", num(40.0)), ("center", Value::Vec2([32.0, 32.0]))],
|
||||
img.clone(),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
// Far from the centre the checker is smeared toward grey.
|
||||
let edge = out.img.get(2, 2)[0];
|
||||
assert!(edge > 0.05 && edge < 0.95, "type {kind}: {edge}");
|
||||
let again = run_fx(
|
||||
"ec.blur.ccradial",
|
||||
&[("type", Value::Enum(kind)), ("amount", num(40.0)), ("center", Value::Vec2([32.0, 32.0]))],
|
||||
img.clone(),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
}
|
||||
// Zero amount is identity; a constant image stays constant.
|
||||
let flat = Image::filled(16, 16, [0.4, 0.4, 0.4, 1.0]);
|
||||
let o = run_fx("ec.blur.ccradial", &[("amount", num(80.0))], flat.clone(), 0.0, EffectEnv::default());
|
||||
assert!(o.img.data.iter().all(|p| (p[0] - 0.4).abs() < 1e-5));
|
||||
let o = run_fx("ec.blur.ccradial", &[("amount", num(0.0))], img.clone(), 0.0, EffectEnv::default());
|
||||
assert_eq!(o.img.data, img.data);
|
||||
}
|
||||
|
||||
/// Frames: sharp checker at even frames, a blurred one at odd frames (shifted by 2 px).
|
||||
struct Shaky;
|
||||
impl EffectHost for Shaky {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
|
||||
let f = (t * 30.0).round() as i64;
|
||||
Some(Buf { img: frame(f), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(f: i64) -> Image {
|
||||
let sharp = crate::util::gen_image(48, 48, |x, y| {
|
||||
let v = if (((x + 2) / 6) + (y / 6)) % 2 == 0 { 1.0 } else { 0.0 };
|
||||
[v, v, v, 1.0]
|
||||
});
|
||||
if f % 2 == 0 { sharp } else { effectcraft_raster::gaussian_blur(&sharp, 2.5, 2.5, true) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_shake_deblur_substitutes_sharp_neighbours() {
|
||||
let host = Shaky;
|
||||
let env = EffectEnv { host: Some(&host), frame_rate: 30.0, ..Default::default() };
|
||||
let blurry = frame(1);
|
||||
let out = run_fx("ec.blur.camerashakedeblur", &[("patchSize", num(16.0)), ("searchRange", num(3.0))], blurry.clone(), 1.0 / 30.0, env);
|
||||
let sharp = frame(0);
|
||||
let err = |a: &Image| a.data.iter().zip(&sharp.data).map(|(p, q)| (p[0] - q[0]).abs()).sum::<f32>() / a.data.len() as f32;
|
||||
assert!(err(&out.img) < err(&blurry) * 0.5, "{} vs {}", err(&out.img), err(&blurry));
|
||||
// A sharp frame is left alone (its neighbours are blurrier).
|
||||
let keep = run_fx("ec.blur.camerashakedeblur", &[], sharp.clone(), 0.0, env);
|
||||
assert_eq!(keep.img.data, sharp.data);
|
||||
// Deterministic; no host = pass-through.
|
||||
let again = run_fx("ec.blur.camerashakedeblur", &[("patchSize", num(16.0)), ("searchRange", num(3.0))], blurry.clone(), 1.0 / 30.0, env);
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
let none = run_fx("ec.blur.camerashakedeblur", &[], blurry.clone(), 0.0, EffectEnv::default());
|
||||
assert_eq!(none.img.data, blurry.data);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,681 @@
|
|||
//! 3D Channel effects: 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR,
|
||||
//! Fog 3D, ID Matte and IDentifier.
|
||||
//!
|
||||
//! They read the layer's auxiliary channels ([`AuxChannels`], via
|
||||
//! [`crate::EffectHost::aux`]): multi-layer OpenEXR footage (depth, object/material IDs,
|
||||
//! Cryptomatte layers, any named channel) or the compositor's depth / layer-ID / Cryptomatte
|
||||
//! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through
|
||||
//! (EXtractoR still maps the layer's own R, G, B and A).
|
||||
//!
|
||||
//! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer
|
||||
//! pixel's layer position, so render scale and layer padding are respected.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_hash};
|
||||
use effectcraft_raster::{AuxChannels, Image, Px, gaussian_blur};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::util::{Plane, gauss_plane, hash1, premul, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "3D Channel", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
fn aux(ctx: &EffectCtx) -> Option<Arc<AuxChannels>> {
|
||||
ctx.env.host?.aux()
|
||||
}
|
||||
|
||||
/// Aux index for each buffer pixel (None outside the aux image).
|
||||
fn index_map(b: &Buf, a: &AuxChannels) -> Vec<Option<usize>> {
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let inv = 1.0 / b.scale.max(1e-9);
|
||||
(0..w * h)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A channel sampled into the buffer grid (`fill` outside).
|
||||
fn plane_of(b: &Buf, idx: &[Option<usize>], ch: &[f32], fill: f32) -> Plane {
|
||||
Plane { w: b.img.width as usize, h: b.img.height as usize, data: idx.par_iter().map(|i| i.map_or(fill, |i| ch[i])).collect() }
|
||||
}
|
||||
|
||||
/// Distinct colour for an ID (hash → hue).
|
||||
fn id_color(id: f32) -> [f32; 3] {
|
||||
let h = hash1(id.to_bits(), 0x1d, 0x5eed);
|
||||
let (r, g, b) = effectcraft_color::hsl_to_rgb(h, 0.75, 0.5);
|
||||
if id == 0.0 { [0.0; 3] } else { [r, g, b] }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 3D Channel Extract
|
||||
|
||||
pub const EXTRACT_CHANNELS: [&str; 8] = ["Z-Depth", "Object ID", "Texture UV", "Surface Normals", "Coverage", "Background RGB", "Unclamped RGB", "Material ID"];
|
||||
|
||||
fn channel_extract(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let Some(a) = aux(ctx) else { return b };
|
||||
let idx = index_map(&b, &a);
|
||||
let (bp, wp) = (ctx.params.f("blackPoint") as f32, ctx.params.f("whitePoint") as f32);
|
||||
let lv = |v: f32| ((v - bp) / (wp - bp).abs().max(1e-9) * (wp - bp).signum()).clamp(0.0, 1.0);
|
||||
let lv = |v: f32| if wp >= bp { lv(v) } else { 1.0 - ((v - wp) / (bp - wp).max(1e-9)).clamp(0.0, 1.0) };
|
||||
let get3 = |names: [&[&str]; 3]| -> Option<[&[f32]; 3]> { Some([a.find(names[0])?, a.find(names[1])?, a.find(names[2])?]) };
|
||||
let img: Option<Image> = match ctx.params.e("channel") {
|
||||
0 => a.depth().map(|z| map_grey(&b, &idx, |i| lv(z[i]))),
|
||||
1 => a.object_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))),
|
||||
7 => a.material_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))),
|
||||
4 => a.coverage().map(|c| map_grey(&b, &idx, |i| lv(c[i]))),
|
||||
2 => get3([&["UV.U", "U", "uv.x"], &["UV.V", "V", "uv.y"], &["UV.W", "W", "uv.z"]])
|
||||
.or_else(|| Some([a.find(&["UV.U", "U", "uv.x"])?, a.find(&["UV.V", "V", "uv.y"])?, a.find(&["UV.V", "V", "uv.y"])?]))
|
||||
.map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), 0.0])),
|
||||
3 => get3([&["N.X", "Normal.X", "normals.x"], &["N.Y", "Normal.Y", "normals.y"], &["N.Z", "Normal.Z", "normals.z"]])
|
||||
.map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i] * 0.5 + 0.5), lv(c[1][i] * 0.5 + 0.5), lv(c[2][i] * 0.5 + 0.5)])),
|
||||
5 => get3([&["BG.R", "Background.R"], &["BG.G", "Background.G"], &["BG.B", "Background.B"]])
|
||||
.map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), lv(c[2][i])])),
|
||||
_ => get3([&["R"], &["G"], &["B"]]).map(|c| map_rgb(&b, &idx, |i| [c[0][i], c[1][i], c[2][i]])),
|
||||
};
|
||||
if let Some(i) = img {
|
||||
b.img = i;
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn map_grey(b: &Buf, idx: &[Option<usize>], f: impl Fn(usize) -> f32 + Sync) -> Image {
|
||||
map_rgb(b, idx, |i| {
|
||||
let v = f(i);
|
||||
[v, v, v]
|
||||
})
|
||||
}
|
||||
|
||||
fn map_rgb(b: &Buf, idx: &[Option<usize>], f: impl Fn(usize) -> [f32; 3] + Sync) -> Image {
|
||||
let mut img = Image::new(b.img.width, b.img.height);
|
||||
img.data.par_iter_mut().zip(idx.par_iter()).for_each(|(p, i)| {
|
||||
if let Some(i) = i {
|
||||
let c = f(*i);
|
||||
*p = [c[0], c[1], c[2], 1.0];
|
||||
}
|
||||
});
|
||||
img
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Depth Matte
|
||||
|
||||
fn depth_matte(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let Some(a) = aux(ctx) else { return b };
|
||||
let Some(z) = a.depth() else { return b };
|
||||
let idx = index_map(&b, &a);
|
||||
let depth = ctx.params.f("depth") as f32;
|
||||
let feather = ctx.params.f("feather").max(0.0) as f32;
|
||||
let invert = ctx.params.b("invert");
|
||||
// Keeps what lies at or beyond Depth (Invert keeps what is in front), with a soft ramp of
|
||||
// `feather` depth units centred on the threshold.
|
||||
let zp = plane_of(&b, &idx, z, BACKGROUND_DEPTH);
|
||||
b.img.data.par_iter_mut().zip(zp.data.par_iter()).for_each(|(px, &zv)| {
|
||||
let k = if feather > 0.0 {
|
||||
((zv - depth) / feather + 0.5).clamp(0.0, 1.0)
|
||||
} else if zv >= depth {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let k = if invert { 1.0 - k } else { k };
|
||||
*px = px.map(|v| v * k);
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Depth of Field
|
||||
|
||||
/// Blur radius (pixels) for depth `z`.
|
||||
pub fn dof_radius(z: f32, focal: f32, thickness: f32, max_r: f32, bias: f32) -> f32 {
|
||||
let d = ((z - focal).abs() - thickness * 0.5).max(0.0);
|
||||
let range = focal.abs().max(1.0);
|
||||
let n = (d / range).min(1.0);
|
||||
// Bias 50 = linear; higher bias brings blur in sooner.
|
||||
let gamma = 2f32.powf((50.0 - bias) / 25.0);
|
||||
max_r * n.powf(gamma)
|
||||
}
|
||||
|
||||
fn depth_of_field(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let Some(a) = aux(ctx) else { return b };
|
||||
let Some(z) = a.depth() else { return b };
|
||||
let max_r = (ctx.params.f("maximumRadius").max(0.0) * b.scale) as f32;
|
||||
if max_r <= 0.0 {
|
||||
return b;
|
||||
}
|
||||
let focal = ctx.params.f("focalPlane") as f32;
|
||||
let thick = ctx.params.f("focalPlaneThickness").max(0.0) as f32;
|
||||
let bias = ctx.params.f("focalBias") as f32;
|
||||
let idx = index_map(&b, &a);
|
||||
let zp = plane_of(&b, &idx, z, BACKGROUND_DEPTH);
|
||||
// A stack of blurs at a few radii, interpolated per pixel by its circle of confusion.
|
||||
const LEVELS: usize = 5;
|
||||
let stack: Vec<Image> = (0..LEVELS)
|
||||
.into_par_iter()
|
||||
.map(|k| {
|
||||
let r = max_r * k as f32 / (LEVELS - 1) as f32;
|
||||
if r < 0.3 { b.img.clone() } else { gaussian_blur(&b.img, (r / 2.0) as f64, (r / 2.0) as f64, true) }
|
||||
})
|
||||
.collect();
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let r = dof_radius(zp.data[i], focal, thick, max_r, bias);
|
||||
let f = (r / max_r * (LEVELS - 1) as f32).clamp(0.0, (LEVELS - 1) as f32);
|
||||
let k0 = (f.floor() as usize).min(LEVELS - 2);
|
||||
let t = f - k0 as f32;
|
||||
let (a0, a1) = (stack[k0].data[i], stack[k0 + 1].data[i]);
|
||||
*px = [0, 1, 2, 3].map(|c| a0[c] + (a1[c] - a0[c]) * t);
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Fog 3D
|
||||
|
||||
fn fog_3d(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let Some(a) = aux(ctx) else { return b };
|
||||
let Some(z) = a.depth() else { return b };
|
||||
let pr = ctx.params;
|
||||
let fc = pr.color("fogColor");
|
||||
let (start, end) = (pr.f("fogStartDepth") as f32, pr.f("fogEndDepth") as f32);
|
||||
let opacity = (pr.f("fogOpacity") / 100.0) as f32;
|
||||
let density = (pr.f("scatteringDensity") / 100.0) as f32;
|
||||
let foggy_bg = pr.b("foggyBackground");
|
||||
let contrib = (pr.f("layerContribution") / 100.0) as f32;
|
||||
let grad = if contrib > 0.0 { ctx.layer_param("gradientLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)) } else { None };
|
||||
let idx = index_map(&b, &a);
|
||||
let zp = plane_of(&b, &idx, z, BACKGROUND_DEPTH);
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let zv = zp.data[i];
|
||||
let bg = zv >= BACKGROUND_DEPTH * 0.5;
|
||||
let mut f = if bg {
|
||||
if foggy_bg { 1.0 } else { 0.0 }
|
||||
} else {
|
||||
let t = ((zv - start) / (end - start).abs().max(1e-6)).clamp(0.0, 1.0);
|
||||
// Denser scattering thickens the fog sooner.
|
||||
1.0 - (1.0 - t).powf(1.0 + density * 3.0)
|
||||
};
|
||||
if let Some(g) = &grad {
|
||||
let (gc, ga) = unpremul(g.data[i]);
|
||||
let l = effectcraft_color::luminance(gc[0], gc[1], gc[2]) * ga;
|
||||
f *= 1.0 - contrib + contrib * l;
|
||||
}
|
||||
let f = (f * opacity).clamp(0.0, 1.0);
|
||||
let (c, al) = unpremul(*px);
|
||||
let al2 = if bg && foggy_bg { al + (1.0 - al) * f } else { al };
|
||||
let out = [0, 1, 2].map(|k| c[k] + (fc[k] - c[k]) * f);
|
||||
*px = premul(out, al2);
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ID Matte / IDentifier
|
||||
|
||||
fn id_channel(a: &AuxChannels, which: u32) -> Option<&[f32]> {
|
||||
if which == 1 { a.material_id() } else { a.object_id() }
|
||||
}
|
||||
|
||||
fn id_matte(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let Some(a) = aux(ctx) else { return b };
|
||||
let Some(ids) = id_channel(&a, ctx.params.e("auxChannel")) else { return b };
|
||||
let idx = index_map(&b, &a);
|
||||
let sel = ctx.params.f("idSelection").round() as f32;
|
||||
let cov = if ctx.params.b("useCoverage") { a.coverage() } else { None };
|
||||
let mut m = Plane {
|
||||
w: b.img.width as usize,
|
||||
h: b.img.height as usize,
|
||||
data: idx.par_iter().map(|i| i.map_or(0.0, |i| if ids[i].round() == sel { cov.map_or(1.0, |c| c[i].clamp(0.0, 1.0)) } else { 0.0 })).collect(),
|
||||
};
|
||||
let feather = ctx.params.f("feather").max(0.0) * b.scale;
|
||||
if feather > 0.0 {
|
||||
m = gauss_plane(&m, feather * 0.5, feather * 0.5);
|
||||
}
|
||||
let invert = ctx.params.b("invert");
|
||||
b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, &k)| {
|
||||
let k = if invert { 1.0 - k } else { k };
|
||||
*px = px.map(|v| v * k);
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
fn identifier(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let Some(a) = aux(ctx) else { return b };
|
||||
let Some(ids) = id_channel(&a, ctx.params.e("channelType")) else { return b };
|
||||
let idx = index_map(&b, &a);
|
||||
let sel = ctx.params.f("id").round() as f32;
|
||||
let display = ctx.params.e("display");
|
||||
let max_id = ids.iter().cloned().fold(1.0f32, f32::max);
|
||||
let src = b.img.clone();
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let Some(k) = idx[i] else {
|
||||
if display != 2 {
|
||||
*px = [0.0; 4];
|
||||
}
|
||||
return;
|
||||
};
|
||||
let id = ids[k].round();
|
||||
let hit = id == sel;
|
||||
*px = match display {
|
||||
// Colors: every ID in its own colour, the selected one highlighted white.
|
||||
0 => {
|
||||
let c = if hit && sel != 0.0 { [1.0; 3] } else { id_color(id) };
|
||||
[c[0], c[1], c[2], 1.0]
|
||||
}
|
||||
1 => {
|
||||
let v = if hit { 1.0 } else { 0.0 };
|
||||
[v, v, v, 1.0]
|
||||
}
|
||||
2 => {
|
||||
let s = src.data[i];
|
||||
if hit { s } else { [0.0; 4] }
|
||||
}
|
||||
_ => {
|
||||
let v = id / max_id;
|
||||
[v, v, v, 1.0]
|
||||
}
|
||||
};
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Cryptomatte
|
||||
|
||||
/// Does `name` match a selection pattern (`*` wildcards, case-sensitive like the spec)?
|
||||
fn wild(pat: &str, name: &str) -> bool {
|
||||
match pat.split_once('*') {
|
||||
None => pat == name,
|
||||
Some((pre, rest)) => {
|
||||
let Some(tail) = name.strip_prefix(pre) else { return false };
|
||||
if rest.is_empty() {
|
||||
return true;
|
||||
}
|
||||
(0..=tail.len()).filter(|&k| tail.is_char_boundary(k)).any(|k| wild(rest, &tail[k..]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a selection: comma-separated names (optionally quoted) or `<hex>` hashes / wildcards.
|
||||
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
|
||||
let mut out = vec![];
|
||||
for tok in sel.split(',') {
|
||||
let t = tok.trim().trim_matches('"').trim_matches('\'');
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if t.contains('*') {
|
||||
if let Some(m) = manifest {
|
||||
out.extend(m.iter().filter(|(n, _)| wild(t, n)).map(|(_, h)| *h));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(hex) = t.strip_prefix("<").and_then(|r| r.strip_suffix(">"))
|
||||
&& let Ok(h) = u32::from_str_radix(hex, 16)
|
||||
{
|
||||
out.push(h);
|
||||
continue;
|
||||
}
|
||||
out.push(manifest.and_then(|m| m.iter().find(|(n, _)| n == t).map(|(_, h)| *h)).unwrap_or_else(|| crypto_hash(t)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub const CRYPTO_LAYERS: [&str; 3] = ["CryptoObject", "CryptoMaterial", "CryptoAsset"];
|
||||
|
||||
fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let Some(a) = aux(ctx) else { return b };
|
||||
let layers = a.crypto_layers();
|
||||
if layers.is_empty() {
|
||||
return b;
|
||||
}
|
||||
let want = CRYPTO_LAYERS[(ctx.params.e("layer") as usize).min(2)];
|
||||
let layer = layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).unwrap_or(&layers[0]).clone();
|
||||
let manifest = a.manifests.iter().find(|(l, _)| *l == layer).map(|(_, m)| m.as_slice());
|
||||
let sel = selection_hashes(ctx.params.s("selection"), manifest);
|
||||
let idx = index_map(&b, &a);
|
||||
let display = ctx.params.e("display");
|
||||
let matte_only = ctx.params.b("matteOnly");
|
||||
let src = b.img.clone();
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let ranks = idx[i].map(|k| a.crypto_ranks(&layer, k)).unwrap_or_default();
|
||||
let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::<f32>().clamp(0.0, 1.0);
|
||||
if matte_only {
|
||||
*px = [m, m, m, m];
|
||||
return;
|
||||
}
|
||||
*px = match display {
|
||||
// Colors: coverage-weighted ID colours (the spec's preview), selection brighter.
|
||||
0 => {
|
||||
let mut c = [0.0f32; 3];
|
||||
for (id, cov) in &ranks {
|
||||
let k = id_color(*id);
|
||||
for j in 0..3 {
|
||||
c[j] += k[j] * cov;
|
||||
}
|
||||
}
|
||||
let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m);
|
||||
[c[0], c[1], c[2], 1.0]
|
||||
}
|
||||
// Matte: the layer keyed by the selection.
|
||||
1 => src.data[i].map(|v| v * m),
|
||||
// Preview: the layer with the selection tinted.
|
||||
_ => {
|
||||
let s = src.data[i];
|
||||
[s[0] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[1] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[2] * (1.0 - 0.5 * m), s[3]]
|
||||
}
|
||||
};
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- EXtractoR
|
||||
|
||||
fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let a = aux(ctx);
|
||||
let names = [ctx.params.s("red"), ctx.params.s("green"), ctx.params.s("blue"), ctx.params.s("alpha")];
|
||||
let (bp, wp) = (ctx.params.f("blackPoint") as f32, ctx.params.f("whitePoint") as f32);
|
||||
let unmult = ctx.params.b("unMult");
|
||||
let clip = ctx.params.b("clip");
|
||||
let idx = a.as_ref().map(|a| index_map(&b, a));
|
||||
// Each output channel: an aux channel by name, or the layer's own R/G/B/A.
|
||||
let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() {
|
||||
"R" | "RED" => Some(0),
|
||||
"G" | "GREEN" => Some(1),
|
||||
"B" | "BLUE" => Some(2),
|
||||
"A" | "ALPHA" => Some(3),
|
||||
"" => None,
|
||||
_ => None,
|
||||
};
|
||||
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.as_ref().and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect();
|
||||
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k)) {
|
||||
return b;
|
||||
}
|
||||
let src = b.img.clone();
|
||||
let lv = |v: f32| {
|
||||
let o = (v - bp) / (wp - bp).abs().max(1e-9) * (wp - bp).signum();
|
||||
if clip { o.clamp(0.0, 1.0) } else { o }
|
||||
};
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let (sc, sa) = unpremul(src.data[i]);
|
||||
let straight = [sc[0], sc[1], sc[2], sa];
|
||||
let mut o: Px = [0.0; 4];
|
||||
for k in 0..4 {
|
||||
o[k] = match (src_ch[k], idx.as_ref().and_then(|m| m[i])) {
|
||||
(Some(ch), Some(j)) => ch[j],
|
||||
(Some(_), None) => 0.0,
|
||||
(None, _) => match own(names[k]) {
|
||||
Some(c) => straight[c],
|
||||
None => {
|
||||
if k == 3 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
if k < 3 {
|
||||
o[k] = lv(o[k]);
|
||||
}
|
||||
}
|
||||
let al = o[3].clamp(0.0, 1.0);
|
||||
let c = if unmult && al > 1e-6 { [o[0] / al, o[1] / al, o[2] / al] } else { [o[0], o[1], o[2]] };
|
||||
*px = premul(c, al);
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text);
|
||||
vec![
|
||||
spec(
|
||||
"ec.3d.channelextract",
|
||||
"3D Channel Extract",
|
||||
vec![
|
||||
p("channel", "3D Channel", Value::Enum(0), popup(&EXTRACT_CHANNELS)),
|
||||
p("blackPoint", "Black Point", num(0.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)),
|
||||
p("whitePoint", "White Point", num(10000.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)),
|
||||
],
|
||||
channel_extract,
|
||||
),
|
||||
spec(
|
||||
"ec.3d.cryptomatte",
|
||||
"Cryptomatte",
|
||||
vec![
|
||||
p("layer", "Layer", Value::Enum(0), popup(&CRYPTO_LAYERS)),
|
||||
text("selection", "Selection", ""),
|
||||
p("display", "Display", Value::Enum(2), popup(&["Colors", "Matte", "Preview"])),
|
||||
p("matteOnly", "Matte Only", Value::Bool(false), ParamUi::Checkbox),
|
||||
],
|
||||
cryptomatte,
|
||||
),
|
||||
spec(
|
||||
"ec.3d.depthmatte",
|
||||
"Depth Matte",
|
||||
vec![
|
||||
p("depth", "Depth", num(0.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)),
|
||||
p("feather", "Feather", num(0.0), slider(0.0, 1.0e6, 0.0, 1000.0, 2)),
|
||||
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
|
||||
],
|
||||
depth_matte,
|
||||
),
|
||||
spec(
|
||||
"ec.3d.depthoffield",
|
||||
"Depth of Field",
|
||||
vec![
|
||||
p("focalPlane", "Focal Plane", num(0.0), slider(-1.0e6, 1.0e6, 0.0, 10000.0, 2)),
|
||||
p("maximumRadius", "Maximum Radius", num(0.0), slider(0.0, 500.0, 0.0, 50.0, 2)),
|
||||
p("focalPlaneThickness", "Focal Plane Thickness", num(0.0), slider(0.0, 1.0e6, 0.0, 1000.0, 2)),
|
||||
p("focalBias", "Focal Bias", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 2)),
|
||||
],
|
||||
depth_of_field,
|
||||
),
|
||||
spec(
|
||||
"ec.3d.extractor",
|
||||
"EXtractoR",
|
||||
vec![
|
||||
text("red", "Red", "R"),
|
||||
text("green", "Green", "G"),
|
||||
text("blue", "Blue", "B"),
|
||||
text("alpha", "Alpha", "A"),
|
||||
p("blackPoint", "Black Point", num(0.0), slider(-1.0e6, 1.0e6, -1.0, 2.0, 3)),
|
||||
p("whitePoint", "White Point", num(1.0), slider(-1.0e6, 1.0e6, -1.0, 2.0, 3)),
|
||||
p("unMult", "UnMult", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("clip", "Clip", Value::Bool(false), ParamUi::Checkbox),
|
||||
],
|
||||
extractor,
|
||||
),
|
||||
spec(
|
||||
"ec.3d.fog3d",
|
||||
"Fog 3D",
|
||||
vec![
|
||||
p("fogColor", "Fog Color", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("fogStartDepth", "Fog Start Depth", num(0.0), slider(-1.0e6, 1.0e6, 0.0, 10000.0, 2)),
|
||||
p("fogEndDepth", "Fog End Depth", num(3000.0), slider(-1.0e6, 1.0e6, 0.0, 10000.0, 2)),
|
||||
p("fogOpacity", "Fog Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("scatteringDensity", "Scattering Density", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("foggyBackground", "Foggy Background", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("layerContribution", "Layer Contribution", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
],
|
||||
fog_3d,
|
||||
),
|
||||
spec(
|
||||
"ec.3d.idmatte",
|
||||
"ID Matte",
|
||||
vec![
|
||||
p("auxChannel", "Aux. Channel", Value::Enum(0), popup(&["Object ID", "Material ID"])),
|
||||
p("idSelection", "ID Selection", num(0.0), slider(0.0, 65535.0, 0.0, 100.0, 0)),
|
||||
p("feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
|
||||
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("useCoverage", "Use Coverage", Value::Bool(false), ParamUi::Checkbox),
|
||||
],
|
||||
id_matte,
|
||||
),
|
||||
spec(
|
||||
"ec.3d.identifier",
|
||||
"IDentifier",
|
||||
vec![
|
||||
p("channelType", "Channel Type", Value::Enum(0), popup(&["Object ID", "Material ID"])),
|
||||
p("display", "Display", Value::Enum(0), popup(&["Colors", "Luma Matte", "Alpha Matte", "Raw"])),
|
||||
p("id", "ID", num(0.0), slider(0.0, 65535.0, 0.0, 100.0, 0)),
|
||||
],
|
||||
identifier,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, EffectHost, LayerPixels, run_fx};
|
||||
use effectcraft_raster::channels3d::crypto_float;
|
||||
|
||||
/// A 20×10 layer: left half object 1 at depth 100, right half object 2 at depth 500; the
|
||||
/// top-right corner is background. Cryptomatte: "left" / "right".
|
||||
struct AuxHost(Arc<AuxChannels>);
|
||||
impl EffectHost for AuxHost {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn aux(&self) -> Option<Arc<AuxChannels>> {
|
||||
Some(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn scene() -> AuxHost {
|
||||
let (w, h) = (20usize, 10usize);
|
||||
let mut a = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
let (mut z, mut id, mut r, mut g) = (vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h]);
|
||||
let (hl, hr) = (crypto_hash("left"), crypto_hash("right"));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if x >= 15 && y < 3 {
|
||||
z[i] = BACKGROUND_DEPTH;
|
||||
} else if x < 10 {
|
||||
z[i] = 100.0;
|
||||
id[i] = 1.0;
|
||||
r[i] = crypto_float(hl);
|
||||
g[i] = 1.0;
|
||||
} else {
|
||||
z[i] = 500.0;
|
||||
id[i] = 2.0;
|
||||
r[i] = crypto_float(hr);
|
||||
g[i] = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
a.channels = vec![("Z".into(), z), ("ObjectID".into(), id), ("CryptoObject00.R".into(), r), ("CryptoObject00.G".into(), g)];
|
||||
a.manifests = vec![("CryptoObject".into(), vec![("left".into(), hl), ("right".into(), hr)])];
|
||||
AuxHost(Arc::new(a))
|
||||
}
|
||||
|
||||
fn img() -> Image {
|
||||
Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0])
|
||||
}
|
||||
|
||||
fn run(id: &str, vals: &[(&str, Value)], host: &AuxHost) -> Image {
|
||||
run_fx(id, vals, img(), 0.0, EffectEnv { host: Some(host), ..Default::default() }).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_through_without_aux_channels() {
|
||||
for s in specs() {
|
||||
let out = run_fx(s.id, &[], img(), 0.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, img().data, "{}", s.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_matte_thresholds() {
|
||||
let h = scene();
|
||||
let o = run("ec.3d.depthmatte", &[("depth", num(300.0))], &h);
|
||||
assert_eq!(o.get(2, 5)[3], 0.0);
|
||||
assert_eq!(o.get(12, 5)[3], 1.0);
|
||||
let inv = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("invert", Value::Bool(true))], &h);
|
||||
assert_eq!(inv.get(2, 5)[3], 1.0);
|
||||
assert_eq!(inv.get(12, 5)[3], 0.0);
|
||||
// Feather: a ramp of 800 units centred on 300 → depth 100 at 0.25, 500 at 0.75.
|
||||
let f = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("feather", num(800.0))], &h);
|
||||
assert!((f.get(2, 5)[3] - 0.25).abs() < 1e-5 && (f.get(12, 5)[3] - 0.75).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_extract_maps_depth_and_ids() {
|
||||
let h = scene();
|
||||
let o = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0))], &h);
|
||||
assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6 && (o.get(12, 5)[0] - 0.5).abs() < 1e-6);
|
||||
assert_eq!(o.get(17, 1)[0], 1.0);
|
||||
let ids = run("ec.3d.channelextract", &[("channel", Value::Enum(1)), ("whitePoint", num(2.0))], &h);
|
||||
assert_eq!(ids.get(2, 5)[0], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_matte_and_identifier() {
|
||||
let h = scene();
|
||||
let o = run("ec.3d.idmatte", &[("idSelection", num(2.0))], &h);
|
||||
assert_eq!(o.get(2, 5)[3], 0.0);
|
||||
assert_eq!(o.get(12, 5)[3], 1.0);
|
||||
let l = run("ec.3d.identifier", &[("display", Value::Enum(1)), ("id", num(1.0))], &h);
|
||||
assert_eq!(l.get(2, 5)[0], 1.0);
|
||||
assert_eq!(l.get(12, 5)[0], 0.0);
|
||||
let c = run("ec.3d.identifier", &[], &h);
|
||||
assert_ne!(c.get(2, 5), c.get(12, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cryptomatte_selects_by_name_and_wildcard() {
|
||||
let h = scene();
|
||||
let m = run("ec.3d.cryptomatte", &[("selection", Value::Str("right".into())), ("matteOnly", Value::Bool(true))], &h);
|
||||
assert_eq!(m.get(2, 5)[3], 0.0);
|
||||
assert_eq!(m.get(12, 5)[3], 1.0);
|
||||
let w = run("ec.3d.cryptomatte", &[("selection", Value::Str("l*".into())), ("display", Value::Enum(1))], &h);
|
||||
assert_eq!(w.get(2, 5)[3], 1.0);
|
||||
assert_eq!(w.get(12, 5)[3], 0.0);
|
||||
assert!(wild("*ight", "right") && wild("r*t", "right") && !wild("x*", "right"));
|
||||
let hex = format!("<{:08x}>", crypto_hash("left"));
|
||||
assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fog_and_depth_of_field_follow_depth() {
|
||||
let h = scene();
|
||||
let f = run("ec.3d.fog3d", &[("fogColor", col(1.0, 0.0, 0.0)), ("fogEndDepth", num(1000.0)), ("scatteringDensity", num(0.0))], &h);
|
||||
// Linear fog: 10 % at depth 100, 50 % at 500, full on the background.
|
||||
assert!((f.get(2, 5)[0] - 0.55).abs() < 1e-4, "{:?}", f.get(2, 5));
|
||||
assert!((f.get(12, 5)[0] - 0.75).abs() < 1e-4);
|
||||
assert!((f.get(17, 1)[0] - 1.0).abs() < 1e-4 && f.get(17, 1)[1] < 1e-4);
|
||||
assert!((dof_radius(100.0, 100.0, 0.0, 10.0, 50.0)).abs() < 1e-6);
|
||||
assert!((dof_radius(200.0, 100.0, 0.0, 10.0, 50.0) - 10.0).abs() < 1e-6);
|
||||
assert_eq!(dof_radius(140.0, 100.0, 100.0, 10.0, 50.0), 0.0);
|
||||
// A sharp edge at the far object blurs when focused near.
|
||||
let mut pic = Image::new(20, 10);
|
||||
for y in 0..10 {
|
||||
for x in 0..20 {
|
||||
let v = if x % 2 == 0 { 1.0 } else { 0.0 };
|
||||
pic.set(x, y, [v, v, v, 1.0]);
|
||||
}
|
||||
}
|
||||
let env = EffectEnv { host: Some(&h), ..Default::default() };
|
||||
let d = run_fx("ec.3d.depthoffield", &[("focalPlane", num(100.0)), ("maximumRadius", num(4.0))], pic.clone(), 0.0, env).img;
|
||||
assert_eq!(d.get(4, 5), pic.get(4, 5));
|
||||
assert!((d.get(12, 5)[0] - 0.5).abs() < 0.3 && d.get(12, 5) != pic.get(12, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_maps_named_channels() {
|
||||
let h = scene();
|
||||
let o = run("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("whitePoint", num(1000.0))], &h);
|
||||
assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6);
|
||||
assert!((o.get(2, 5)[1] - 0.0005).abs() < 1e-6);
|
||||
// Without aux: the layer's own channels can be swapped.
|
||||
let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default());
|
||||
assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,719 @@
|
|||
//! Distort: CC Flo Motion, Liquify and Rolling Shutter Repair.
|
||||
//!
|
||||
//! * **CC Flo Motion** — two knots that pull the image toward themselves (positive amount) or
|
||||
//! push it away (negative), with a falloff exponent, optional tiling and supersampled
|
||||
//! antialiasing.
|
||||
//! * **Liquify** — brush strokes build a displacement mesh (a grid of layer-space offsets). The
|
||||
//! strokes are the effect's data (the hidden `distortionMesh` text, one stroke per line, so it
|
||||
//! animates with hold keyframes and is written by the `liquify.stroke` command); replaying
|
||||
//! them is deterministic and cached. Tools: warp, turbulence, twirl clockwise / counter-
|
||||
//! clockwise, pucker, bloat, shift pixels, reflection, clone, reconstruction and freeze / thaw.
|
||||
//! Distortion Percentage scales the mesh and Distortion Mesh Offset moves it.
|
||||
//! * **Rolling Shutter Repair** — every scanline of a CMOS frame is exposed a little later than
|
||||
//! the previous one, so moving content shears. Motion is measured between the neighbouring
|
||||
//! frames with pyramidal Lucas–Kanade (`effectcraft-track`) on a grid of points; each pixel is
|
||||
//! then resampled from where it was at the frame's mid-exposure time:
|
||||
//! `out(p) = in(p + v(p)·rate·(s(p) − ½))`, with `s` the scan position (0…1 along the scan
|
||||
//! direction) and `v` the per-frame velocity (Warp: one smooth velocity per scanline band;
|
||||
//! Pixel Motion: a dense interpolated field).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::Image;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::util::{hash1, point_in_poly};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Flo Motion
|
||||
|
||||
fn flo_motion(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let knots = [(ctx.params.v2("knot1"), ctx.params.f("amount1") / 100.0), (ctx.params.v2("knot2"), ctx.params.f("amount2") / 100.0)];
|
||||
if knots.iter().all(|k| k.1 == 0.0) {
|
||||
return b;
|
||||
}
|
||||
let tile = ctx.params.b("tileEdges");
|
||||
let ss = match ctx.params.e("antialiasing") {
|
||||
0 => 1,
|
||||
1 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
let falloff = ctx.params.f("falloff").max(0.01);
|
||||
let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0));
|
||||
let radius = (lw * lw + lh * lh).sqrt() * 0.5;
|
||||
let src = b.img.clone();
|
||||
let (scale, off) = (b.scale, b.offset);
|
||||
let map = |lx: f64, ly: f64| -> (f64, f64) {
|
||||
let (mut x, mut y) = (lx, ly);
|
||||
for (k, a) in knots {
|
||||
if a == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (dx, dy) = (x - k[0], y - k[1]);
|
||||
let r = (dx * dx + dy * dy).sqrt() / radius;
|
||||
// Weight 1 at the knot, falling off with distance.
|
||||
let w = (1.0 - r.min(1.0)).powf(falloff);
|
||||
// Pull: sample closer to the knot (content flows out of it); push: farther.
|
||||
let s = (1.0 - a.clamp(-4.0, 0.95) * w).max(0.02);
|
||||
x = k[0] + dx * s;
|
||||
y = k[1] + dy * s;
|
||||
}
|
||||
(x, y)
|
||||
};
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let mut acc = [0.0f32; 4];
|
||||
for sy in 0..ss {
|
||||
for sx in 0..ss {
|
||||
let px = x as f64 + (sx as f64 + 0.5) / ss as f64;
|
||||
let py = y as f64 + (sy as f64 + 0.5) / ss as f64;
|
||||
let (lx, ly) = map((px - off[0]) / scale, (py - off[1]) / scale);
|
||||
let (lx, ly) = if tile { (lx.rem_euclid(lw), ly.rem_euclid(lh)) } else { (lx, ly) };
|
||||
let q = src.sample_bilinear(lx * scale + off[0], ly * scale + off[1]);
|
||||
for c in 0..4 {
|
||||
acc[c] += q[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = (ss * ss) as f32;
|
||||
*o = acc.map(|v| v / n);
|
||||
}
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Liquify
|
||||
|
||||
/// Liquify tools (the stroke text uses these names).
|
||||
pub const LIQUIFY_TOOLS: [&str; 12] = [
|
||||
"warp",
|
||||
"turbulence",
|
||||
"twirlClockwise",
|
||||
"twirlCounterclockwise",
|
||||
"pucker",
|
||||
"bloat",
|
||||
"shiftPixels",
|
||||
"reflection",
|
||||
"clone",
|
||||
"reconstruction",
|
||||
"freeze",
|
||||
"thaw",
|
||||
];
|
||||
|
||||
/// One recorded Liquify stroke.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LiquifyStroke {
|
||||
pub tool: usize,
|
||||
/// Brush size (diameter) and pressure (0–100), turbulent jitter (0–100), layer pixels.
|
||||
pub size: f64,
|
||||
pub pressure: f64,
|
||||
pub jitter: f64,
|
||||
/// Clone offset (source − destination).
|
||||
pub clone_offset: [f64; 2],
|
||||
pub points: Vec<[f64; 2]>,
|
||||
}
|
||||
|
||||
impl LiquifyStroke {
|
||||
/// Text form: `tool size pressure jitter cloneX cloneY x,y x,y …`.
|
||||
pub fn to_line(&self) -> String {
|
||||
let mut s = format!("{} {} {} {} {} {}", LIQUIFY_TOOLS[self.tool], self.size, self.pressure, self.jitter, self.clone_offset[0], self.clone_offset[1]);
|
||||
for p in &self.points {
|
||||
s.push_str(&format!(" {},{}", p[0], p[1]));
|
||||
}
|
||||
s
|
||||
}
|
||||
pub fn parse(line: &str) -> Option<LiquifyStroke> {
|
||||
let mut it = line.split_whitespace();
|
||||
let name = it.next()?;
|
||||
let tool = LIQUIFY_TOOLS.iter().position(|t| t.eq_ignore_ascii_case(name))?;
|
||||
let mut f = || -> Option<f64> { it.next()?.parse().ok() };
|
||||
let (size, pressure, jitter, cx, cy) = (f()?, f()?, f()?, f()?, f()?);
|
||||
let points: Vec<[f64; 2]> = it
|
||||
.filter_map(|t| {
|
||||
let (a, b) = t.split_once(',')?;
|
||||
Some([a.parse().ok()?, b.parse().ok()?])
|
||||
})
|
||||
.collect();
|
||||
(!points.is_empty()).then_some(LiquifyStroke { tool, size, pressure, jitter, clone_offset: [cx, cy], points })
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the `distortionMesh` text into strokes (bad lines are skipped).
|
||||
pub fn parse_strokes(s: &str) -> Vec<LiquifyStroke> {
|
||||
s.lines().filter_map(LiquifyStroke::parse).collect()
|
||||
}
|
||||
|
||||
/// Displacement mesh: offsets (layer pixels) at grid nodes spaced `cell` apart.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Mesh {
|
||||
pub nx: usize,
|
||||
pub ny: usize,
|
||||
pub cell: f64,
|
||||
pub d: Vec<[f64; 2]>,
|
||||
pub freeze: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
fn new(w: f64, h: f64, cell: f64) -> Mesh {
|
||||
let nx = (w / cell).ceil() as usize + 1;
|
||||
let ny = (h / cell).ceil() as usize + 1;
|
||||
Mesh { nx, ny, cell, d: vec![[0.0; 2]; nx * ny], freeze: vec![0.0; nx * ny] }
|
||||
}
|
||||
/// Bilinear displacement at a layer point (zero outside the mesh).
|
||||
pub fn at(&self, x: f64, y: f64) -> [f64; 2] {
|
||||
let fx = x / self.cell;
|
||||
let fy = y / self.cell;
|
||||
if fx < 0.0 || fy < 0.0 || fx > (self.nx - 1) as f64 || fy > (self.ny - 1) as f64 {
|
||||
return [0.0; 2];
|
||||
}
|
||||
let (x0, y0) = ((fx.floor() as usize).min(self.nx - 2), (fy.floor() as usize).min(self.ny - 2));
|
||||
let (x1, y1) = ((x0 + 1).min(self.nx - 1), (y0 + 1).min(self.ny - 1));
|
||||
let (tx, ty) = (fx - x0 as f64, fy - y0 as f64);
|
||||
let g = |i: usize, j: usize| self.d[j * self.nx + i];
|
||||
let l = |a: [f64; 2], b: [f64; 2], t: f64| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
l(l(g(x0, y0), g(x1, y0), tx), l(g(x0, y1), g(x1, y1), tx), ty)
|
||||
}
|
||||
}
|
||||
|
||||
/// Replay strokes into a displacement mesh over a `w`×`h` layer. `frozen(x, y)` reports
|
||||
/// mask-frozen points (Freeze Area Mask).
|
||||
pub fn build_mesh(strokes: &[LiquifyStroke], w: f64, h: f64, frozen: &(dyn Fn(f64, f64) -> bool + Sync)) -> Mesh {
|
||||
let cell = (w.max(h) / 128.0).clamp(2.0, 16.0);
|
||||
let mut m = Mesh::new(w, h, cell);
|
||||
for (i, f) in m.freeze.iter_mut().enumerate() {
|
||||
let (x, y) = ((i % m.nx) as f64 * cell, (i / m.nx) as f64 * cell);
|
||||
if frozen(x, y) {
|
||||
*f = 1.0;
|
||||
}
|
||||
}
|
||||
for (si, s) in strokes.iter().enumerate() {
|
||||
let r = (s.size * 0.5).max(1.0);
|
||||
let pr = (s.pressure / 100.0).clamp(0.0, 1.0);
|
||||
// Dabs every r/4 along the stroke; each dab knows the local motion of the brush.
|
||||
let mut dabs: Vec<([f64; 2], [f64; 2])> = vec![];
|
||||
if s.points.len() == 1 {
|
||||
dabs.push((s.points[0], [0.0; 2]));
|
||||
}
|
||||
for seg in s.points.windows(2) {
|
||||
let (a, b) = (seg[0], seg[1]);
|
||||
let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
|
||||
let n = ((len / (r * 0.25)).ceil() as usize).max(1);
|
||||
let step = [(b[0] - a[0]) / n as f64, (b[1] - a[1]) / n as f64];
|
||||
for k in 1..=n {
|
||||
dabs.push(([a[0] + step[0] * k as f64, a[1] + step[1] * k as f64], step));
|
||||
}
|
||||
}
|
||||
for (di, (c, mv)) in dabs.iter().enumerate() {
|
||||
let (gx0, gx1) = (((c[0] - r) / cell).floor().max(0.0) as usize, (((c[0] + r) / cell).ceil() as usize).min(m.nx - 1));
|
||||
let (gy0, gy1) = (((c[1] - r) / cell).floor().max(0.0) as usize, (((c[1] + r) / cell).ceil() as usize).min(m.ny - 1));
|
||||
let prev = m.d.clone();
|
||||
for gy in gy0..=gy1 {
|
||||
for gx in gx0..=gx1 {
|
||||
let (x, y) = (gx as f64 * cell, gy as f64 * cell);
|
||||
let (dx, dy) = (x - c[0], y - c[1]);
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
if dist >= r {
|
||||
continue;
|
||||
}
|
||||
let i = gy * m.nx + gx;
|
||||
// Smooth brush falloff.
|
||||
let fall = {
|
||||
let t = 1.0 - dist / r;
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
};
|
||||
let tool = LIQUIFY_TOOLS[s.tool];
|
||||
if tool == "freeze" || tool == "thaw" {
|
||||
let v = pr * fall;
|
||||
m.freeze[i] = if tool == "freeze" { m.freeze[i].max(v) } else { (m.freeze[i] - v).max(0.0) };
|
||||
continue;
|
||||
}
|
||||
let w = pr * fall * (1.0 - m.freeze[i]) * 0.5;
|
||||
if w <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let d = &mut m.d[i];
|
||||
match tool {
|
||||
"warp" => {
|
||||
d[0] -= mv[0] * w * 2.0;
|
||||
d[1] -= mv[1] * w * 2.0;
|
||||
}
|
||||
"turbulence" => {
|
||||
let j = s.jitter / 100.0 * r * 0.1;
|
||||
let seed = (si as u32).wrapping_mul(7919) ^ di as u32;
|
||||
d[0] += (hash1(gx as u32, gy as u32, seed) as f64 * 2.0 - 1.0) * j * w;
|
||||
d[1] += (hash1(gx as u32, gy as u32, seed ^ 0x55) as f64 * 2.0 - 1.0) * j * w;
|
||||
d[0] -= mv[0] * w;
|
||||
d[1] -= mv[1] * w;
|
||||
}
|
||||
"twirlClockwise" | "twirlCounterclockwise" => {
|
||||
let sgn = if tool == "twirlClockwise" { -1.0 } else { 1.0 };
|
||||
let a = sgn * 0.08 * w;
|
||||
let (sn, cs) = a.sin_cos();
|
||||
d[0] += dx * cs - dy * sn - dx;
|
||||
d[1] += dx * sn + dy * cs - dy;
|
||||
}
|
||||
"pucker" => {
|
||||
d[0] += dx * 0.05 * w;
|
||||
d[1] += dy * 0.05 * w;
|
||||
}
|
||||
"bloat" => {
|
||||
d[0] -= dx * 0.05 * w;
|
||||
d[1] -= dy * 0.05 * w;
|
||||
}
|
||||
"shiftPixels" => {
|
||||
// Perpendicular to the stroke (to its left).
|
||||
d[0] -= mv[1] * w * 2.0;
|
||||
d[1] += mv[0] * w * 2.0;
|
||||
}
|
||||
"reflection" => {
|
||||
// Mirror across the stroke line through the dab.
|
||||
let l = (mv[0] * mv[0] + mv[1] * mv[1]).sqrt();
|
||||
if l > 1e-9 {
|
||||
let n = [-mv[1] / l, mv[0] / l];
|
||||
let k = dx * n[0] + dy * n[1];
|
||||
let tgt = [-2.0 * k * n[0], -2.0 * k * n[1]];
|
||||
d[0] += (tgt[0] - d[0]) * w;
|
||||
d[1] += (tgt[1] - d[1]) * w;
|
||||
}
|
||||
}
|
||||
"clone" => {
|
||||
// Copy the distortion found at the clone source.
|
||||
let sx = x + s.clone_offset[0];
|
||||
let sy = y + s.clone_offset[1];
|
||||
let fx = (sx / cell).round().clamp(0.0, (m.nx - 1) as f64) as usize;
|
||||
let fy = (sy / cell).round().clamp(0.0, (m.ny - 1) as f64) as usize;
|
||||
let src = prev[fy * m.nx + fx];
|
||||
let tgt = [src[0] + s.clone_offset[0], src[1] + s.clone_offset[1]];
|
||||
d[0] += (tgt[0] - d[0]) * w;
|
||||
d[1] += (tgt[1] - d[1]) * w;
|
||||
}
|
||||
// Reconstruction: relax toward no distortion.
|
||||
_ => {
|
||||
d[0] *= 1.0 - w;
|
||||
d[1] *= 1.0 - w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
type MeshCache = Mutex<HashMap<u64, Arc<Mesh>>>;
|
||||
|
||||
fn cached_mesh(ctx: &EffectCtx) -> Arc<Mesh> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let text = ctx.params.s("distortionMesh");
|
||||
let mask = ctx.params.f("freezeAreaMask").round() as usize;
|
||||
let poly = mask.checked_sub(1).and_then(|i| ctx.env.masks.get(i)).cloned();
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut h);
|
||||
ctx.layer_size[0].to_bits().hash(&mut h);
|
||||
ctx.layer_size[1].to_bits().hash(&mut h);
|
||||
if let Some(p) = &poly {
|
||||
for q in &p.points {
|
||||
q[0].to_bits().hash(&mut h);
|
||||
q[1].to_bits().hash(&mut h);
|
||||
}
|
||||
p.inverted.hash(&mut h);
|
||||
}
|
||||
let key = h.finish();
|
||||
static CACHE: OnceLock<MeshCache> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(m) = cache.lock().ok().and_then(|c| c.get(&key).cloned()) {
|
||||
return m;
|
||||
}
|
||||
let strokes = parse_strokes(text);
|
||||
let frozen = |x: f64, y: f64| match &poly {
|
||||
Some(p) => point_in_poly(&p.points, x, y) != p.inverted,
|
||||
None => false,
|
||||
};
|
||||
let m = Arc::new(build_mesh(&strokes, ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0), &frozen));
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
if c.len() > 32 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, m.clone());
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn liquify(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pct = ctx.params.f("distortionPercentage") / 100.0;
|
||||
let show_freeze = ctx.params.b("viewFreezeAreaMask");
|
||||
let show_mesh = ctx.params.b("viewMesh");
|
||||
if ctx.params.s("distortionMesh").trim().is_empty() && !show_freeze && !show_mesh {
|
||||
return b;
|
||||
}
|
||||
let mesh = cached_mesh(ctx);
|
||||
let off = ctx.params.v2("distortionMeshOffset");
|
||||
let mesh_color = ctx.params.color("meshColor");
|
||||
let src = b.img.clone();
|
||||
let (scale, o) = (b.scale, b.offset);
|
||||
let line_w = 0.6 / scale.max(1e-6);
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let lx = (x as f64 + 0.5 - o[0]) / scale;
|
||||
let ly = (y as f64 + 0.5 - o[1]) / scale;
|
||||
let d = mesh.at(lx - off[0], ly - off[1]);
|
||||
let (sx, sy) = (lx + d[0] * pct, ly + d[1] * pct);
|
||||
let mut c = if d == [0.0; 2] { *px } else { src.sample_bilinear(sx * scale + o[0], sy * scale + o[1]) };
|
||||
if show_freeze {
|
||||
let fx = ((lx - off[0]) / mesh.cell).round().clamp(0.0, (mesh.nx - 1) as f64) as usize;
|
||||
let fy = ((ly - off[1]) / mesh.cell).round().clamp(0.0, (mesh.ny - 1) as f64) as usize;
|
||||
let f = mesh.freeze[fy * mesh.nx + fx] as f32;
|
||||
if f > 0.0 {
|
||||
c[0] = c[0] * (1.0 - 0.5 * f) + 0.5 * f;
|
||||
c[3] = c[3].max(0.5 * f);
|
||||
}
|
||||
}
|
||||
if show_mesh {
|
||||
// Grid lines every 4 mesh cells, displaced with the mesh.
|
||||
let gs = mesh.cell * 4.0;
|
||||
let (gx, gy) = ((lx + d[0] * pct - off[0]).rem_euclid(gs), (ly + d[1] * pct - off[1]).rem_euclid(gs));
|
||||
if gx < line_w || gy < line_w {
|
||||
c = [mesh_color[0], mesh_color[1], mesh_color[2], 1.0];
|
||||
}
|
||||
}
|
||||
*px = c;
|
||||
}
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Rolling Shutter Repair
|
||||
|
||||
/// Per-pixel velocity field (layer pixels per frame) on a coarse grid, from tracked points.
|
||||
struct Flow {
|
||||
nx: usize,
|
||||
ny: usize,
|
||||
cell: f64,
|
||||
v: Vec<[f64; 2]>,
|
||||
}
|
||||
|
||||
impl Flow {
|
||||
fn at(&self, x: f64, y: f64) -> [f64; 2] {
|
||||
let fx = (x / self.cell - 0.5).clamp(0.0, (self.nx - 1) as f64);
|
||||
let fy = (y / self.cell - 0.5).clamp(0.0, (self.ny - 1) as f64);
|
||||
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
|
||||
let (x1, y1) = ((x0 + 1).min(self.nx - 1), (y0 + 1).min(self.ny - 1));
|
||||
let (tx, ty) = (fx - x0 as f64, fy - y0 as f64);
|
||||
let g = |i: usize, j: usize| self.v[j * self.nx + i];
|
||||
let l = |a: [f64; 2], b: [f64; 2], t: f64| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
l(l(g(x0, y0), g(x1, y0), tx), l(g(x0, y1), g(x1, y1), tx), ty)
|
||||
}
|
||||
}
|
||||
|
||||
fn median(v: &mut [f64]) -> f64 {
|
||||
v.sort_by(f64::total_cmp);
|
||||
v[v.len() / 2]
|
||||
}
|
||||
|
||||
/// Measure motion around the current frame (`cur`) from `prev` to `next` (all in the same
|
||||
/// buffer grid). `per_row`: Warp method (one velocity per scanline band).
|
||||
fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool) -> Option<Flow> {
|
||||
use effectcraft_track::klt::{GrayPyramid, LkOpts, analysis_factor, track};
|
||||
let factor = analysis_factor(cur.width, cur.height, if detailed { 640 } else { 320 });
|
||||
let pc = GrayPyramid::from_image(cur, [0.0; 2], factor, 4);
|
||||
let (w, h) = (pc.width(), pc.height());
|
||||
let n = if detailed { 24 } else { 12 };
|
||||
let cell_w = w as f64 / n as f64;
|
||||
let rows = ((n as f64) * h as f64 / w.max(1) as f64).round().max(2.0) as usize;
|
||||
let cell_h = h as f64 / rows as f64;
|
||||
let pts: Vec<[f64; 2]> = (0..rows).flat_map(|j| (0..n).map(move |i| [(i as f64 + 0.5) * cell_w, (j as f64 + 0.5) * cell_h])).collect();
|
||||
let o = LkOpts { radius: 4, fb_max: 1.0, ..Default::default() };
|
||||
let go = |other: Option<&Image>| other.map(|img| track(&pc, &GrayPyramid::from_image(img, [0.0; 2], factor, 4), &pts, None, &o));
|
||||
let (tn, tp) = (go(next), go(prev));
|
||||
// Velocity in plane pixels per frame.
|
||||
let vel: Vec<Option<[f64; 2]>> = (0..pts.len())
|
||||
.map(|i| {
|
||||
let p = pts[i];
|
||||
let fw = tn.as_ref().and_then(|t| t[i]).map(|q| [q[0] - p[0], q[1] - p[1]]);
|
||||
let bw = tp.as_ref().and_then(|t| t[i]).map(|q| [p[0] - q[0], p[1] - q[1]]);
|
||||
match (fw, bw) {
|
||||
(Some(a), Some(b)) => Some([(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5]),
|
||||
(a, b) => a.or(b),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let valid: Vec<[f64; 2]> = vel.iter().flatten().copied().collect();
|
||||
if valid.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut vx: Vec<f64> = valid.iter().map(|v| v[0]).collect();
|
||||
let mut vy: Vec<f64> = valid.iter().map(|v| v[1]).collect();
|
||||
let global = [median(&mut vx), median(&mut vy)];
|
||||
let k = factor as f64 / scale.max(1e-9);
|
||||
let mut v = vec![[0.0; 2]; n * rows];
|
||||
for j in 0..rows {
|
||||
// Row (band) median for Warp; per point (outliers replaced) for Pixel Motion.
|
||||
let band: Vec<[f64; 2]> = (0..n).filter_map(|i| vel[j * n + i]).collect();
|
||||
let row_med = if band.is_empty() {
|
||||
global
|
||||
} else {
|
||||
let mut a: Vec<f64> = band.iter().map(|v| v[0]).collect();
|
||||
let mut b: Vec<f64> = band.iter().map(|v| v[1]).collect();
|
||||
[median(&mut a), median(&mut b)]
|
||||
};
|
||||
for i in 0..n {
|
||||
let s = if per_row {
|
||||
row_med
|
||||
} else {
|
||||
match vel[j * n + i] {
|
||||
Some(q) if (q[0] - row_med[0]).abs() + (q[1] - row_med[1]).abs() < 3.0 + 0.5 * (row_med[0].abs() + row_med[1].abs()) => q,
|
||||
_ => row_med,
|
||||
}
|
||||
};
|
||||
v[j * n + i] = [s[0] * k, s[1] * k];
|
||||
}
|
||||
}
|
||||
// Cell size in layer pixels (grid is uniform in plane pixels; assume square plane pixels).
|
||||
Some(Flow { nx: n, ny: rows, cell: cell_w * k, v })
|
||||
}
|
||||
|
||||
fn same_grid(nb: Buf, b: &Buf) -> Image {
|
||||
if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale {
|
||||
return nb.img;
|
||||
}
|
||||
let k = nb.scale / b.scale.max(1e-9);
|
||||
crate::util::gen_image(b.img.width, b.img.height, |x, y| {
|
||||
let lx = (x as f64 + 0.5 - b.offset[0]) * k + nb.offset[0];
|
||||
let ly = (y as f64 + 0.5 - b.offset[1]) * k + nb.offset[1];
|
||||
nb.img.sample_bilinear(lx, ly)
|
||||
})
|
||||
}
|
||||
|
||||
fn rolling_shutter(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let rate = ctx.params.f("rollingShutterRate") / 100.0;
|
||||
if rate == 0.0 {
|
||||
return b;
|
||||
}
|
||||
let Some(host) = ctx.env.host else { return b };
|
||||
let fps = ctx.fps();
|
||||
let prev = host.self_at(ctx.time - 1.0 / fps, ctx.env.effect_index).map(|nb| same_grid(nb, &b));
|
||||
let next = host.self_at(ctx.time + 1.0 / fps, ctx.env.effect_index).map(|nb| same_grid(nb, &b));
|
||||
if prev.is_none() && next.is_none() {
|
||||
return b;
|
||||
}
|
||||
let per_row = ctx.params.e("method") == 0;
|
||||
let Some(flow) = measure(&b.img, prev.as_ref(), next.as_ref(), 1.0, ctx.params.b("detailedAnalysis"), per_row) else { return b };
|
||||
let dir = ctx.params.e("scanDirection");
|
||||
let src = b.img.clone();
|
||||
let (w, h) = (b.img.width as f64, b.img.height as f64);
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let s = match dir {
|
||||
1 => 1.0 - fy / h,
|
||||
2 => fx / w,
|
||||
3 => 1.0 - fx / w,
|
||||
_ => fy / h,
|
||||
};
|
||||
let tau = rate * (s - 0.5);
|
||||
let v = flow.at(fx, fy);
|
||||
*px = src.sample_bilinear_clamped(fx + v[0] * tau, fy + v[1] * tau);
|
||||
}
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.distort.ccflomotion",
|
||||
"CC Flo Motion",
|
||||
vec![
|
||||
p("knot1", "Knot 1", Value::Vec2([0.25, 0.5]), ParamUi::Point),
|
||||
p("amount1", "Amount 1", num(50.0), slider(-400.0, 100.0, -100.0, 100.0, 1)),
|
||||
p("knot2", "Knot 2", Value::Vec2([0.75, 0.5]), ParamUi::Point),
|
||||
p("amount2", "Amount 2", num(-50.0), slider(-400.0, 100.0, -100.0, 100.0, 1)),
|
||||
p("tileEdges", "Tile Edges", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("antialiasing", "Antialiasing", Value::Enum(1), popup(&["Low", "Medium", "High"])),
|
||||
p("falloff", "Falloff", num(2.0), slider(0.01, 10.0, 0.1, 5.0, 2)),
|
||||
],
|
||||
flo_motion,
|
||||
),
|
||||
spec(
|
||||
"ec.distort.liquify",
|
||||
"Liquify",
|
||||
vec![
|
||||
p("tool", "Tool", Value::Enum(0), popup(&LIQUIFY_TOOLS)),
|
||||
p("brushSize", "Brush Size", num(64.0), slider(1.0, 600.0, 1.0, 600.0, 0)),
|
||||
p("brushPressure", "Brush Pressure", num(50.0), slider(1.0, 100.0, 1.0, 100.0, 0)),
|
||||
p("freezeAreaMask", "Freeze Area Mask", num(0.0), ParamUi::Mask),
|
||||
p("turbulentJitter", "Turbulent Jitter", num(70.0), slider(1.0, 100.0, 1.0, 100.0, 0)),
|
||||
p("cloneOffset", "Clone Offset", Value::Vec2([0.0, 0.0]), ParamUi::Hidden),
|
||||
p("reconstructionMode", "Reconstruction Mode", Value::Enum(0), popup(&["Revert", "Displace", "Amplitwist", "Affine"])),
|
||||
p("viewFreezeAreaMask", "View Freeze Area Mask", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("viewMesh", "View Mesh", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("meshSize", "Mesh Size", Value::Enum(1), popup(&["Small", "Medium", "Large"])),
|
||||
p("meshColor", "Mesh Color", col(0.5, 0.5, 0.5), ParamUi::Color),
|
||||
// The strokes (see `LiquifyStroke`); written by the `liquify.stroke` command.
|
||||
p("distortionMesh", "Distortion Mesh", Value::Str(String::new()), ParamUi::Hidden),
|
||||
p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Hidden),
|
||||
p("distortionPercentage", "Distortion Percentage", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("distortionDisplayQuality", "Distortion Display Quality", Value::Enum(1), popup(&["Draft", "Smooth"])),
|
||||
],
|
||||
liquify,
|
||||
),
|
||||
spec(
|
||||
"ec.distort.rollingshutterrepair",
|
||||
"Rolling Shutter Repair",
|
||||
vec![
|
||||
p("rollingShutterRate", "Rolling Shutter Rate", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("scanDirection", "Scan Direction", Value::Enum(0), popup(&["Top → Bottom", "Bottom → Top", "Left → Right", "Right → Left"])),
|
||||
p("method", "Method", Value::Enum(0), popup(&["Warp", "Pixel Motion"])),
|
||||
p("detailedAnalysis", "Detailed Analysis", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("pixelMotionDetail", "Pixel Motion Detail", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
],
|
||||
rolling_shutter,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, EffectHost, LayerPixels, run_fx};
|
||||
|
||||
fn grid_img(w: u32, h: u32) -> Image {
|
||||
crate::util::gen_image(w, h, |x, y| {
|
||||
let v = if x % 8 == 0 || y % 8 == 0 { 1.0 } else { 0.2 };
|
||||
[v, v * 0.5, x as f32 / w as f32, 1.0]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flo_motion_pulls_and_pushes() {
|
||||
let img = grid_img(64, 64);
|
||||
let out = run_fx(
|
||||
"ec.distort.ccflomotion",
|
||||
&[("knot1", Value::Vec2([32.0, 32.0])), ("amount1", num(80.0)), ("amount2", num(0.0))],
|
||||
img.clone(),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
// The knot itself is fixed; pixels near it are magnified (sampled closer to the knot).
|
||||
assert!((out.img.get(32, 32)[2] - img.get(32, 32)[2]).abs() < 0.01);
|
||||
assert!((out.img.get(40, 32)[2] - img.get(40, 32)[2]).abs() > 0.01);
|
||||
assert!(out.img.get(40, 32)[2] < img.get(40, 32)[2]);
|
||||
let none = run_fx("ec.distort.ccflomotion", &[("amount1", num(0.0)), ("amount2", num(0.0))], img.clone(), 0.0, EffectEnv::default());
|
||||
assert_eq!(none.img.data, img.data);
|
||||
let again = run_fx(
|
||||
"ec.distort.ccflomotion",
|
||||
&[("knot1", Value::Vec2([32.0, 32.0])), ("amount1", num(80.0)), ("amount2", num(0.0))],
|
||||
img,
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liquify_strokes_round_trip_and_warp() {
|
||||
let s = LiquifyStroke { tool: 0, size: 30.0, pressure: 80.0, jitter: 70.0, clone_offset: [0.0, 0.0], points: vec![[20.0, 32.0], [40.0, 32.0]] };
|
||||
assert_eq!(LiquifyStroke::parse(&s.to_line()), Some(s.clone()));
|
||||
assert!(LiquifyStroke::parse("nope 1 2 3 4 5 1,2").is_none());
|
||||
let img = grid_img(64, 64);
|
||||
let out = run_fx("ec.distort.liquify", &[("distortionMesh", Value::Str(s.to_line()))], img.clone(), 0.0, EffectEnv::default());
|
||||
assert_ne!(out.img.data, img.data);
|
||||
// Warping to the right: a pixel right of the start now shows content from its left.
|
||||
let at = |i: &Image| i.get(36, 32)[2];
|
||||
assert!(at(&out.img) < at(&img), "{} vs {}", at(&out.img), at(&img));
|
||||
// Far away, untouched.
|
||||
assert_eq!(out.img.get(5, 5), img.get(5, 5));
|
||||
// Distortion Percentage 0 = identity; deterministic.
|
||||
let zero = run_fx(
|
||||
"ec.distort.liquify",
|
||||
&[("distortionMesh", Value::Str(s.to_line())), ("distortionPercentage", num(0.0))],
|
||||
img.clone(),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
assert_eq!(zero.img.data, img.data);
|
||||
let again = run_fx("ec.distort.liquify", &[("distortionMesh", Value::Str(s.to_line()))], img.clone(), 0.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
// Reconstruction undoes the warp; freeze protects.
|
||||
let mut rec = s.clone();
|
||||
rec.tool = 9;
|
||||
rec.pressure = 100.0;
|
||||
let lines = format!("{}\n{}\n{}\n{}", s.to_line(), rec.to_line(), rec.to_line(), rec.to_line());
|
||||
let m1 = build_mesh(&parse_strokes(&s.to_line()), 64.0, 64.0, &|_, _| false);
|
||||
let m2 = build_mesh(&parse_strokes(&lines), 64.0, 64.0, &|_, _| false);
|
||||
let mag = |m: &Mesh| m.d.iter().map(|d| d[0].abs() + d[1].abs()).sum::<f64>();
|
||||
assert!(mag(&m2) < mag(&m1) * 0.5);
|
||||
let frozen = build_mesh(&parse_strokes(&s.to_line()), 64.0, 64.0, &|_, _| true);
|
||||
assert_eq!(mag(&frozen), 0.0);
|
||||
// Every tool changes (or deliberately keeps) the mesh without NaNs.
|
||||
for t in 0..LIQUIFY_TOOLS.len() {
|
||||
let st = LiquifyStroke { tool: t, clone_offset: [10.0, 0.0], ..s.clone() };
|
||||
let m = build_mesh(&[s.clone(), st], 64.0, 64.0, &|_, _| false);
|
||||
assert!(m.d.iter().all(|d| d[0].is_finite() && d[1].is_finite()), "{}", LIQUIFY_TOOLS[t]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A textured frame panning right at 3 px per frame, captured with a rolling shutter: row
|
||||
/// `y` is exposed `(y / h − ½)` frames late (rate 100 %).
|
||||
fn texture(x: f64, y: f64) -> f32 {
|
||||
let a = ((x * 0.31).sin() * (y * 0.23).cos() + (x * 0.07 + y * 0.11).sin()) as f32;
|
||||
0.5 + 0.25 * a
|
||||
}
|
||||
fn rs_frame(f: f64, shutter: bool) -> Image {
|
||||
let (w, h) = (96u32, 64u32);
|
||||
crate::util::gen_image(w, h, |x, y| {
|
||||
let tau = if shutter { (y as f64 + 0.5) / h as f64 - 0.5 } else { 0.0 };
|
||||
let shift = 3.0 * (f + tau);
|
||||
let v = texture(x as f64 + 0.5 - shift, y as f64 + 0.5);
|
||||
[v, v, v, 1.0]
|
||||
})
|
||||
}
|
||||
struct Pan;
|
||||
impl EffectHost for Pan {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
|
||||
Some(Buf { img: rs_frame(t * 24.0, true), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_shutter_repair_unskews_a_pan() {
|
||||
let host = Pan;
|
||||
let env = EffectEnv { host: Some(&host), frame_rate: 24.0, ..Default::default() };
|
||||
let t = 5.0 / 24.0;
|
||||
let skewed = rs_frame(5.0, true);
|
||||
let truth = rs_frame(5.0, false);
|
||||
let err = |a: &Image| {
|
||||
let mut s = 0.0;
|
||||
let mut n = 0.0;
|
||||
for y in 4..60 {
|
||||
for x in 12..84 {
|
||||
s += (a.get(x, y)[0] - truth.get(x, y)[0]).abs();
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
s / n
|
||||
};
|
||||
for method in [0u32, 1] {
|
||||
let out = run_fx("ec.distort.rollingshutterrepair", &[("rollingShutterRate", num(100.0)), ("method", Value::Enum(method))], skewed.clone(), t, env);
|
||||
assert!(err(&out.img) < err(&skewed) * 0.4, "method {method}: {} vs {}", err(&out.img), err(&skewed));
|
||||
let again =
|
||||
run_fx("ec.distort.rollingshutterrepair", &[("rollingShutterRate", num(100.0)), ("method", Value::Enum(method))], skewed.clone(), t, env);
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
}
|
||||
let none = run_fx("ec.distort.rollingshutterrepair", &[], skewed.clone(), t, EffectEnv::default());
|
||||
assert_eq!(none.img.data, skewed.data);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,455 @@
|
|||
//! Key Light — a full screen-colour keyer with the controls of After Effects' bundled
|
||||
//! "Keylight (1.2)" (that name is a trademark of its vendor, so the effect is registered as
|
||||
//! **Key Light**; `lookup("Keylight (1.2)")` finds it through [`KEYLIGHT_ALIASES`]).
|
||||
//!
|
||||
//! The method is the textbook *screen difference* key, written from the public description of
|
||||
//! the controls:
|
||||
//!
|
||||
//! * colours are first neutralised by the **Alpha Bias** / **Despill Bias** colours (each channel
|
||||
//! scaled so the bias colour becomes grey — mid grey = no change);
|
||||
//! * the **screen difference** of a pixel is its screen-primary channel minus a balance-weighted
|
||||
//! mix of the other two (**Screen Balance**); the raw matte is
|
||||
//! `1 − Screen Gain × diff(pixel) / diff(screen colour)`;
|
||||
//! * the **Screen Matte** is then clipped (Clip Black / White, with Clip Rollback restoring edge
|
||||
//! detail), despotted (morphological open/close: Despot Black / White), shrunk or grown and
|
||||
//! softened, combined with the **Inside** / **Outside** masks and the source alpha;
|
||||
//! * the foreground is the source with the screen colour removed in proportion to the
|
||||
//! transparency (`(c − (1 − α)·S) / α`), then **despilled** (the primary is limited to the
|
||||
//! balanced secondary), with **Replace Method** colouring areas whose alpha was raised by
|
||||
//! clipping or the inside mask, and optional foreground and edge colour correction.
|
||||
//!
|
||||
//! Views: Source, Source Alpha, Corrected Source, Colour Correction Edges, Screen Matte, Inside
|
||||
//! Mask, Outside Mask, Combined Matte, Status, Intermediate Result and Final Result.
|
||||
|
||||
use effectcraft_color::luminance;
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::{Image, gaussian_blur};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::util::{Plane, gauss_plane, morph_frac, point_in_poly, premul, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
/// Other names the effect answers to in [`crate::lookup`].
|
||||
pub const KEYLIGHT_ALIASES: &[&str] = &["Keylight (1.2)", "Keylight"];
|
||||
|
||||
pub const VIEWS: [&str; 11] = [
|
||||
"Source",
|
||||
"Source Alpha",
|
||||
"Corrected Source",
|
||||
"Colour Correction Edges",
|
||||
"Screen Matte",
|
||||
"Inside Mask",
|
||||
"Outside Mask",
|
||||
"Combined Matte",
|
||||
"Status",
|
||||
"Intermediate Result",
|
||||
"Final Result",
|
||||
];
|
||||
|
||||
const REPLACE: [&str; 4] = ["None", "Source", "Hard Colour", "Soft Colour"];
|
||||
|
||||
fn pct(d: f64) -> (Value, ParamUi) {
|
||||
(num(d), slider(0.0, 100.0, 0.0, 100.0, 1))
|
||||
}
|
||||
|
||||
fn mask_popup() -> ParamUi {
|
||||
popup(&["None", "Mask 1", "Mask 2", "Mask 3", "Mask 4", "Mask 5", "Mask 6", "Mask 7", "Mask 8", "Mask 9", "Mask 10"])
|
||||
}
|
||||
|
||||
/// Index of the screen's primary channel and the two others (larger-in-screen first).
|
||||
fn primary(s: [f32; 3]) -> (usize, usize, usize) {
|
||||
let pi = if s[1] >= s[0] && s[1] >= s[2] {
|
||||
1
|
||||
} else if s[2] >= s[0] {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let (a, b) = match pi {
|
||||
0 => (1, 2),
|
||||
1 => (0, 2),
|
||||
_ => (0, 1),
|
||||
};
|
||||
if s[a] >= s[b] { (pi, a, b) } else { (pi, b, a) }
|
||||
}
|
||||
|
||||
/// Screen difference: primary minus the balance-weighted secondaries.
|
||||
#[inline]
|
||||
pub fn screen_diff(c: [f32; 3], pi: usize, o1: usize, o2: usize, bal: f32) -> f32 {
|
||||
c[pi] - (bal * c[o1] + (1.0 - bal) * c[o2])
|
||||
}
|
||||
|
||||
/// Scale channels so `bias` becomes neutral (grey keeps everything as is).
|
||||
#[inline]
|
||||
fn neutralise(c: [f32; 3], bias: [f32; 3]) -> [f32; 3] {
|
||||
let l = luminance(bias[0], bias[1], bias[2]).max(1e-4);
|
||||
[0, 1, 2].map(|i| c[i] * l / bias[i].max(1e-4))
|
||||
}
|
||||
|
||||
fn mask_coverage(ctx: &EffectCtx, b: &Buf, idx: u32, softness: f64, invert: bool) -> Option<Plane> {
|
||||
let shape = ctx.env.masks.get((idx as usize).checked_sub(1)?)?;
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let mut pl = Plane::new(w, h);
|
||||
let inv = 1.0 / b.scale.max(1e-9);
|
||||
pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let lx = (x as f64 + 0.5 - b.offset[0]) * inv;
|
||||
let ly = (y as f64 + 0.5 - b.offset[1]) * inv;
|
||||
*v = if point_in_poly(&shape.points, lx, ly) != shape.inverted { 1.0 } else { 0.0 };
|
||||
}
|
||||
});
|
||||
let s = softness * b.scale;
|
||||
if s > 0.0 {
|
||||
pl = gauss_plane(&pl, s * 0.5, s * 0.5);
|
||||
}
|
||||
if invert {
|
||||
pl = pl.map(|v| 1.0 - v);
|
||||
}
|
||||
Some(pl)
|
||||
}
|
||||
|
||||
/// Saturation / contrast / brightness correction of a straight colour.
|
||||
fn correct(c: [f32; 3], sat: f32, contrast: f32, bright: f32) -> [f32; 3] {
|
||||
let l = luminance(c[0], c[1], c[2]);
|
||||
let c = c.map(|v| l + (v - l) * sat);
|
||||
c.map(|v| ((v - 0.5) * contrast + 0.5) * bright)
|
||||
}
|
||||
|
||||
fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let view = pr.e("view");
|
||||
if view == 0 {
|
||||
return b;
|
||||
}
|
||||
let sc = pr.color("screenColour");
|
||||
let screen = [sc[0], sc[1], sc[2]];
|
||||
let (pi, o1, o2) = primary(screen);
|
||||
let gain = pr.f("screenGain") as f32 / 100.0;
|
||||
let bal = pr.f("screenBalance") as f32 / 100.0;
|
||||
let dbias = pr.color("despillBias");
|
||||
let abias = if pr.b("lockBiasesTogether") { dbias } else { pr.color("alphaBias") };
|
||||
let (dbias, abias) = ([dbias[0], dbias[1], dbias[2]], [abias[0], abias[1], abias[2]]);
|
||||
let s_alpha = neutralise(screen, abias);
|
||||
let sd = screen_diff(s_alpha, pi, o1, o2, bal).max(1e-3);
|
||||
let cb = pr.f("clipBlack") as f32 / 100.0;
|
||||
let cw = pr.f("clipWhite") as f32 / 100.0;
|
||||
let rollback = pr.f("clipRollback") * b.scale;
|
||||
let preblur = pr.f("screenPreblur") * b.scale;
|
||||
let sg = pr.f("screenShrinkGrow") * b.scale;
|
||||
let soft = pr.f("screenSoftness") * b.scale;
|
||||
let despot_b = pr.f("screenDespotBlack") * b.scale;
|
||||
let despot_w = pr.f("screenDespotWhite") * b.scale;
|
||||
|
||||
let src = b.img.clone();
|
||||
let blurred;
|
||||
let key_src: &Image = if preblur > 0.0 {
|
||||
blurred = gaussian_blur(&src, preblur * 0.5, preblur * 0.5, true);
|
||||
&blurred
|
||||
} else {
|
||||
&src
|
||||
};
|
||||
// Raw screen matte (opacity of the foreground).
|
||||
let raw = Plane::from_image(key_src, |px| {
|
||||
let (c, _) = unpremul(px);
|
||||
let d = screen_diff(neutralise(c, abias), pi, o1, o2, bal);
|
||||
(1.0 - gain * d / sd).clamp(0.0, 1.0)
|
||||
});
|
||||
let clip = |v: f32| ((v - cb) / (cw - cb).max(1e-4)).clamp(0.0, 1.0);
|
||||
let mut m = raw.map(clip);
|
||||
if rollback > 0.0 {
|
||||
// Restore edge detail lost by clipping within `rollback` pixels of the clipped matte.
|
||||
let grown = morph_frac(&m, rollback, true);
|
||||
let shrunk = morph_frac(&m, rollback, false);
|
||||
m = Plane {
|
||||
w: m.w,
|
||||
h: m.h,
|
||||
data: (0..m.data.len())
|
||||
.into_par_iter()
|
||||
.map(|i| if grown.data[i] > shrunk.data[i] + 1e-4 { m.data[i].max(raw.data[i]).min(grown.data[i]) } else { m.data[i] })
|
||||
.collect(),
|
||||
};
|
||||
}
|
||||
if despot_b > 0.0 {
|
||||
// Fill small holes: close (dilate then erode).
|
||||
m = morph_frac(&morph_frac(&m, despot_b, true), despot_b, false);
|
||||
}
|
||||
if despot_w > 0.0 {
|
||||
// Remove small specks: open (erode then dilate).
|
||||
m = morph_frac(&morph_frac(&m, despot_w, false), despot_w, true);
|
||||
}
|
||||
if sg < 0.0 {
|
||||
m = morph_frac(&m, -sg, false);
|
||||
} else if sg > 0.0 {
|
||||
m = morph_frac(&m, sg, true);
|
||||
}
|
||||
if soft > 0.0 {
|
||||
m = gauss_plane(&m, soft * 0.5, soft * 0.5);
|
||||
}
|
||||
let screen_matte = m.clone();
|
||||
let inside = mask_coverage(ctx, &b, pr.e("insideMask"), pr.f("insideMaskSoftness"), pr.b("invertInsideMask"));
|
||||
let outside = mask_coverage(ctx, &b, pr.e("outsideMask"), pr.f("outsideMaskSoftness"), pr.b("invertOutsideMask"));
|
||||
let src_alpha_mode = pr.e("sourceAlpha");
|
||||
let n = src.data.len();
|
||||
let combined: Vec<f32> = (0..n)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let mut a = screen_matte.data[i];
|
||||
let sa = src.data[i][3].clamp(0.0, 1.0);
|
||||
let ins = inside.as_ref().map_or(0.0, |p| p.data[i]);
|
||||
let ins = if src_alpha_mode == 1 { ins.max(sa) } else { ins };
|
||||
a = a.max(ins);
|
||||
if let Some(o) = &outside {
|
||||
a *= 1.0 - o.data[i];
|
||||
}
|
||||
if src_alpha_mode == 2 {
|
||||
a *= sa;
|
||||
}
|
||||
a.clamp(0.0, 1.0)
|
||||
})
|
||||
.collect();
|
||||
let edges: Option<Vec<f32>> = pr.b("enableEdgeColourCorrection").then(|| {
|
||||
let grow = pr.f("edgeGrow") * b.scale;
|
||||
let hard = (pr.f("edgeHardness") / 100.0) as f32;
|
||||
let esoft = pr.f("edgeSoftness") * b.scale;
|
||||
// Edge band: where the (grown) combined matte is partial.
|
||||
let cm = Plane { w: m.w, h: m.h, data: combined.clone() };
|
||||
let band = cm.map(|a| if a > 1e-3 && a < 0.999 { 1.0 } else { 0.0 });
|
||||
let mut band = if grow > 0.0 { morph_frac(&band, grow, true) } else { band };
|
||||
if esoft > 0.0 {
|
||||
band = gauss_plane(&band, esoft * 0.5, esoft * 0.5);
|
||||
}
|
||||
band.data.iter().map(|v| (v * (1.0 + hard * 4.0)).min(1.0)).collect()
|
||||
});
|
||||
let replace = pr.e("replaceMethod");
|
||||
let rc = pr.color("replaceColour");
|
||||
let in_replace = pr.e("insideReplaceMethod");
|
||||
let in_rc = pr.color("insideReplaceColour");
|
||||
let fg_cc = pr.b("enableColourCorrection");
|
||||
let (fs, fc, fbr) = (pr.f("saturation") as f32 / 100.0, pr.f("contrast") as f32 / 100.0 + 1.0, pr.f("brightness") as f32 / 100.0 + 1.0);
|
||||
let (es, ec, ebr) = (pr.f("edgeSaturation") as f32 / 100.0, pr.f("edgeContrast") as f32 / 100.0 + 1.0, pr.f("edgeBrightness") as f32 / 100.0 + 1.0);
|
||||
let unpremultiply = pr.b("unpremultiplyResult");
|
||||
let crops = [pr.f("cropLeft"), pr.f("cropRight"), pr.f("cropTop"), pr.f("cropBottom")].map(|v| v / 100.0);
|
||||
let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]);
|
||||
let w = b.img.width as usize;
|
||||
let (scale, off) = (b.scale, b.offset);
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let (c, _) = unpremul(src.data[i]);
|
||||
let a = combined[i];
|
||||
let sm = screen_matte.data[i];
|
||||
// Source crops (layer fractions from each edge).
|
||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
let (lx, ly) = ((x - off[0]) / scale / lw.max(1e-9), (y - off[1]) / scale / lh.max(1e-9));
|
||||
let cropped = lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3];
|
||||
// Screen removal on the raw (unclipped) transparency, then despill.
|
||||
let ar = raw.data[i].max(1e-4);
|
||||
let mut fg = [0, 1, 2].map(|k| ((c[k] - (1.0 - ar) * screen[k]) / ar).max(0.0));
|
||||
let nfg = neutralise(fg, dbias);
|
||||
let spill = screen_diff(nfg, pi, o1, o2, bal).max(0.0);
|
||||
let l0 = (luminance(dbias[0], dbias[1], dbias[2]) / dbias[pi].max(1e-4)).max(1e-4);
|
||||
fg[pi] -= spill / l0;
|
||||
let intermediate = fg;
|
||||
// Replace colour where the alpha was raised beyond the raw screen matte.
|
||||
let raised = (sm - raw.data[i]).max(0.0);
|
||||
let raised_in = inside.as_ref().map_or(0.0, |p| (p.data[i] - sm).max(0.0));
|
||||
let apply_replace = |fg: [f32; 3], method: u32, rc: [f32; 4], amt: f32| -> [f32; 3] {
|
||||
if amt <= 0.0 {
|
||||
return fg;
|
||||
}
|
||||
match method {
|
||||
1 => [0, 1, 2].map(|k| fg[k] + (c[k] - fg[k]) * amt),
|
||||
2 => [rc[0], rc[1], rc[2]],
|
||||
3 => {
|
||||
let l = luminance(c[0], c[1], c[2]) / luminance(rc[0], rc[1], rc[2]).max(1e-4);
|
||||
[0, 1, 2].map(|k| fg[k] + (rc[k] * l - fg[k]) * amt)
|
||||
}
|
||||
_ => fg,
|
||||
}
|
||||
};
|
||||
fg = apply_replace(fg, replace, rc, raised);
|
||||
fg = apply_replace(fg, in_replace, in_rc, raised_in);
|
||||
if fg_cc {
|
||||
fg = correct(fg, fs, fc, fbr);
|
||||
}
|
||||
let edge = edges.as_ref().map_or(0.0, |e| e[i]);
|
||||
if edge > 0.0 {
|
||||
let ce = correct(fg, es, ec, ebr);
|
||||
fg = [0, 1, 2].map(|k| fg[k] + (ce[k] - fg[k]) * edge);
|
||||
}
|
||||
let grey = |v: f32| [v, v, v, 1.0];
|
||||
*px = match view {
|
||||
1 => grey(src.data[i][3]),
|
||||
2 => {
|
||||
let mut cs = neutralise(c, dbias);
|
||||
let s2 = screen_diff(cs, pi, o1, o2, bal).max(0.0);
|
||||
cs[pi] -= s2;
|
||||
if fg_cc {
|
||||
cs = correct(cs, fs, fc, fbr);
|
||||
}
|
||||
[cs[0], cs[1], cs[2], 1.0]
|
||||
}
|
||||
3 => grey(edge),
|
||||
4 => grey(sm),
|
||||
5 => grey(inside.as_ref().map_or(0.0, |p| p.data[i])),
|
||||
6 => grey(outside.as_ref().map_or(0.0, |p| p.data[i])),
|
||||
7 => grey(a),
|
||||
8 => {
|
||||
let v = if a <= 1e-3 {
|
||||
0.0
|
||||
} else if a >= 0.999 {
|
||||
1.0
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
grey(v)
|
||||
}
|
||||
9 => premul(intermediate, sm),
|
||||
_ => {
|
||||
if cropped {
|
||||
[0.0; 4]
|
||||
} else if unpremultiply {
|
||||
premul(fg, a)
|
||||
} else {
|
||||
// Keylight's premultiplied output read as straight colour.
|
||||
premul(fg.map(|v| v * a), a)
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
let pp = |id: &'static str, name: &'static str, v: (Value, ParamUi)| p(id, name, v.0, v.1);
|
||||
let cc = |id: &'static str, name: &'static str| p(id, name, num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1));
|
||||
vec![EffectSpec {
|
||||
id: "ec.keying.keylight",
|
||||
name: "Key Light",
|
||||
category: "Keying",
|
||||
params: vec![
|
||||
p("view", "View", Value::Enum(10), popup(&VIEWS)),
|
||||
p("unpremultiplyResult", "Unpremultiply Result", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("screenColour", "Screen Colour", col(0.0, 1.0, 0.0), ParamUi::Color),
|
||||
p("screenGain", "Screen Gain", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("screenBalance", "Screen Balance", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("despillBias", "Despill Bias", col(0.5, 0.5, 0.5), ParamUi::Color),
|
||||
p("alphaBias", "Alpha Bias", col(0.5, 0.5, 0.5), ParamUi::Color),
|
||||
p("lockBiasesTogether", "Lock Biases Together", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("screenPreblur", "Screen Pre-blur", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 1)),
|
||||
// Screen Matte
|
||||
pp("clipBlack", "Clip Black", pct(0.0)),
|
||||
pp("clipWhite", "Clip White", pct(100.0)),
|
||||
p("clipRollback", "Clip Rollback", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("screenShrinkGrow", "Screen Shrink/Grow", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 1)),
|
||||
p("screenSoftness", "Screen Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("screenDespotBlack", "Screen Despot Black", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("screenDespotWhite", "Screen Despot White", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("replaceMethod", "Replace Method", Value::Enum(3), popup(&REPLACE)),
|
||||
p("replaceColour", "Replace Colour", col(0.5, 0.5, 0.5), ParamUi::Color),
|
||||
// Inside Mask
|
||||
p("insideMask", "Inside Mask", Value::Enum(0), mask_popup()),
|
||||
p("insideMaskSoftness", "Inside Mask Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("invertInsideMask", "Invert", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("insideReplaceMethod", "Inside Replace Method", Value::Enum(1), popup(&REPLACE)),
|
||||
p("insideReplaceColour", "Inside Replace Colour", col(0.5, 0.5, 0.5), ParamUi::Color),
|
||||
p("sourceAlpha", "Source Alpha", Value::Enum(2), popup(&["Ignore", "Add to Inside Mask", "Normal"])),
|
||||
// Outside Mask
|
||||
p("outsideMask", "Outside Mask", Value::Enum(0), mask_popup()),
|
||||
p("outsideMaskSoftness", "Outside Mask Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("invertOutsideMask", "Invert Outside Mask", Value::Bool(false), ParamUi::Checkbox),
|
||||
// Foreground Colour Correction
|
||||
p("enableColourCorrection", "Enable Colour Correction", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("saturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
cc("contrast", "Contrast"),
|
||||
cc("brightness", "Brightness"),
|
||||
// Edge Colour Correction
|
||||
p("enableEdgeColourCorrection", "Enable Edge Colour Correction", Value::Bool(false), ParamUi::Checkbox),
|
||||
pp("edgeHardness", "Edge Hardness", pct(0.0)),
|
||||
p("edgeSoftness", "Edge Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("edgeGrow", "Edge Grow", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("edgeSaturation", "Edge Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
cc("edgeContrast", "Edge Contrast"),
|
||||
cc("edgeBrightness", "Edge Brightness"),
|
||||
// Source Crops
|
||||
pp("cropLeft", "Left", pct(0.0)),
|
||||
pp("cropRight", "Right", pct(0.0)),
|
||||
pp("cropTop", "Top", pct(0.0)),
|
||||
pp("cropBottom", "Bottom", pct(0.0)),
|
||||
],
|
||||
render: key_light,
|
||||
gpu: false,
|
||||
float: true,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, MaskShape, run_fx};
|
||||
|
||||
/// Green screen with a red square (fg) and a half-transparent mix strip.
|
||||
fn plate() -> Image {
|
||||
crate::util::gen_image(40, 30, |x, y| {
|
||||
if (10..20).contains(&x) && (10..20).contains(&y) {
|
||||
[0.8, 0.2, 0.1, 1.0]
|
||||
} else if (25..30).contains(&x) {
|
||||
// 50 % red over green.
|
||||
[0.4, 0.6, 0.05, 1.0]
|
||||
} else {
|
||||
[0.1, 0.9, 0.1, 1.0]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn green() -> Value {
|
||||
col(0.1, 0.9, 0.1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_the_screen_and_keeps_the_foreground() {
|
||||
let out = run_fx("ec.keying.keylight", &[("screenColour", green())], plate(), 0.0, EffectEnv::default());
|
||||
let bg = out.img.get(2, 2);
|
||||
let fg = out.img.get(15, 15);
|
||||
let mix = out.img.get(27, 5);
|
||||
assert!(bg[3] < 0.02, "{bg:?}");
|
||||
assert!(fg[3] > 0.98 && (fg[0] - 0.8).abs() < 0.05, "{fg:?}");
|
||||
assert!(mix[3] > 0.3 && mix[3] < 0.7, "{mix:?}");
|
||||
// The recovered edge colour has the green removed (no spill).
|
||||
let (c, _) = unpremul(mix);
|
||||
assert!(c[1] <= c[0] + 0.05, "{c:?}");
|
||||
// Deterministic.
|
||||
let again = run_fx("ec.keying.keylight", &[("screenColour", green())], plate(), 0.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_views_and_masks() {
|
||||
// Clip White 60 %: the half-transparent strip becomes solid.
|
||||
let out = run_fx("ec.keying.keylight", &[("screenColour", green()), ("clipWhite", num(40.0))], plate(), 0.0, EffectEnv::default());
|
||||
assert!(out.img.get(27, 5)[3] > 0.99);
|
||||
// Screen Matte and Status views are grey-scale and opaque.
|
||||
let sm = run_fx("ec.keying.keylight", &[("screenColour", green()), ("view", Value::Enum(4))], plate(), 0.0, EffectEnv::default());
|
||||
assert!(sm.img.get(2, 2)[0] < 0.02 && sm.img.get(15, 15)[0] > 0.98 && sm.img.get(2, 2)[3] == 1.0);
|
||||
let st = run_fx("ec.keying.keylight", &[("screenColour", green()), ("view", Value::Enum(8))], plate(), 0.0, EffectEnv::default());
|
||||
assert_eq!(st.img.get(27, 5)[0], 0.5);
|
||||
// Source view is the identity.
|
||||
let src = run_fx("ec.keying.keylight", &[("view", Value::Enum(0))], plate(), 0.0, EffectEnv::default());
|
||||
assert_eq!(src.img.data, plate().data);
|
||||
// Inside mask forces opacity; outside mask forces transparency.
|
||||
let sq = |x0: f64, y0: f64, x1: f64, y1: f64| MaskShape {
|
||||
name: "m".into(),
|
||||
points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]],
|
||||
closed: true,
|
||||
inverted: false,
|
||||
};
|
||||
let masks = [sq(0.0, 0.0, 6.0, 6.0), sq(12.0, 12.0, 18.0, 18.0)];
|
||||
let env = EffectEnv { masks: &masks, ..Default::default() };
|
||||
let m = run_fx("ec.keying.keylight", &[("screenColour", green()), ("insideMask", Value::Enum(1)), ("outsideMask", Value::Enum(2))], plate(), 0.0, env);
|
||||
assert!(m.img.get(2, 2)[3] > 0.99, "{:?}", m.img.get(2, 2));
|
||||
assert!(m.img.get(15, 15)[3] < 0.01);
|
||||
// Screen gain above 100 % keys more of the strip.
|
||||
let g = run_fx("ec.keying.keylight", &[("screenColour", green()), ("screenGain", num(150.0))], plate(), 0.0, EffectEnv::default());
|
||||
assert!(g.img.get(27, 5)[3] < out.img.get(27, 5)[3]);
|
||||
assert_eq!(crate::lookup("Keylight (1.2)").map(|s| s.id), Some("ec.keying.keylight"));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,10 @@
|
|||
|
||||
pub mod audio_fx;
|
||||
mod blur2;
|
||||
mod blur3;
|
||||
mod channel;
|
||||
mod channel2;
|
||||
mod channel3d;
|
||||
mod color2;
|
||||
mod color3;
|
||||
mod color_fx;
|
||||
|
|
@ -17,22 +19,27 @@ mod controls;
|
|||
mod distort;
|
||||
mod distort2;
|
||||
mod distort3;
|
||||
pub mod distort4;
|
||||
mod generate;
|
||||
mod generate2;
|
||||
mod generate3;
|
||||
mod keying;
|
||||
mod keying2;
|
||||
pub mod keylight;
|
||||
mod matte;
|
||||
mod misc;
|
||||
pub mod mocha_shape;
|
||||
mod noise;
|
||||
mod noise2;
|
||||
mod obsolete;
|
||||
mod ocio;
|
||||
pub mod paint;
|
||||
mod perspective;
|
||||
mod perspective2;
|
||||
pub mod puppet;
|
||||
mod sim;
|
||||
mod sim2;
|
||||
mod sim3;
|
||||
mod stylize2;
|
||||
mod stylize3;
|
||||
mod textfx;
|
||||
|
|
@ -42,6 +49,7 @@ mod transition2;
|
|||
pub mod util;
|
||||
mod utility;
|
||||
mod utility2;
|
||||
mod vr;
|
||||
pub mod warp_stab;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -51,7 +59,7 @@ pub use color2::Curve;
|
|||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
|
||||
pub use effectcraft_raster::Image;
|
||||
pub use effectcraft_raster::{AuxChannels, Image};
|
||||
|
||||
/// Effect categories in Effects & Presets order.
|
||||
pub const CATEGORIES: &[&str] = &[
|
||||
|
|
@ -232,6 +240,13 @@ pub trait EffectHost: Sync {
|
|||
fn layer_at(&self, _id: u64, _comp_time: f64, _masks_and_effects: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
/// Auxiliary 3D channels (depth, object / material IDs, normals, Cryptomatte, any named
|
||||
/// EXR channel) of the effect's own layer source at the current time, in layer space (see
|
||||
/// [`AuxChannels`]). `None` when the source has none (the 3D Channel effects then pass the
|
||||
/// layer through).
|
||||
fn aux(&self) -> Option<std::sync::Arc<AuxChannels>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra context the renderer may supply (all optional; `Default` is "nothing known").
|
||||
|
|
@ -246,6 +261,10 @@ pub struct EffectEnv<'a> {
|
|||
pub frame_rate: f64,
|
||||
/// Index of the running effect in its layer's stack (for [`EffectHost::self_at`]).
|
||||
pub effect_index: usize,
|
||||
/// The project's working colour space (`None` = unmanaged, treated as sRGB).
|
||||
pub working_space: Option<effectcraft_color::ColorSpace>,
|
||||
/// Working-space pixels are linear light (Linearize Working Space).
|
||||
pub working_linear: bool,
|
||||
}
|
||||
|
||||
/// What an effect gets to render with.
|
||||
|
|
@ -349,6 +368,14 @@ pub fn registry() -> &'static [EffectSpec] {
|
|||
v.extend(noise2::specs());
|
||||
v.extend(color3::specs());
|
||||
v.extend(obsolete::specs());
|
||||
v.extend(ocio::specs());
|
||||
v.extend(vr::specs());
|
||||
v.extend(blur3::specs());
|
||||
v.extend(distort4::specs());
|
||||
v.extend(keylight::specs());
|
||||
v.extend(mocha_shape::specs());
|
||||
v.extend(sim3::specs());
|
||||
v.extend(channel3d::specs());
|
||||
v.extend(time_fx::specs());
|
||||
v.extend(audio_fx::specs());
|
||||
v.extend(paint::specs());
|
||||
|
|
@ -370,7 +397,10 @@ pub fn find(id: &str) -> Option<&'static EffectSpec> {
|
|||
|
||||
/// 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)))
|
||||
find(name_or_id).or_else(|| registry().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()
|
||||
})
|
||||
}
|
||||
|
||||
/// A parameter's default value on a layer of `layer_size` (point defaults are fractions of the
|
||||
|
|
@ -492,6 +522,10 @@ pub const TIME_DEPENDENT: &[&str] = &[
|
|||
"ec.time.ccforcemotionblur",
|
||||
"ec.time.ccwidetime",
|
||||
"ec.time.pixelmotionblur",
|
||||
"ec.vr.digitalglitch",
|
||||
"ec.blur.camerashakedeblur",
|
||||
"ec.distort.rollingshutterrepair",
|
||||
"ec.obsolete.mochashape",
|
||||
// Each frame gets its own stabilizing warp.
|
||||
warp_stab::ID,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
//! Obsolete ▸ mocha shape: renders planar-tracked roto shapes as a matte.
|
||||
//!
|
||||
//! Mocha's own clipboard / export formats are not publicly specified, so the effect reads shape
|
||||
//! data in a small documented JSON format of ours (in the hidden `shapeData` parameter, either the
|
||||
//! JSON itself or a path to a `.json` file), which converters or agents can produce:
|
||||
//!
|
||||
//! ```json
|
||||
//! {"shapes": [{"name": "Shape 1", "closed": true, "inverted": false, "feather": 2.0,
|
||||
//! "points": [[x, y], …],
|
||||
//! "frames": [{"time": 0.0, "points": [[x, y], …]}, {"time": 1.0, "points": […]}]}]}
|
||||
//! ```
|
||||
//!
|
||||
//! Coordinates are layer pixels. `frames` (layer time in seconds, linearly interpolated between
|
||||
//! keys with the same vertex count, held outside) override the static `points`. Shapes combine
|
||||
//! with Blend Mode (add / subtract / intersect / difference) into a coverage matte that is used
|
||||
//! per Render Type: Shape Cutout (layer × matte), Color Composite (shape colour over the layer)
|
||||
//! or Color Shape Cutout (shape colour inside the matte only).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::util::{Plane, gauss_plane, point_in_poly};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MochaShape {
|
||||
pub name: String,
|
||||
pub closed: bool,
|
||||
pub inverted: bool,
|
||||
pub feather: f64,
|
||||
pub points: Vec<[f64; 2]>,
|
||||
/// (layer time, points), sorted by time.
|
||||
pub frames: Vec<(f64, Vec<[f64; 2]>)>,
|
||||
}
|
||||
|
||||
impl MochaShape {
|
||||
/// Outline at layer time `t`.
|
||||
pub fn at(&self, t: f64) -> Vec<[f64; 2]> {
|
||||
if self.frames.is_empty() {
|
||||
return self.points.clone();
|
||||
}
|
||||
let i = self.frames.partition_point(|f| f.0 <= t);
|
||||
if i == 0 {
|
||||
return self.frames[0].1.clone();
|
||||
}
|
||||
if i == self.frames.len() {
|
||||
return self.frames[i - 1].1.clone();
|
||||
}
|
||||
let (a, b) = (&self.frames[i - 1], &self.frames[i]);
|
||||
if a.1.len() != b.1.len() {
|
||||
return a.1.clone();
|
||||
}
|
||||
let u = ((t - a.0) / (b.0 - a.0).max(1e-12)).clamp(0.0, 1.0);
|
||||
a.1.iter().zip(&b.1).map(|(p, q)| [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u]).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn pts(v: &serde_json::Value) -> Vec<[f64; 2]> {
|
||||
v.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|p| {
|
||||
let p = p.as_array()?;
|
||||
Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Parse shape data JSON (see the module docs). `None` when it is not valid.
|
||||
pub fn parse_shapes(text: &str) -> Option<Vec<MochaShape>> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let list = v.get("shapes").and_then(|s| s.as_array()).or_else(|| v.as_array())?;
|
||||
let mut out = vec![];
|
||||
for s in list {
|
||||
let mut frames: Vec<(f64, Vec<[f64; 2]>)> = s
|
||||
.get("frames")
|
||||
.and_then(|f| f.as_array())
|
||||
.map(|a| a.iter().filter_map(|f| Some((f.get("time")?.as_f64()?, pts(f.get("points")?)))).collect())
|
||||
.unwrap_or_default();
|
||||
frames.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
out.push(MochaShape {
|
||||
name: s.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string(),
|
||||
closed: s.get("closed").and_then(|c| c.as_bool()).unwrap_or(true),
|
||||
inverted: s.get("inverted").and_then(|c| c.as_bool()).unwrap_or(false),
|
||||
feather: s.get("feather").and_then(|c| c.as_f64()).unwrap_or(0.0).max(0.0),
|
||||
points: s.get("points").map(pts).unwrap_or_default(),
|
||||
frames,
|
||||
});
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
type ShapeCache = Mutex<HashMap<String, Option<Arc<Vec<MochaShape>>>>>;
|
||||
|
||||
fn load(src: &str) -> Option<Arc<Vec<MochaShape>>> {
|
||||
let s = src.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
static CACHE: OnceLock<ShapeCache> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(v) = cache.lock().ok().and_then(|c| c.get(src).cloned()) {
|
||||
return v;
|
||||
}
|
||||
let parsed =
|
||||
if s.starts_with('{') || s.starts_with('[') { parse_shapes(s) } else { std::fs::read_to_string(s).ok().and_then(|t| parse_shapes(&t)) }.map(Arc::new);
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
if c.len() > 64 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(src.to_string(), parsed.clone());
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
fn mocha_shape(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let Some(shapes) = load(ctx.params.s("shapeData")) else { return b };
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let blend = ctx.params.e("blendMode");
|
||||
let inv = 1.0 / b.scale.max(1e-9);
|
||||
let mut matte: Option<Plane> = None;
|
||||
for s in shapes.iter() {
|
||||
let outline = s.at(ctx.time);
|
||||
if outline.len() < 3 || !s.closed {
|
||||
continue;
|
||||
}
|
||||
let mut cov = Plane::new(w, h);
|
||||
cov.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let lx = (x as f64 + 0.5 - b.offset[0]) * inv;
|
||||
let ly = (y as f64 + 0.5 - b.offset[1]) * inv;
|
||||
*v = if point_in_poly(&outline, lx, ly) != s.inverted { 1.0 } else { 0.0 };
|
||||
}
|
||||
});
|
||||
if s.feather > 0.0 {
|
||||
let f = s.feather * b.scale * 0.5;
|
||||
cov = gauss_plane(&cov, f, f);
|
||||
}
|
||||
matte = Some(match matte {
|
||||
None => cov,
|
||||
Some(m) => m.zip_map(&cov, |a, c| match blend {
|
||||
1 => a * (1.0 - c),
|
||||
2 => a * c,
|
||||
3 => (a - c).abs(),
|
||||
_ => a + c - a * c,
|
||||
}),
|
||||
});
|
||||
}
|
||||
let Some(mut m) = matte else { return b };
|
||||
if ctx.params.b("invert") {
|
||||
m = m.map(|v| 1.0 - v);
|
||||
}
|
||||
let kind = ctx.params.e("renderType");
|
||||
let c = ctx.params.color("shapeColour");
|
||||
let op = (ctx.params.f("opacity") / 100.0) as f32;
|
||||
let edge = ctx.params.b("renderEdgeWidth");
|
||||
b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, &k)| {
|
||||
let k = if edge { (1.0 - (2.0 * k - 1.0).abs()).clamp(0.0, 1.0) } else { k.clamp(0.0, 1.0) };
|
||||
match kind {
|
||||
1 => {
|
||||
let a = k * op;
|
||||
let s = [c[0] * a, c[1] * a, c[2] * a, a];
|
||||
*px = [s[0] + px[0] * (1.0 - a), s[1] + px[1] * (1.0 - a), s[2] + px[2] * (1.0 - a), s[3] + px[3] * (1.0 - a)];
|
||||
}
|
||||
2 => {
|
||||
let a = k * op;
|
||||
*px = [c[0] * a, c[1] * a, c[2] * a, a];
|
||||
}
|
||||
_ => {
|
||||
let a = 1.0 - (1.0 - k) * op;
|
||||
*px = px.map(|v| v * a);
|
||||
}
|
||||
}
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![EffectSpec {
|
||||
id: "ec.obsolete.mochashape",
|
||||
name: "mocha shape",
|
||||
category: "Obsolete",
|
||||
params: vec![
|
||||
p("blendMode", "Blend mode", Value::Enum(0), popup(&["Add", "Subtract", "Intersect", "Difference"])),
|
||||
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("renderEdgeWidth", "Render edge width", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("renderType", "Render type", Value::Enum(0), popup(&["Shape cutout", "Color composite", "Color shape cutout"])),
|
||||
p("shapeColour", "Shape colour", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
// JSON shape data (see the module docs) or a path to a .json file.
|
||||
p("shapeData", "Shape data", Value::Str(String::new()), ParamUi::Hidden),
|
||||
],
|
||||
render: mocha_shape,
|
||||
gpu: false,
|
||||
float: true,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, Image, run_fx};
|
||||
|
||||
const DATA: &str = r#"{"shapes":[{"name":"S","closed":true,"points":[[2,2],[10,2],[10,10],[2,10]],
|
||||
"frames":[{"time":0,"points":[[2,2],[10,2],[10,10],[2,10]]},{"time":1,"points":[[12,2],[20,2],[20,10],[12,10]]}]}]}"#;
|
||||
|
||||
#[test]
|
||||
fn parses_and_interpolates() {
|
||||
let s = parse_shapes(DATA).unwrap();
|
||||
assert_eq!(s.len(), 1);
|
||||
assert_eq!(s[0].at(0.5)[0], [7.0, 2.0]);
|
||||
assert_eq!(s[0].at(5.0)[0], [12.0, 2.0]);
|
||||
assert!(parse_shapes("not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuts_out_the_shape_over_time() {
|
||||
let img = Image::filled(24, 12, [1.0, 0.0, 0.0, 1.0]);
|
||||
let a = run_fx("ec.obsolete.mochashape", &[("shapeData", Value::Str(DATA.into()))], img.clone(), 0.0, EffectEnv::default());
|
||||
assert_eq!(a.img.get(5, 5)[3], 1.0);
|
||||
assert_eq!(a.img.get(15, 5)[3], 0.0);
|
||||
let b = run_fx("ec.obsolete.mochashape", &[("shapeData", Value::Str(DATA.into()))], img.clone(), 1.0, EffectEnv::default());
|
||||
assert_eq!(b.img.get(5, 5)[3], 0.0);
|
||||
assert_eq!(b.img.get(15, 5)[3], 1.0);
|
||||
// Color shape cutout fills with the shape colour; no data = pass-through.
|
||||
let c = run_fx(
|
||||
"ec.obsolete.mochashape",
|
||||
&[("shapeData", Value::Str(DATA.into())), ("renderType", Value::Enum(2)), ("shapeColour", col(0.0, 1.0, 0.0))],
|
||||
img.clone(),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
assert_eq!(c.img.get(5, 5), [0.0, 1.0, 0.0, 1.0]);
|
||||
let none = run_fx("ec.obsolete.mochashape", &[], img.clone(), 0.0, EffectEnv::default());
|
||||
assert_eq!(none.img.data, img.data);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,716 @@
|
|||
//! Simulation: Particle Playground and CC Hair.
|
||||
//!
|
||||
//! **Particle Playground** is a deterministic 2D particle system stepped from layer time 0 at
|
||||
//! [`SPS`] steps per second through [`SimCache`] (seeking anywhere gives the same frame as
|
||||
//! playing up to it). Producers: **Cannon** (a stream from a barrel with angle, radius, rate,
|
||||
//! velocity and random spreads), **Grid** (particles placed on a grid at time 0) and **Layer
|
||||
//! Exploder** (another layer broken into particles at time 0). Forces: **Gravity** (force,
|
||||
//! random spread, direction), **Repel** (particles push each other apart within a radius),
|
||||
//! **Wall** (a mask of the layer the particles bounce off) and the **Persistent Property
|
||||
//! Mapper** (a map layer's red / green / blue drive particle properties at the particle's
|
||||
//! position each step). **Layer Map** draws particles with the colours of another layer
|
||||
//! sampled at each particle's birth position. Map layers are read at the frame being rendered
|
||||
//! (their pixels are part of the cache key). Particles replace the layer's own pixels, as in
|
||||
//! After Effects.
|
||||
//!
|
||||
//! **CC Hair** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a
|
||||
//! quadratic curve of Length that droops with Weight, optionally steered and scaled by a hairfall
|
||||
//! map layer (luminance → length, horizontal/vertical gradient → lean), and shades them with a
|
||||
//! Kajiya–Kay-style strand model (diffuse ∝ sin(tangent, light), specular from the half vector).
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::{Image, Px};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::sim::{Acc, SPS, Shape, Sprite, combine, splat, steps_at};
|
||||
use crate::util::{SimCache, hash1, params_key, point_in_poly, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Simulation", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Particle Playground
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PgParticle {
|
||||
pub p: [f32; 2],
|
||||
pub v: [f32; 2],
|
||||
/// Layer position where the particle was born (Layer Map lookups).
|
||||
pub origin: [f32; 2],
|
||||
pub c: [f32; 4],
|
||||
pub r: f32,
|
||||
pub mass: f32,
|
||||
pub friction: f32,
|
||||
pub force: [f32; 2],
|
||||
pub id: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PgState {
|
||||
pub parts: Vec<PgParticle>,
|
||||
carry: f64,
|
||||
next_id: u32,
|
||||
}
|
||||
|
||||
/// A map layer sampled in layer coordinates.
|
||||
struct MapImg {
|
||||
img: Image,
|
||||
/// Layer point → pixel: (x · k₀ + o₀, y · k₁ + o₁).
|
||||
k: [f64; 2],
|
||||
o: [f64; 2],
|
||||
}
|
||||
|
||||
impl MapImg {
|
||||
fn from(ctx: &EffectCtx, lp: crate::LayerPixels) -> MapImg {
|
||||
// Fit the map layer to this layer (stretch), like AE's layer maps.
|
||||
let (ls, os) = (ctx.layer_size, lp.size);
|
||||
let sx = if ls[0] > 0.0 { os[0] / ls[0] } else { 1.0 };
|
||||
let sy = if ls[1] > 0.0 { os[1] / ls[1] } else { 1.0 };
|
||||
MapImg { img: lp.buf.img, k: [lp.buf.scale * sx, lp.buf.scale * sy], o: lp.buf.offset }
|
||||
}
|
||||
fn at(&self, x: f32, y: f32) -> Px {
|
||||
let (c, a) = unpremul(self.img.sample_bilinear_clamped(x as f64 * self.k[0] + self.o[0], y as f64 * self.k[1] + self.o[1]));
|
||||
[c[0], c[1], c[2], a]
|
||||
}
|
||||
}
|
||||
|
||||
pub const MAP_TARGETS: [&str; 12] =
|
||||
["None", "Red", "Green", "Blue", "Scale", "Opacity", "X Velocity", "Y Velocity", "X Force", "Y Force", "Kinetic Friction", "Mass"];
|
||||
|
||||
struct Pg {
|
||||
// Cannon
|
||||
cannon: bool,
|
||||
pos: [f32; 2],
|
||||
barrel_angle: f32,
|
||||
barrel_radius: f32,
|
||||
rate: f64,
|
||||
dir_spread: f32,
|
||||
vel: f32,
|
||||
vel_spread: f32,
|
||||
color: [f32; 4],
|
||||
radius: f32,
|
||||
// Gravity
|
||||
gravity: [f32; 2],
|
||||
grav_spread: f32,
|
||||
// Repel
|
||||
repel: f32,
|
||||
repel_radius: f32,
|
||||
// Wall
|
||||
wall: Option<Vec<[f64; 2]>>,
|
||||
// Property mapper
|
||||
mapper: Option<MapImg>,
|
||||
map_to: [(u32, f32, f32); 3],
|
||||
seed: u32,
|
||||
bounds: [f32; 4],
|
||||
}
|
||||
|
||||
impl Pg {
|
||||
fn spawn(&self, id: u32) -> PgParticle {
|
||||
let s = self.seed;
|
||||
let h = |k: u32| hash1(id, k, s) * 2.0 - 1.0;
|
||||
// AE angles: 0° = up, clockwise.
|
||||
let a = (self.barrel_angle + h(1) * self.dir_spread * 0.5).to_radians();
|
||||
let d = [a.sin(), -a.cos()];
|
||||
let perp = [-d[1], d[0]];
|
||||
let off = h(2) * self.barrel_radius;
|
||||
let speed = (self.vel + h(3) * self.vel_spread).max(0.0);
|
||||
let p = [self.pos[0] + perp[0] * off, self.pos[1] + perp[1] * off];
|
||||
PgParticle { p, v: [d[0] * speed, d[1] * speed], origin: p, c: self.color, r: self.radius, mass: 1.0, friction: 0.0, force: [0.0; 2], id }
|
||||
}
|
||||
|
||||
fn step(&self, st: &mut PgState) {
|
||||
let dt = (1.0 / SPS) as f32;
|
||||
let s = self.seed;
|
||||
// Property mapper (persistent): drive properties from the map at the particle position.
|
||||
if let Some(m) = &self.mapper {
|
||||
st.parts.par_iter_mut().for_each(|q| {
|
||||
let px = m.at(q.p[0], q.p[1]);
|
||||
for (ch, &(target, lo, hi)) in self.map_to.iter().enumerate() {
|
||||
let v = lo + (hi - lo) * px[ch];
|
||||
match target {
|
||||
1 => q.c[0] = v,
|
||||
2 => q.c[1] = v,
|
||||
3 => q.c[2] = v,
|
||||
4 => q.r = self.radius * v.max(0.0),
|
||||
5 => q.c[3] = v.clamp(0.0, 1.0),
|
||||
6 => q.v[0] = v,
|
||||
7 => q.v[1] = v,
|
||||
8 => q.force[0] = v,
|
||||
9 => q.force[1] = v,
|
||||
10 => q.friction = v.clamp(0.0, 1.0),
|
||||
11 => q.mass = v.max(0.01),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Repel: spatial hash, symmetric pairwise push within the radius.
|
||||
let mut push = vec![[0.0f32; 2]; st.parts.len()];
|
||||
if self.repel != 0.0 && self.repel_radius > 0.0 && st.parts.len() > 1 {
|
||||
let cell = self.repel_radius;
|
||||
let mut grid: std::collections::HashMap<(i32, i32), Vec<usize>> = std::collections::HashMap::new();
|
||||
for (i, q) in st.parts.iter().enumerate() {
|
||||
grid.entry(((q.p[0] / cell).floor() as i32, (q.p[1] / cell).floor() as i32)).or_default().push(i);
|
||||
}
|
||||
let parts = &st.parts;
|
||||
push.par_iter_mut().enumerate().for_each(|(i, f)| {
|
||||
let q = parts[i];
|
||||
let (cx, cy) = ((q.p[0] / cell).floor() as i32, (q.p[1] / cell).floor() as i32);
|
||||
for gy in cy - 1..=cy + 1 {
|
||||
for gx in cx - 1..=cx + 1 {
|
||||
let Some(list) = grid.get(&(gx, gy)) else { continue };
|
||||
for &j in list {
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
let o = parts[j];
|
||||
let (dx, dy) = (q.p[0] - o.p[0], q.p[1] - o.p[1]);
|
||||
let d = (dx * dx + dy * dy).sqrt();
|
||||
if d < self.repel_radius && d > 1e-4 {
|
||||
let k = self.repel * (1.0 - d / self.repel_radius) / d;
|
||||
f[0] += dx * k;
|
||||
f[1] += dy * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let wall = self.wall.as_deref();
|
||||
st.parts.par_iter_mut().zip(push.par_iter()).for_each(|(q, f)| {
|
||||
let g = if self.grav_spread > 0.0 { 1.0 + (hash1(q.id, 7, s) * 2.0 - 1.0) * self.grav_spread } else { 1.0 };
|
||||
let ax = self.gravity[0] * g + (q.force[0] + f[0]) / q.mass;
|
||||
let ay = self.gravity[1] * g + (q.force[1] + f[1]) / q.mass;
|
||||
let damp = 1.0 - q.friction * 0.1;
|
||||
q.v = [(q.v[0] + ax * dt) * damp, (q.v[1] + ay * dt) * damp];
|
||||
let np = [q.p[0] + q.v[0] * dt, q.p[1] + q.v[1] * dt];
|
||||
if let Some(w) = wall {
|
||||
let was = point_in_poly(w, q.p[0] as f64, q.p[1] as f64);
|
||||
let now = point_in_poly(w, np[0] as f64, np[1] as f64);
|
||||
if was != now {
|
||||
// Bounce: reverse the velocity and stay on the same side.
|
||||
q.v = [-q.v[0] * 0.9, -q.v[1] * 0.9];
|
||||
return;
|
||||
}
|
||||
}
|
||||
q.p = np;
|
||||
});
|
||||
let b = self.bounds;
|
||||
st.parts.retain(|q| q.p[0] > b[0] && q.p[0] < b[2] && q.p[1] > b[1] && q.p[1] < b[3]);
|
||||
if self.cannon {
|
||||
st.carry += self.rate / SPS;
|
||||
let n = st.carry.floor() as u32;
|
||||
st.carry -= n as f64;
|
||||
for _ in 0..n {
|
||||
if st.parts.len() >= 60_000 {
|
||||
break;
|
||||
}
|
||||
let id = st.next_id;
|
||||
st.next_id = st.next_id.wrapping_add(1);
|
||||
let mut q = self.spawn(id);
|
||||
// Sub-step birth offset keeps streams smooth.
|
||||
let frac = hash1(id, 9, s) * dt;
|
||||
q.p = [q.p[0] + q.v[0] * frac, q.p[1] + q.v[1] * frac];
|
||||
st.parts.push(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static PG_CACHE: SimCache<PgState> = SimCache::new(6);
|
||||
|
||||
fn image_key(img: &Image) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
img.width.hash(&mut h);
|
||||
img.height.hash(&mut h);
|
||||
for p in img.data.iter().step_by(7) {
|
||||
for c in p {
|
||||
c.to_bits().hash(&mut h);
|
||||
}
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
|
||||
let grav_dir = (pr.f("gravityDirection") as f32).to_radians();
|
||||
let gf = pr.f("gravityForce") as f32;
|
||||
let wall = (pr.f("wallBoundary").round() as usize).checked_sub(1).and_then(|i| ctx.env.masks.get(i)).map(|m| m.points.clone());
|
||||
let mapper = if pr.b("mapperEnabled") { ctx.layer_param("useLayerAsMap", true).map(|lp| MapImg::from(ctx, lp)) } else { None };
|
||||
let pg = Pg {
|
||||
cannon: pr.b("cannonEnabled"),
|
||||
pos: { pr.v2("cannonPosition").map(|v| v as f32) },
|
||||
barrel_angle: pr.f("barrelAngle") as f32,
|
||||
barrel_radius: pr.f("barrelRadius") as f32,
|
||||
rate: pr.f("particlesPerSecond").max(0.0),
|
||||
dir_spread: pr.f("directionRandomSpread") as f32,
|
||||
vel: pr.f("velocity") as f32,
|
||||
vel_spread: pr.f("velocityRandomSpread") as f32,
|
||||
color: pr.color("cannonColor"),
|
||||
radius: pr.f("cannonParticleRadius").max(0.0) as f32,
|
||||
gravity: [grav_dir.sin() * gf, -grav_dir.cos() * gf],
|
||||
grav_spread: (pr.f("gravityForceRandomSpread") / 100.0).max(0.0) as f32,
|
||||
repel: pr.f("repelForce") as f32 * 100.0,
|
||||
repel_radius: pr.f("repelForceRadius").max(0.0) as f32,
|
||||
wall,
|
||||
mapper,
|
||||
map_to: [
|
||||
(pr.e("mapRedTo"), pr.f("redMin") as f32, pr.f("redMax") as f32),
|
||||
(pr.e("mapGreenTo"), pr.f("greenMin") as f32, pr.f("greenMax") as f32),
|
||||
(pr.e("mapBlueTo"), pr.f("blueMin") as f32, pr.f("blueMax") as f32),
|
||||
],
|
||||
seed: (pr.f("randomSeed") as u32).wrapping_mul(0x9e37_79b9) ^ ctx.seed,
|
||||
bounds: [-lw * 2.0, -lh * 2.0, lw * 3.0, lh * 3.0],
|
||||
};
|
||||
// Initial particles: Grid and Layer Exploder.
|
||||
let grid_on = pr.b("gridEnabled");
|
||||
let across = pr.f("particlesAcross").max(0.0).round() as u32;
|
||||
let down = pr.f("particlesDown").max(0.0).round() as u32;
|
||||
let gpos = pr.v2("gridPosition");
|
||||
let (gw, gh) = (pr.f("gridWidth"), pr.f("gridHeight"));
|
||||
let gcol = pr.color("gridColor");
|
||||
let grad = pr.f("gridParticleRadius").max(0.0) as f32;
|
||||
let exploder = if pr.b("exploderEnabled") {
|
||||
let host = ctx.env.host;
|
||||
let lid = pr.get("explodeLayer").and_then(Value::as_layer);
|
||||
match (host, lid) {
|
||||
(Some(h), Some(id)) => h.layer_at(id, ctx.env.comp_time - ctx.time, true).or_else(|| h.layer(id, true)),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ex_r = pr.f("radiusOfNewParticles").max(0.5) as f32;
|
||||
let ex_disp = pr.f("velocityDispersion") as f32;
|
||||
let layer_map = pr.b("layerMapEnabled").then(|| ctx.layer_param("layerMapLayer", true).map(|lp| MapImg::from(ctx, lp))).flatten();
|
||||
|
||||
let mut key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 0x7067);
|
||||
if let Some(m) = &pg.mapper {
|
||||
key ^= image_key(&m.img).rotate_left(7);
|
||||
}
|
||||
if let Some(e) = &exploder {
|
||||
key ^= image_key(&e.buf.img).rotate_left(13);
|
||||
}
|
||||
if let Some(w) = &pg.wall {
|
||||
key ^= w.iter().fold(0u64, |a, p| a.rotate_left(5) ^ p[0].to_bits() ^ p[1].to_bits().rotate_left(17));
|
||||
}
|
||||
let seed = pg.seed;
|
||||
let init = || {
|
||||
let mut st = PgState::default();
|
||||
if grid_on && across > 0 && down > 0 {
|
||||
for j in 0..down {
|
||||
for i in 0..across {
|
||||
let fx = if across > 1 { i as f64 / (across - 1) as f64 - 0.5 } else { 0.0 };
|
||||
let fy = if down > 1 { j as f64 / (down - 1) as f64 - 0.5 } else { 0.0 };
|
||||
let p = [(gpos[0] + fx * gw) as f32, (gpos[1] + fy * gh) as f32];
|
||||
let id = st.next_id;
|
||||
st.next_id += 1;
|
||||
st.parts.push(PgParticle { p, v: [0.0; 2], origin: p, c: gcol, r: grad, mass: 1.0, friction: 0.0, force: [0.0; 2], id });
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(e) = &exploder {
|
||||
let m = MapImg::from(ctx, e.clone());
|
||||
let step = (ex_r * 2.0).max(1.0);
|
||||
let mut y = step * 0.5;
|
||||
while y < lh {
|
||||
let mut x = step * 0.5;
|
||||
while x < lw {
|
||||
let c = m.at(x, y);
|
||||
if c[3] > 0.05 {
|
||||
let id = st.next_id;
|
||||
st.next_id += 1;
|
||||
let h = |k: u32| hash1(id, k, seed) * 2.0 - 1.0;
|
||||
let (dx, dy) = (x - lw * 0.5, y - lh * 0.5);
|
||||
let l = (dx * dx + dy * dy).sqrt().max(1.0);
|
||||
let v = [dx / l * ex_disp + h(1) * ex_disp, dy / l * ex_disp + h(2) * ex_disp];
|
||||
st.parts.push(PgParticle { p: [x, y], v, origin: [x, y], c, r: ex_r, mass: 1.0, friction: 0.0, force: [0.0; 2], id });
|
||||
}
|
||||
x += step;
|
||||
}
|
||||
y += step;
|
||||
}
|
||||
}
|
||||
st
|
||||
};
|
||||
let st = PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st));
|
||||
let s = b.scale as f32;
|
||||
let sprites: Vec<Sprite> = st
|
||||
.parts
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]);
|
||||
let c = match &layer_map {
|
||||
Some(m) => {
|
||||
let mc = m.at(q.origin[0], q.origin[1]);
|
||||
[mc[0], mc[1], mc[2], mc[3] * q.c[3]]
|
||||
}
|
||||
None => q.c,
|
||||
};
|
||||
Sprite::new(x as f32, y as f32, q.r * s, c, Shape::Disc)
|
||||
})
|
||||
.collect();
|
||||
let fx = splat(b.img.width, b.img.height, &sprites, Acc::Over);
|
||||
// Particles replace the layer's own pixels.
|
||||
b.img = combine(&b.img, &fx, 5);
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Hair
|
||||
|
||||
fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let len = pr.f("length").max(0.0) as f32;
|
||||
let density = pr.f("density").max(0.0) as f32 / 100.0;
|
||||
if len <= 0.0 || density <= 0.0 {
|
||||
return b;
|
||||
}
|
||||
let thick = pr.f("thickness").max(0.05) as f32;
|
||||
let weight = pr.f("weight") as f32;
|
||||
let const_mass = pr.b("constantMass");
|
||||
let map = ctx.layer_param("mapLayer", true).map(|lp| MapImg::from(ctx, lp));
|
||||
let map_strength = (pr.f("mapStrength") / 100.0) as f32;
|
||||
let map_soft = pr.f("mapSoftness").max(0.0) as f32;
|
||||
let noise = (pr.f("addNoise") / 100.0) as f32;
|
||||
let hc = pr.color("hairColor");
|
||||
let bright = (pr.f("brightness") / 100.0) as f32;
|
||||
let opacity = (pr.f("opacity") / 100.0) as f32;
|
||||
let inherit = (pr.f("colorInheritance") / 100.0) as f32;
|
||||
let light_i = (pr.f("lightIntensity") / 100.0) as f32;
|
||||
let ld = (pr.f("lightDirection") as f32).to_radians();
|
||||
let light = [ld.sin(), -ld.cos()];
|
||||
let (amb, dif, spe) = ((pr.f("ambient") / 100.0) as f32, (pr.f("diffuse") / 100.0) as f32, (pr.f("specular") / 100.0) as f32);
|
||||
let rough = (pr.f("roughness") / 100.0).clamp(0.01, 1.0) as f32;
|
||||
let seed = ctx.seed ^ (pr.f("randomSeed") as u32).wrapping_mul(0x85eb_ca6b);
|
||||
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
|
||||
// Roots: a jittered grid over the layer, density per 1 000 px².
|
||||
let count = ((lw * lh / 1000.0) * density * 10.0).min(200_000.0) as u32;
|
||||
let src = b.img.clone();
|
||||
let segs = 6u32;
|
||||
let strands: Vec<Vec<Sprite>> = (0..count)
|
||||
.into_par_iter()
|
||||
.filter_map(|i| {
|
||||
let rx = hash1(i, 1, seed) * lw;
|
||||
let ry = hash1(i, 2, seed) * lh;
|
||||
let (bx, by) = b.to_px([rx as f64, ry as f64]);
|
||||
let root = src.sample_bilinear(bx, by);
|
||||
if root[3] < 0.5 {
|
||||
return None;
|
||||
}
|
||||
let (rc, _) = unpremul(root);
|
||||
let mut l = len * (0.75 + 0.5 * hash1(i, 3, seed));
|
||||
// Lean: random, plus the map's local gradient (hairfall map).
|
||||
let mut lean = [(hash1(i, 4, seed) * 2.0 - 1.0) * 0.6, -1.0 + hash1(i, 5, seed) * 0.4];
|
||||
if let Some(m) = &map {
|
||||
let lum = |x: f32, y: f32| {
|
||||
let c = m.at(x, y);
|
||||
(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) * c[3]
|
||||
};
|
||||
let d = map_soft.max(1.0);
|
||||
let (gx, gy) = (lum(rx + d, ry) - lum(rx - d, ry), lum(rx, ry + d) - lum(rx, ry - d));
|
||||
l *= 1.0 - map_strength + map_strength * lum(rx, ry);
|
||||
lean = [lean[0] + gx * map_strength * 4.0, lean[1] + gy * map_strength * 4.0];
|
||||
if noise > 0.0 {
|
||||
lean[0] += (hash1(i, 6, seed) * 2.0 - 1.0) * noise;
|
||||
}
|
||||
}
|
||||
let ll = (lean[0] * lean[0] + lean[1] * lean[1]).sqrt().max(1e-4);
|
||||
let dir = [lean[0] / ll, lean[1] / ll];
|
||||
// Droop: heavier (or thinner with Constant Mass off) hair bends more.
|
||||
let droop = weight * if const_mass { 1.0 } else { 1.0 / thick.max(0.2) } * l;
|
||||
let base = [rc[0] + (hc[0] - rc[0]) * (1.0 - inherit), rc[1] + (hc[1] - rc[1]) * (1.0 - inherit), rc[2] + (hc[2] - rc[2]) * (1.0 - inherit)];
|
||||
let pt = |s: f32| [rx + dir[0] * l * s, ry + dir[1] * l * s + droop * s * s];
|
||||
let mut out = Vec::with_capacity(segs as usize);
|
||||
for k in 0..segs {
|
||||
let (s0, s1) = (k as f32 / segs as f32, (k + 1) as f32 / segs as f32);
|
||||
let (a, c) = (pt(s0), pt(s1));
|
||||
let t = {
|
||||
let (tx, ty) = (c[0] - a[0], c[1] - a[1]);
|
||||
let tl = (tx * tx + ty * ty).sqrt().max(1e-6);
|
||||
[tx / tl, ty / tl]
|
||||
};
|
||||
// Strand shading (Kajiya–Kay): diffuse ∝ sin(T, L), specular from T·L.
|
||||
let tl = t[0] * light[0] + t[1] * light[1];
|
||||
let diff = (1.0 - tl * tl).max(0.0).sqrt();
|
||||
let spec = (1.0 - tl.abs()).powf(1.0 / rough) * spe;
|
||||
let shade = (amb + dif * diff * light_i) * bright;
|
||||
let tip = 1.0 - 0.6 * s1;
|
||||
let c3 = [base[0] * shade + spec * light_i, base[1] * shade + spec * light_i, base[2] * shade + spec * light_i];
|
||||
let (pa, pc) = (b.to_px([a[0] as f64, a[1] as f64]), b.to_px([c[0] as f64, c[1] as f64]));
|
||||
out.push(Sprite {
|
||||
x: pa.0 as f32,
|
||||
y: pa.1 as f32,
|
||||
r: thick * 0.5 * b.scale as f32 * tip,
|
||||
c: [c3[0], c3[1], c3[2], opacity],
|
||||
shape: Shape::Line { dx: (pc.0 - pa.0) as f32, dy: (pc.1 - pa.1) as f32 },
|
||||
rot: 0.0,
|
||||
});
|
||||
}
|
||||
Some(out)
|
||||
})
|
||||
.collect();
|
||||
let sprites: Vec<Sprite> = strands.into_iter().flatten().collect();
|
||||
let fx = splat(b.img.width, b.img.height, &sprites, Acc::Over);
|
||||
b.img = combine(&b.img, &fx, 0);
|
||||
b
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
let pt = |x: f64, y: f64| Value::Vec2([x, y]);
|
||||
let px = |d: f64, max: f64| (num(d), slider(0.0, max * 10.0, 0.0, max, 1));
|
||||
let sp = |id: &'static str, name: &'static str, v: (Value, ParamUi)| p(id, name, v.0, v.1);
|
||||
let mut pg = vec![
|
||||
// Cannon
|
||||
p("cannonEnabled", "Cannon", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("cannonPosition", "Position", pt(0.5, 0.9), ParamUi::Point),
|
||||
p("barrelAngle", "Barrel Angle", num(0.0), ParamUi::Angle),
|
||||
sp("barrelRadius", "Barrel Radius", px(0.0, 100.0)),
|
||||
sp("particlesPerSecond", "Particles Per Second", px(60.0, 500.0)),
|
||||
sp("directionRandomSpread", "Direction Random Spread", px(20.0, 360.0)),
|
||||
sp("velocity", "Velocity", px(130.0, 1000.0)),
|
||||
sp("velocityRandomSpread", "Velocity Random Spread", px(20.0, 500.0)),
|
||||
p("cannonColor", "Color", col(1.0, 0.0, 0.0), ParamUi::Color),
|
||||
sp("cannonParticleRadius", "Particle Radius", px(2.0, 50.0)),
|
||||
// Grid
|
||||
p("gridEnabled", "Grid", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("gridPosition", "Grid Position", pt(0.5, 0.5), ParamUi::Point),
|
||||
sp("gridWidth", "Width", px(100.0, 2000.0)),
|
||||
sp("gridHeight", "Height", px(100.0, 2000.0)),
|
||||
sp("particlesAcross", "Particles Across", px(0.0, 100.0)),
|
||||
sp("particlesDown", "Particles Down", px(0.0, 100.0)),
|
||||
p("gridColor", "Grid Color", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
sp("gridParticleRadius", "Grid Particle Radius", px(2.0, 50.0)),
|
||||
// Layer Exploder
|
||||
p("exploderEnabled", "Layer Exploder", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("explodeLayer", "Explode Layer", Value::Layer(None), ParamUi::Layer),
|
||||
sp("radiusOfNewParticles", "Radius of New Particles", px(2.0, 50.0)),
|
||||
sp("velocityDispersion", "Velocity Dispersion", px(20.0, 500.0)),
|
||||
// Layer Map
|
||||
p("layerMapEnabled", "Layer Map", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("layerMapLayer", "Use Layer", Value::Layer(None), ParamUi::Layer),
|
||||
// Gravity
|
||||
sp("gravityForce", "Force", px(108.0, 1000.0)),
|
||||
sp("gravityForceRandomSpread", "Force Random Spread", px(0.0, 100.0)),
|
||||
p("gravityDirection", "Direction", num(180.0), ParamUi::Angle),
|
||||
// Repel
|
||||
p("repelForce", "Repel Force", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 2)),
|
||||
sp("repelForceRadius", "Force Radius", px(0.0, 100.0)),
|
||||
// Wall
|
||||
p("wallBoundary", "Boundary", num(0.0), ParamUi::Mask),
|
||||
// Persistent Property Mapper
|
||||
p("mapperEnabled", "Persistent Property Mapper", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("useLayerAsMap", "Use Layer As Map", Value::Layer(None), ParamUi::Layer),
|
||||
];
|
||||
for (to, mn, mx, n_to, n_min, n_max) in [
|
||||
("mapRedTo", "redMin", "redMax", "Map Red To", "Red Min", "Red Max"),
|
||||
("mapGreenTo", "greenMin", "greenMax", "Map Green To", "Green Min", "Green Max"),
|
||||
("mapBlueTo", "blueMin", "blueMax", "Map Blue To", "Blue Min", "Blue Max"),
|
||||
] {
|
||||
pg.push(p(to, n_to, Value::Enum(0), popup(&MAP_TARGETS)));
|
||||
pg.push(p(mn, n_min, num(0.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2)));
|
||||
pg.push(p(mx, n_max, num(1.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2)));
|
||||
}
|
||||
pg.push(p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)));
|
||||
let pc = |d: f64| (num(d), slider(0.0, 100.0, 0.0, 100.0, 1));
|
||||
vec![
|
||||
spec("ec.sim.particleplayground", "Particle Playground", pg, particle_playground),
|
||||
spec(
|
||||
"ec.sim.cchair",
|
||||
"CC Hair",
|
||||
vec![
|
||||
sp("length", "Length", px(30.0, 200.0)),
|
||||
p("thickness", "Thickness", num(1.0), slider(0.05, 20.0, 0.1, 5.0, 2)),
|
||||
p("weight", "Weight", num(0.2), slider(-10.0, 10.0, -2.0, 2.0, 2)),
|
||||
p("constantMass", "Constant Mass", Value::Bool(false), ParamUi::Checkbox),
|
||||
sp("density", "Density", px(100.0, 1000.0)),
|
||||
// Hairfall Map
|
||||
sp("mapStrength", "Map Strength", pc(0.0)),
|
||||
p("mapLayer", "Map Layer", Value::Layer(None), ParamUi::Layer),
|
||||
sp("mapSoftness", "Map Softness", px(0.0, 100.0)),
|
||||
sp("addNoise", "Add Noise", pc(0.0)),
|
||||
// Hair Color
|
||||
p("hairColor", "Color", col(0.35, 0.25, 0.15), ParamUi::Color),
|
||||
sp("brightness", "Brightness", px(100.0, 400.0)),
|
||||
sp("opacity", "Opacity", pc(100.0)),
|
||||
sp("colorInheritance", "Color Inheritance", pc(0.0)),
|
||||
// Light
|
||||
sp("lightIntensity", "Light Intensity", px(100.0, 400.0)),
|
||||
p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle),
|
||||
// Shading
|
||||
sp("ambient", "Ambient", pc(40.0)),
|
||||
sp("diffuse", "Diffuse", pc(60.0)),
|
||||
sp("specular", "Specular", pc(30.0)),
|
||||
sp("roughness", "Roughness", pc(20.0)),
|
||||
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)),
|
||||
],
|
||||
cc_hair,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, MaskShape, run_fx};
|
||||
|
||||
fn run(vals: &[(&str, Value)], t: f64) -> Image {
|
||||
run_fx("ec.sim.particleplayground", vals, Image::filled(80, 60, [0.2, 0.2, 0.2, 1.0]), t, EffectEnv::default()).img
|
||||
}
|
||||
|
||||
fn cannon(t: f64) -> Image {
|
||||
run(&[("cannonPosition", Value::Vec2([40.0, 50.0])), ("velocity", num(60.0)), ("gravityForce", num(30.0))], t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannon_stream_is_seek_consistent() {
|
||||
let a = cannon(1.0);
|
||||
// Particles replace the layer: some coverage, mostly transparent, red.
|
||||
let cov: f32 = a.data.iter().map(|p| p[3]).sum();
|
||||
assert!(cov > 5.0, "{cov}");
|
||||
assert!(a.data.iter().any(|p| p[3] > 0.5 && p[0] > 0.5 && p[1] < 0.1));
|
||||
// Seek back and forth: the same frame every time.
|
||||
let _ = cannon(2.0);
|
||||
let b = cannon(1.0);
|
||||
assert_eq!(a.data, b.data);
|
||||
let c = cannon(0.5);
|
||||
assert_ne!(a.data, c.data);
|
||||
// Nothing has been fired at time 0.
|
||||
let z = cannon(0.0);
|
||||
assert!(z.data.iter().all(|p| p[3] == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_falls_with_gravity_and_bounces_off_a_wall() {
|
||||
let grid = |t: f64, env: EffectEnv| {
|
||||
run_fx(
|
||||
"ec.sim.particleplayground",
|
||||
&[
|
||||
("cannonEnabled", Value::Bool(false)),
|
||||
("gridPosition", Value::Vec2([40.0, 10.0])),
|
||||
("gridWidth", num(40.0)),
|
||||
("gridHeight", num(0.0)),
|
||||
("particlesAcross", num(5.0)),
|
||||
("particlesDown", num(1.0)),
|
||||
("gravityForce", num(100.0)),
|
||||
("wallBoundary", num(1.0)),
|
||||
],
|
||||
Image::new(80, 60),
|
||||
t,
|
||||
env,
|
||||
)
|
||||
.img
|
||||
};
|
||||
// Alpha-weighted mean height of the particles.
|
||||
let row_of = |img: &Image| {
|
||||
let (mut s, mut n) = (0.0f64, 0.0f64);
|
||||
for y in 0..60 {
|
||||
for x in 0..80 {
|
||||
let a = img.get(x, y)[3] as f64;
|
||||
s += (y as f64 + 0.5) * a;
|
||||
n += a;
|
||||
}
|
||||
}
|
||||
(s / n.max(1e-9)).round() as i64
|
||||
};
|
||||
let t0 = grid(0.0, EffectEnv::default());
|
||||
let t1 = grid(0.5, EffectEnv::default());
|
||||
assert_eq!(row_of(&t0), 10);
|
||||
// ½ g t² = 12.5 px.
|
||||
assert!((row_of(&t1) - 22).abs() <= 2, "{}", row_of(&t1));
|
||||
// A wall (mask 1) whose floor is at y = 15 keeps them above it.
|
||||
let masks = [MaskShape { name: "w".into(), points: vec![[0.0, 0.0], [80.0, 0.0], [80.0, 15.0], [0.0, 15.0]], closed: true, inverted: false }];
|
||||
let env = EffectEnv { masks: &masks, ..Default::default() };
|
||||
let w = grid(1.5, env);
|
||||
assert!(row_of(&w) <= 16, "{}", row_of(&w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repel_spreads_particles() {
|
||||
let spread = |force: f64| {
|
||||
let img = run(
|
||||
&[
|
||||
("cannonEnabled", Value::Bool(false)),
|
||||
("gridPosition", Value::Vec2([40.0, 30.0])),
|
||||
("gridWidth", num(6.0)),
|
||||
("gridHeight", num(6.0)),
|
||||
("particlesAcross", num(3.0)),
|
||||
("particlesDown", num(3.0)),
|
||||
("gravityForce", num(0.0)),
|
||||
("repelForce", num(force)),
|
||||
("repelForceRadius", num(20.0)),
|
||||
],
|
||||
0.5,
|
||||
);
|
||||
let (mut x0, mut x1) = (80, 0);
|
||||
for y in 0..60 {
|
||||
for x in 0..80 {
|
||||
if img.get(x, y)[3] > 0.3 {
|
||||
x0 = x0.min(x);
|
||||
x1 = x1.max(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
x1 - x0
|
||||
};
|
||||
assert!(spread(0.2) > spread(0.0) + 4, "{} vs {}", spread(0.2), spread(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_mapper_and_layer_map_read_layers() {
|
||||
// Without a host, the layer-based features are inert (deterministic output).
|
||||
let a = run(&[("mapRedTo", Value::Enum(6)), ("redMin", num(-50.0)), ("redMax", num(50.0))], 0.7);
|
||||
let b = run(&[("mapRedTo", Value::Enum(6)), ("redMin", num(-50.0)), ("redMax", num(50.0))], 0.7);
|
||||
assert_eq!(a.data, b.data);
|
||||
let st = Pg {
|
||||
cannon: false,
|
||||
pos: [0.0; 2],
|
||||
barrel_angle: 0.0,
|
||||
barrel_radius: 0.0,
|
||||
rate: 0.0,
|
||||
dir_spread: 0.0,
|
||||
vel: 0.0,
|
||||
vel_spread: 0.0,
|
||||
color: [1.0; 4],
|
||||
radius: 2.0,
|
||||
gravity: [0.0; 2],
|
||||
grav_spread: 0.0,
|
||||
repel: 0.0,
|
||||
repel_radius: 0.0,
|
||||
wall: None,
|
||||
mapper: Some(MapImg { img: Image::filled(4, 4, [1.0, 0.0, 0.5, 1.0]), k: [1.0; 2], o: [0.0; 2] }),
|
||||
map_to: [(6, 0.0, 120.0), (0, 0.0, 1.0), (4, 0.0, 2.0)],
|
||||
seed: 1,
|
||||
bounds: [-1e6, -1e6, 1e6, 1e6],
|
||||
};
|
||||
let mut s = PgState::default();
|
||||
s.parts.push(PgParticle { p: [1.0, 1.0], v: [0.0; 2], origin: [1.0, 1.0], c: [1.0; 4], r: 2.0, mass: 1.0, friction: 0.0, force: [0.0; 2], id: 0 });
|
||||
st.step(&mut s);
|
||||
// Red 1 → X velocity 120 px/s; blue 0.5 → scale 1 (radius 2).
|
||||
assert!((s.parts[0].v[0] - 120.0).abs() < 1e-3);
|
||||
assert!((s.parts[0].r - 2.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_hair_grows_from_opaque_pixels() {
|
||||
let mut img = Image::new(60, 60);
|
||||
for y in 20..40 {
|
||||
for x in 20..40 {
|
||||
img.set(x, y, [0.8, 0.6, 0.4, 1.0]);
|
||||
}
|
||||
}
|
||||
let out = run_fx("ec.sim.cchair", &[("length", num(12.0)), ("density", num(300.0))], img.clone(), 0.0, EffectEnv::default());
|
||||
// Hair reaches beyond the square (above it: hair leans up, then droops).
|
||||
let outside: f32 = (0..60).flat_map(|x| (0..20).map(move |y| (x, y))).map(|(x, y)| out.img.get(x, y)[3]).sum();
|
||||
assert!(outside > 1.0, "{outside}");
|
||||
// Nothing grows from transparent areas far away.
|
||||
assert_eq!(out.img.get(2, 58)[3], 0.0);
|
||||
let again = run_fx("ec.sim.cchair", &[("length", num(12.0)), ("density", num(300.0))], img.clone(), 3.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
let none = run_fx("ec.sim.cchair", &[("length", num(0.0))], img.clone(), 0.0, EffectEnv::default());
|
||||
assert_eq!(none.img.data, img.data);
|
||||
}
|
||||
}
|
||||
|
|
@ -153,7 +153,7 @@ fn cineon(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
// ---- Colour LUTs (.cube) ----
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Lut {
|
||||
pub struct Lut {
|
||||
pub n1: usize,
|
||||
pub d1: Vec<[f32; 3]>,
|
||||
pub n3: usize,
|
||||
|
|
@ -163,7 +163,7 @@ pub(crate) struct Lut {
|
|||
}
|
||||
|
||||
/// Parse `.cube` text (TITLE, LUT_1D_SIZE, LUT_3D_SIZE, DOMAIN_MIN/MAX, data red-fastest).
|
||||
pub(crate) fn parse_cube(text: &str) -> Option<Lut> {
|
||||
pub fn parse_cube(text: &str) -> Option<Lut> {
|
||||
let mut lut = Lut { n1: 0, d1: vec![], n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] };
|
||||
let mut rows: Vec<[f32; 3]> = Vec::new();
|
||||
let three = |it: &mut std::str::SplitWhitespace| -> Option<[f32; 3]> {
|
||||
|
|
@ -238,6 +238,57 @@ impl Lut {
|
|||
}
|
||||
c
|
||||
}
|
||||
/// Apply with a choice of 3D interpolation: 0 nearest, 1 trilinear, otherwise tetrahedral.
|
||||
pub fn apply_interp(&self, c: [f32; 3], interp: u32) -> [f32; 3] {
|
||||
let mut c = c;
|
||||
if self.n1 > 1 {
|
||||
let t = self.norm(c);
|
||||
c = [0, 1, 2].map(|i| {
|
||||
let x = t[i] * (self.n1 - 1) as f32;
|
||||
if interp == 0 {
|
||||
return self.d1[(x.round() as usize).min(self.n1 - 1)][i];
|
||||
}
|
||||
let i0 = (x.floor() as usize).min(self.n1 - 2);
|
||||
let f = x - i0 as f32;
|
||||
self.d1[i0][i] + (self.d1[i0 + 1][i] - self.d1[i0][i]) * f
|
||||
});
|
||||
}
|
||||
if self.n3 > 1 {
|
||||
let t = self.norm(c);
|
||||
c = match interp {
|
||||
0 => self.nearest(t),
|
||||
1 => self.trilinear(t),
|
||||
_ => self.tetra(t),
|
||||
};
|
||||
}
|
||||
c
|
||||
}
|
||||
fn nearest(&self, t: [f32; 3]) -> [f32; 3] {
|
||||
let n = self.n3;
|
||||
let s = (n - 1) as f32;
|
||||
let i = |x: f32| ((x * s).round() as usize).min(n - 1);
|
||||
self.d3[i(t[0]) + i(t[1]) * n + i(t[2]) * n * n]
|
||||
}
|
||||
/// Trilinear interpolation on the lattice (red fastest).
|
||||
pub fn trilinear(&self, t: [f32; 3]) -> [f32; 3] {
|
||||
let n = self.n3;
|
||||
let s = (n - 1) as f32;
|
||||
let idx = |x: f32| {
|
||||
let v = x * s;
|
||||
let i = (v.floor() as usize).min(n - 2);
|
||||
(i, v - i as f32)
|
||||
};
|
||||
let (r, fr) = idx(t[0]);
|
||||
let (g, fg) = idx(t[1]);
|
||||
let (b, fb) = idx(t[2]);
|
||||
let at = |dr: usize, dg: usize, db: usize| self.d3[(r + dr) + (g + dg) * n + (b + db) * n * n];
|
||||
let l = |a: [f32; 3], b: [f32; 3], f: f32| [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
|
||||
let c00 = l(at(0, 0, 0), at(1, 0, 0), fr);
|
||||
let c10 = l(at(0, 1, 0), at(1, 1, 0), fr);
|
||||
let c01 = l(at(0, 0, 1), at(1, 0, 1), fr);
|
||||
let c11 = l(at(0, 1, 1), at(1, 1, 1), fr);
|
||||
l(l(c00, c10, fg), l(c01, c11, fg), fb)
|
||||
}
|
||||
fn tetra(&self, t: [f32; 3]) -> [f32; 3] {
|
||||
let n = self.n3;
|
||||
let s = (n - 1) as f32;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -358,9 +358,30 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
// ---------------------------------------------------------------- scripts
|
||||
|
||||
/// Run a command script: a JSON array or JSON lines of `{"command": id, "params": {…}}` (also
|
||||
/// accepts the control channel's `{"method": "engine.execute", "params": {"id", "params"}}`).
|
||||
/// File ▸ Scripts ▸ Run Script File…: `.jsx` / `.js` files run as JavaScript against the After
|
||||
/// Effects-style object model ([`Session::script`]); anything else is a command script: a JSON
|
||||
/// 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> {
|
||||
if let Some(path) = str_p(p, "path")
|
||||
&& p.get("steps").is_none()
|
||||
&& is_js_script(path)
|
||||
{
|
||||
if path.to_ascii_lowercase().ends_with(".jsxbin") {
|
||||
return Err(bad("file.runScript", "binary .jsxbin scripts are not supported: run the .jsx source"));
|
||||
}
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
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);
|
||||
}
|
||||
let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
|
||||
(Some(Value::Array(a)), _) => a.clone(),
|
||||
(_, Some(path)) => {
|
||||
|
|
@ -388,7 +409,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
_ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))),
|
||||
};
|
||||
if id == "file.runScript" {
|
||||
if id == "file.runScript" || id == "script.run" {
|
||||
return Err(bad("file.runScript", "scripts can't run scripts"));
|
||||
}
|
||||
let r = s.execute(&id, params).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
|
||||
|
|
@ -397,6 +418,28 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"steps": results.len(), "results": results}))
|
||||
}
|
||||
|
||||
/// `.jsx` / `.js` (and `.jsxbin`, rejected) are JavaScript; `.json` / `.jsonl` / other files are
|
||||
/// command scripts.
|
||||
fn is_js_script(path: &str) -> bool {
|
||||
let l = path.to_ascii_lowercase();
|
||||
l.ends_with(".jsx") || l.ends_with(".js") || l.ends_with(".jsxbin")
|
||||
}
|
||||
|
||||
/// 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()))?;
|
||||
Ok(runner(s, &crate::ScriptRequest { code, name, console }))
|
||||
}
|
||||
|
||||
/// `script.run`: run JavaScript (the After Effects scripting object model: `app.project`, comps,
|
||||
/// layers, properties…) and report `{ok, result, output, error}`; script errors are reported in
|
||||
/// `error` (with line and column), not as a command failure.
|
||||
fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let code = str_p(p, "code").ok_or_else(|| bad("script.run", "missing `code` (JavaScript)"))?.to_string();
|
||||
let name = str_p(p, "name").unwrap_or("script").to_string();
|
||||
run_js(s, &code, &name, super::b_p(p, "console").unwrap_or(false))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- footage
|
||||
|
||||
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -638,10 +681,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Run Script File...",
|
||||
["File", "Scripts"],
|
||||
None,
|
||||
"{path (.jsonl/.json command script) | steps: [{command, params}]}",
|
||||
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | steps: [{command, params}]}",
|
||||
always,
|
||||
run_script
|
||||
),
|
||||
cmd!(
|
||||
"script.run",
|
||||
"Run Script",
|
||||
[],
|
||||
None,
|
||||
"{code (JavaScript: app, app.project, CompItem, Layer, Property… like After Effects scripting), name?, console? (Script Console context)} → {ok, result, output, error: {message, line, column} | null}",
|
||||
always,
|
||||
script_run
|
||||
),
|
||||
cmd!(
|
||||
"file.interpretFootage",
|
||||
"Main...",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
//! Liquify brush strokes (`liquify.stroke`, `liquify.clear`).
|
||||
//!
|
||||
//! A stroke is appended to the layer's Liquify effect (added when the layer has none) as one
|
||||
//! line of its hidden Distortion Mesh data (see `effectcraft_effects::distort4`). Brush size,
|
||||
//! pressure, turbulent jitter and clone offset default to the effect's own tool options. When
|
||||
//! the Distortion Mesh is animated, the stroke goes into a keyframe at the current time (copying
|
||||
//! the mesh in effect there), as After Effects records mesh keyframes.
|
||||
|
||||
use effectcraft_effects::distort4::{LIQUIFY_TOOLS, LiquifyStroke, parse_strokes};
|
||||
use effectcraft_keyframe::{Keyframe, Value as KV};
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{GroupKind, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, f_p, has_comp, layer_mut, layer_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
const ID: &str = "ec.distort.liquify";
|
||||
|
||||
fn is_liquify(g: &PropGroup) -> bool {
|
||||
matches!(&g.kind, GroupKind::Effect { effect } if effect == ID)
|
||||
}
|
||||
|
||||
fn liquify_effect(fx: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2], want: Option<Uid>) -> Result<Uid> {
|
||||
if let Some(u) = want {
|
||||
return fx.groups().find(|g| g.uid == u && is_liquify(g)).map(|g| g.uid).ok_or_else(|| bad("liquify.stroke", "no such Liquify effect"));
|
||||
}
|
||||
if let Some(g) = fx.groups().filter(|g| is_liquify(g)).last() {
|
||||
return Ok(g.uid);
|
||||
}
|
||||
let spec = effectcraft_effects::find(ID).ok_or_else(|| EngineError::Other("Liquify effect missing".into()))?;
|
||||
let g = effectcraft_effects::instantiate(spec, ids, "Liquify", layer_size);
|
||||
let uid = g.uid;
|
||||
fx.children.push(g.into());
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
fn num_of(g: &PropGroup, id: &str, default: f64) -> f64 {
|
||||
g.get(id).map(|p| p.value.as_f64()).unwrap_or(default)
|
||||
}
|
||||
|
||||
fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "liquify.stroke";
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let tool_name = str_p(p, "tool").unwrap_or("warp");
|
||||
let tool = LIQUIFY_TOOLS
|
||||
.iter()
|
||||
.position(|t| t.eq_ignore_ascii_case(tool_name))
|
||||
.ok_or_else(|| bad(cmd, format!("unknown tool `{tool_name}` ({})", LIQUIFY_TOOLS.join(" | "))))?;
|
||||
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y], …] (layer space)"))?;
|
||||
let mut points = vec![];
|
||||
for q in raw {
|
||||
let a = q.as_array().ok_or_else(|| bad(cmd, "each point is [x, y]"))?;
|
||||
let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)) else {
|
||||
return Err(bad(cmd, "each point is [x, y]"));
|
||||
};
|
||||
points.push([x, y]);
|
||||
}
|
||||
if points.is_empty() {
|
||||
return Err(bad(cmd, "`points` is empty"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = comp.frame_rate.snap_nearest(super::time_p(s, p, Some(comp)));
|
||||
let layer = comp.layer(lid).ok_or_else(|| bad(cmd, "no layer"))?;
|
||||
if layer.effects().is_none() || !layer.source.is_av() {
|
||||
return Err(bad(cmd, "Liquify works on footage, solid, text, shape and precomp layers"));
|
||||
}
|
||||
let lt = layer.layer_time(t);
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, layer);
|
||||
let size = if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] };
|
||||
let want = p.get("effect").and_then(Value::as_u64);
|
||||
let (fx_uid, count) = s.edit("Liquify Stroke", None, |proj, _st| {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "this layer has no effects"))?;
|
||||
let mut ids = Ids(&mut next);
|
||||
let fx_uid = liquify_effect(fx, &mut ids, size, want)?;
|
||||
proj.next_id = next;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let g = l.props.sub_mut("effects").and_then(|fx| fx.find_group_mut(fx_uid)).ok_or_else(|| bad(cmd, "Liquify effect gone"))?;
|
||||
let co = g.get("cloneOffset").map(|p| p.value.as_vec2()).unwrap_or([0.0; 2]);
|
||||
let st = LiquifyStroke {
|
||||
tool,
|
||||
size: f_p(p, "size").unwrap_or_else(|| num_of(g, "brushSize", 64.0)).max(1.0),
|
||||
pressure: f_p(p, "pressure").unwrap_or_else(|| num_of(g, "brushPressure", 50.0)).clamp(1.0, 100.0),
|
||||
jitter: f_p(p, "jitter").unwrap_or_else(|| num_of(g, "turbulentJitter", 70.0)).clamp(1.0, 100.0),
|
||||
clone_offset: match p.get("cloneOffset").and_then(Value::as_array) {
|
||||
Some(a) => [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)],
|
||||
None => co,
|
||||
},
|
||||
points: points.clone(),
|
||||
};
|
||||
let prop = g.get_mut("distortionMesh").ok_or_else(|| bad(cmd, "Liquify effect has no Distortion Mesh"))?;
|
||||
let append = |cur: &str| if cur.trim().is_empty() { st.to_line() } else { format!("{}\n{}", cur.trim_end(), st.to_line()) };
|
||||
let text = if prop.keys.is_empty() {
|
||||
let cur = match &prop.value {
|
||||
KV::Str(x) => x.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let v = append(&cur);
|
||||
prop.value = KV::Str(v.clone());
|
||||
v
|
||||
} else {
|
||||
// Hold keyframes: the mesh in effect at `lt` is the last key at or before it.
|
||||
let cur = prop.keys.iter().rfind(|k| k.time <= lt).or(prop.keys.first()).map(|k| k.value.clone());
|
||||
let cur = match cur {
|
||||
Some(KV::Str(x)) => x,
|
||||
_ => String::new(),
|
||||
};
|
||||
let v = append(&cur);
|
||||
match prop.keys.iter_mut().find(|k| k.time == lt) {
|
||||
Some(k) => k.value = KV::Str(v.clone()),
|
||||
None => {
|
||||
prop.keys.push(Keyframe::new(lt, KV::Str(v.clone())));
|
||||
prop.keys.sort_by_key(|k| k.time);
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
Ok((fx_uid, parse_strokes(&text).len()))
|
||||
})?;
|
||||
Ok(json!({"effect": fx_uid, "strokes": count}))
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "liquify.clear";
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let want = p.get("effect").and_then(Value::as_u64);
|
||||
s.edit("Clear Liquify Mesh", None, |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "no effects"))?;
|
||||
let uid =
|
||||
fx.groups().filter(|g| is_liquify(g) && want.is_none_or(|u| u == g.uid)).last().map(|g| g.uid).ok_or_else(|| bad(cmd, "no Liquify effect"))?;
|
||||
let g = fx.find_group_mut(uid).ok_or_else(|| bad(cmd, "no Liquify effect"))?;
|
||||
let prop = g.get_mut("distortionMesh").ok_or_else(|| bad(cmd, "no Distortion Mesh"))?;
|
||||
prop.keys.clear();
|
||||
prop.value = KV::Str(String::new());
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"liquify.stroke",
|
||||
"Liquify Stroke",
|
||||
[],
|
||||
None,
|
||||
"{layer?, effect?: uid, tool?: warp|turbulence|twirlClockwise|twirlCounterclockwise|pucker|bloat|shiftPixels|reflection|clone|reconstruction|freeze|thaw, points: [[x,y],…] (layer space), size?, pressure? (1-100), jitter? (1-100), cloneOffset?: [dx,dy], time? (s) | frame?}",
|
||||
has_comp,
|
||||
stroke
|
||||
),
|
||||
cmd!("liquify.clear", "Clear Liquify Mesh", [], None, "{layer?, effect?: uid}", has_comp, clear),
|
||||
]
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ mod layer;
|
|||
mod layer_menu;
|
||||
mod layer_time;
|
||||
mod link;
|
||||
mod liquify;
|
||||
mod lottie;
|
||||
mod markers;
|
||||
mod mask;
|
||||
|
|
@ -26,6 +27,7 @@ pub mod paint;
|
|||
mod paths;
|
||||
mod project_items;
|
||||
mod prop;
|
||||
mod prop_groups;
|
||||
pub mod puppet;
|
||||
mod query;
|
||||
mod render_queue;
|
||||
|
|
@ -107,6 +109,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(text_edit::specs());
|
||||
v.extend(layer_time::specs());
|
||||
v.extend(prop::specs());
|
||||
v.extend(prop_groups::specs());
|
||||
v.extend(project_items::specs());
|
||||
v.extend(anim::specs());
|
||||
v.extend(anim_tools::specs());
|
||||
|
|
@ -133,6 +136,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(warp_cmds::specs());
|
||||
v.extend(paint::specs());
|
||||
v.extend(puppet::specs());
|
||||
v.extend(liquify::specs());
|
||||
v.extend(settings::specs());
|
||||
v.extend(stubs::specs());
|
||||
v
|
||||
|
|
|
|||
|
|
@ -140,6 +140,92 @@ fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Delete project items (folders with their contents). Layers that use a deleted item and Render
|
||||
/// Queue items of deleted comps go too, like After Effects' Edit ▸ Clear in the Project panel.
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "project.delete";
|
||||
let ids = items_p(s, p, c)?;
|
||||
let mut doomed: Vec<ItemId> = vec![];
|
||||
for id in s.project.items.keys() {
|
||||
if ids.iter().any(|d| is_within(&s.project, *id, *d)) {
|
||||
doomed.push(*id);
|
||||
}
|
||||
}
|
||||
let n = doomed.len();
|
||||
s.edit("Delete Items", None, |proj, st| {
|
||||
for id in &doomed {
|
||||
proj.items.remove(id);
|
||||
}
|
||||
let comp_ids: Vec<ItemId> = proj.comps().map(|(id, _)| *id).collect();
|
||||
for cid in comp_ids {
|
||||
let uses = proj.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.item().is_some_and(|i| doomed.contains(&i))));
|
||||
if !uses {
|
||||
continue;
|
||||
}
|
||||
if let Some(comp) = proj.comp_mut(cid) {
|
||||
let gone: Vec<effectcraft_project::LayerId> =
|
||||
comp.layers.iter().filter(|l| l.source.item().is_some_and(|i| doomed.contains(&i))).map(|l| l.id).collect();
|
||||
comp.layers.retain(|l| !gone.contains(&l.id));
|
||||
for l in &mut comp.layers {
|
||||
if l.parent.is_some_and(|x| gone.contains(&x)) {
|
||||
l.parent = None;
|
||||
}
|
||||
if l.track_matte.is_some_and(|m| gone.contains(&m.layer)) {
|
||||
l.track_matte = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
proj.render_queue.retain(|r| !doomed.contains(&r.comp));
|
||||
st.project_selection.retain(|i| !doomed.contains(i));
|
||||
Ok(())
|
||||
})?;
|
||||
s.sanitize_state();
|
||||
Ok(json!(n))
|
||||
}
|
||||
|
||||
/// Duplicate project items (Edit ▸ Duplicate in the Project panel): comps get fresh layer ids and
|
||||
/// a numbered name; the copies are selected.
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "project.duplicate";
|
||||
let ids = items_p(s, p, c)?;
|
||||
let mut created = vec![];
|
||||
s.edit("Duplicate", None, |proj, st| {
|
||||
for id in &ids {
|
||||
let it = proj.item(*id).ok_or(EngineError::Other("item vanished".into()))?.clone();
|
||||
if it.is_folder() {
|
||||
return Err(bad(c, "folders can't be duplicated"));
|
||||
}
|
||||
let base = it.name.trim_end_matches(|ch: char| ch.is_ascii_digit()).trim_end().to_string();
|
||||
let name = (2..).map(|i| format!("{base} {i}")).find(|n| proj.find_by_name(n).is_none()).unwrap_or_default();
|
||||
let mut kind = it.kind.clone();
|
||||
if let effectcraft_project::ItemKind::Comp(comp) = &mut kind {
|
||||
let comp = std::sync::Arc::make_mut(comp);
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for l in &mut comp.layers {
|
||||
let new = effectcraft_project::LayerId(proj.alloc());
|
||||
map.insert(l.id, new);
|
||||
l.id = new;
|
||||
let mut next = proj.next_id;
|
||||
l.props.reassign_uids(&mut next);
|
||||
proj.next_id = next + 1;
|
||||
}
|
||||
for l in &mut comp.layers {
|
||||
l.parent = l.parent.and_then(|p| map.get(&p).copied());
|
||||
if let Some(m) = &mut l.track_matte {
|
||||
m.layer = map.get(&m.layer).copied().unwrap_or(m.layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
let nid = proj.add_item(&name, it.label, it.parent, kind);
|
||||
created.push(nid);
|
||||
}
|
||||
st.project_selection = created.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": created.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("project.select", "Select Project Items", [], None, "{items: [id|name], add?}", always, select),
|
||||
|
|
@ -147,5 +233,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("project.move", "Move to Folder", [], None, "{items?: [id|name] (default: selected), folder: id|name|null (root)}", always, move_items),
|
||||
cmd!("project.setLabel", "Item Label", [], None, "{items?, label: name|index}", always, set_label),
|
||||
cmd!("project.setComment", "Item Comment", [], None, "{items?, comment}", always, set_comment),
|
||||
cmd!(
|
||||
"project.delete",
|
||||
"Delete Project Items",
|
||||
[],
|
||||
None,
|
||||
"{items?: [id|name] (default: selected)} (folders with their contents; layers using the items go too)",
|
||||
always,
|
||||
delete
|
||||
),
|
||||
cmd!("project.duplicate", "Duplicate Project Items", [], None, "{items?: [id|name] (default: selected)} → {items}", always, duplicate),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
//! Generic edits of property-tree groups by uid: rename, enable, remove, duplicate and reorder an
|
||||
//! effect, mask, shape item or text animator (what scripting's `PropertyGroup.name`, `enabled`,
|
||||
//! `remove()`, `duplicate()` and `moveTo()` do, and what agents need without a UI selection).
|
||||
|
||||
use effectcraft_project::{GroupKind, ItemId, LayerId, Node, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// Groups that can be renamed, removed, duplicated and reordered: instances in indexed lists.
|
||||
fn is_instance(g: &PropGroup) -> bool {
|
||||
matches!(g.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. })
|
||||
}
|
||||
|
||||
/// `{layer, prop: uid}` → (comp, layer, group uid), checking that the group is an instance.
|
||||
fn group_ref(s: &Session, p: &Value, cmd: &str, need_instance: bool) -> Result<(ItemId, LayerId, Uid)> {
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let uid = p.get("prop").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `prop` (group uid)"))?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
let g = l.props.find_group(uid).ok_or_else(|| bad(cmd, format!("no property group @{uid}")))?;
|
||||
if need_instance && !is_instance(g) {
|
||||
return Err(bad(cmd, format!("`{}` is a fixed group (only effects, masks, shape items and animators)", g.name)));
|
||||
}
|
||||
Ok((cid, lid, uid))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.renameGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?.to_string();
|
||||
if name.trim().is_empty() {
|
||||
return Err(bad(c, "the name can't be empty"));
|
||||
}
|
||||
s.edit("Rename", None, |proj, _| {
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
g.name = name.clone();
|
||||
Ok(json!(name))
|
||||
})
|
||||
}
|
||||
|
||||
fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.setGroupEnabled";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
let v = b_p(p, "value");
|
||||
s.edit("Toggle", None, |proj, _| {
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
g.enabled = v.unwrap_or(!g.enabled);
|
||||
Ok(json!(g.enabled))
|
||||
})
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.removeGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
s.edit("Delete", None, |proj, st| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
parent.children.retain(|n| n.uid() != uid);
|
||||
st.selected_props.retain(|(_, u)| *u != uid);
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.duplicateGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
s.edit("Duplicate", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let i = parent.children.iter().position(|n| n.uid() == uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let Node::Group(mut g) = parent.children[i].clone() else { return Err(bad(c, "not a group")) };
|
||||
g.reassign_uids(&mut next);
|
||||
let new = g.uid;
|
||||
parent.children.insert(i + 1, Node::Group(g));
|
||||
proj.next_id = next + 1;
|
||||
Ok(json!({"prop": new}))
|
||||
})
|
||||
}
|
||||
|
||||
fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.moveGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
let to = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing `index` (1-based)"))? as usize;
|
||||
s.edit("Reorder", None, |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let i = parent.children.iter().position(|n| n.uid() == uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
if to < 1 || to > parent.children.len() {
|
||||
return Err(bad(c, format!("index {to} is out of range 1..{}", parent.children.len())));
|
||||
}
|
||||
let n = parent.children.remove(i);
|
||||
parent.children.insert(to - 1, n);
|
||||
Ok(json!(to))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("prop.renameGroup", "Rename Property Group", [], None, "{layer?, prop: uid, name}", has_layers, rename),
|
||||
cmd!("prop.setGroupEnabled", "Enable Property Group", [], None, "{layer?, prop: uid, value?}", has_layers, set_enabled),
|
||||
cmd!("prop.removeGroup", "Delete Property Group", [], None, "{layer?, prop: uid}", has_layers, remove),
|
||||
cmd!("prop.duplicateGroup", "Duplicate Property Group", [], None, "{layer?, prop: uid} → {prop: new uid}", has_layers, duplicate),
|
||||
cmd!("prop.moveGroup", "Reorder Property Group", [], None, "{layer?, prop: uid, index (1-based among its siblings)}", has_layers, move_to),
|
||||
]
|
||||
}
|
||||
|
|
@ -114,7 +114,8 @@ fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Animator {}", anims.children.len() + 1));
|
||||
let mut g = build::text_animator(&mut ids, &name, vec![]);
|
||||
let added: usize = kinds.iter().map(|k| add_props(&mut ids, &mut g, k, three)).sum();
|
||||
if added == 0 {
|
||||
// `properties: []` adds an empty animator (scripting's addProperty("ADBE Text Animator")).
|
||||
if added == 0 && !kinds.is_empty() {
|
||||
return Err(bad(C, "unknown animator property"));
|
||||
}
|
||||
let uid = g.uid;
|
||||
|
|
|
|||
|
|
@ -285,8 +285,25 @@ pub struct Session {
|
|||
pub autosave: autosave::AutoSaveState,
|
||||
/// The viewer snapshot (Take Snapshot / Show Snapshot).
|
||||
pub snapshot: Option<viewer::Snapshot>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// A script to run (see [`Session::script`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ScriptRequest<'a> {
|
||||
pub code: &'a str,
|
||||
/// File name shown in errors and `$.fileName` (`console` for the Script Console).
|
||||
pub name: &'a str,
|
||||
/// Run in the Script Console's persistent context (variables survive between runs).
|
||||
pub console: bool,
|
||||
}
|
||||
|
||||
/// Runs a script against the session and reports `{ok, result, output, error: {message, line,
|
||||
/// column, file} | null}`.
|
||||
pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value;
|
||||
|
||||
impl Default for Session {
|
||||
fn default() -> Self {
|
||||
Session {
|
||||
|
|
@ -319,6 +336,7 @@ impl Default for Session {
|
|||
shortcut_table: std::sync::OnceLock::new(),
|
||||
autosave: autosave::AutoSaveState::default(),
|
||||
snapshot: None,
|
||||
script: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -949,6 +949,7 @@ Window
|
|||
Preview | window.panel {"panel":"preview"} | Cmd+3
|
||||
Progress | window.unavailablePanel {"panel":"progress"}
|
||||
Properties | window.panel {"panel":"properties"}
|
||||
Script Console | window.panel {"panel":"scriptConsole"}
|
||||
Smoother | window.panel {"panel":"smoother"}
|
||||
Tools | window.panel {"panel":"tools"} | Cmd+1
|
||||
Tracker | window.panel {"panel":"tracker"}
|
||||
|
|
|
|||
|
|
@ -227,3 +227,26 @@ fn project_with_paint_and_puppet_round_trips() {
|
|||
let v: Value = serde_json::from_str(&j).unwrap();
|
||||
assert!(v.to_string().contains("ec.paint.paint") && v.to_string().contains("ec.distort.puppet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liquify_stroke_adds_the_effect_and_warps() {
|
||||
let (mut s, id) = setup();
|
||||
// Layer (5, 30) is comp (55, 50). Before: opaque blue.
|
||||
assert!(px(&s, 0.0, 55, 50)[3] > 0.99);
|
||||
let r = s.execute("liquify.stroke", json!({"layer": id, "tool": "warp", "points": [[-20, 30], [10, 30]], "size": 40, "pressure": 100})).unwrap();
|
||||
assert_eq!(r["strokes"], 1);
|
||||
let l = layer(&s, id);
|
||||
let fx = l.effects().unwrap().groups().next().unwrap().clone();
|
||||
assert!(matches!(&fx.kind, effectcraft_project::GroupKind::Effect { effect } if effect == "ec.distort.liquify"));
|
||||
// The warp pulls transparent pixels from outside the layer over its left edge.
|
||||
assert!(px(&s, 0.0, 55, 50)[3] < 0.9, "{:?}", px(&s, 0.0, 55, 50));
|
||||
// A second stroke goes into the same effect.
|
||||
let r2 = s.execute("liquify.stroke", json!({"layer": id, "tool": "reconstruction", "points": [[0, 30]], "size": 60, "pressure": 100})).unwrap();
|
||||
assert_eq!(r2["strokes"], 2);
|
||||
assert_eq!(r2["effect"], r["effect"]);
|
||||
assert!(s.execute("liquify.stroke", json!({"layer": id, "tool": "smudge", "points": [[0, 0]]})).is_err());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(layer(&s, id).effects().unwrap().groups().next().is_none());
|
||||
assert!(px(&s, 0.0, 55, 50)[3] > 0.99);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,3 +39,60 @@ fn move_into_folder_rename_label_comment_with_undo() {
|
|||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.item(comp).unwrap().comment, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_and_duplicate_items() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newNull", json!({})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"comp": inner.0})).unwrap();
|
||||
// Duplicate: a new comp with fresh layer ids.
|
||||
let r = s.execute("project.duplicate", json!({"items": [inner.0]})).unwrap();
|
||||
let dup = ItemId(r["items"][0].as_u64().unwrap());
|
||||
assert_eq!(s.project.item(dup).unwrap().name, "Inner 2");
|
||||
let a = s.project.comp(inner).unwrap().layers[0].id;
|
||||
let b = s.project.comp(dup).unwrap().layers[0].id;
|
||||
assert_ne!(a, b);
|
||||
// Delete: layers that use the item and its render items go too; undoable.
|
||||
s.execute("project.delete", json!({"items": ["Inner"]})).unwrap();
|
||||
assert!(s.project.item(inner).is_none());
|
||||
assert!(s.project.comp(outer).unwrap().layers.is_empty());
|
||||
assert!(s.project.render_queue.is_empty());
|
||||
s.undo();
|
||||
assert!(s.project.item(inner).is_some());
|
||||
assert_eq!(s.project.comp(outer).unwrap().layers.len(), 1);
|
||||
// Folders take their contents.
|
||||
let f = ItemId(s.execute("project.newFolder", json!({"name": "F"})).unwrap()["item"].as_u64().unwrap());
|
||||
s.execute("project.move", json!({"items": [dup.0], "folder": f.0})).unwrap();
|
||||
s.execute("project.delete", json!({"items": [f.0]})).unwrap();
|
||||
assert!(s.project.item(dup).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_edits() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
let a = s.execute("effect.apply", json!({"layer": l, "effect": "Gaussian Blur"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let b = s.execute("effect.apply", json!({"layer": l, "effect": "Invert"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let fx = |s: &Session| -> Vec<(u64, String, bool)> {
|
||||
s.active_comp().unwrap().layers[0].props.sub("effects").unwrap().groups().map(|g| (g.uid, g.name.clone(), g.enabled)).collect()
|
||||
};
|
||||
s.execute("prop.renameGroup", json!({"layer": l, "prop": a, "name": "Soft"})).unwrap();
|
||||
s.execute("prop.setGroupEnabled", json!({"layer": l, "prop": a, "value": false})).unwrap();
|
||||
s.execute("prop.moveGroup", json!({"layer": l, "prop": b, "index": 1})).unwrap();
|
||||
let d = s.execute("prop.duplicateGroup", json!({"layer": l, "prop": a})).unwrap()["prop"].as_u64().unwrap();
|
||||
let v = fx(&s);
|
||||
assert_eq!(v.iter().map(|x| x.0).collect::<Vec<_>>(), [b, a, d]);
|
||||
assert_eq!(v[1].1, "Soft");
|
||||
assert!(!v[1].2);
|
||||
s.execute("prop.removeGroup", json!({"layer": l, "prop": a})).unwrap();
|
||||
assert_eq!(fx(&s).len(), 2);
|
||||
// Fixed groups can't be removed.
|
||||
let tr = s.active_comp().unwrap().layers[0].props.sub("transform").unwrap().uid;
|
||||
assert!(s.execute("prop.removeGroup", json!({"layer": l, "prop": tr})).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "effectcraft-host"
|
||||
description = "Builds a fully wired EffectCraft session: media decoding (FilmCraft codecs), importer and the expression engine"
|
||||
description = "Builds a fully wired EffectCraft session: media decoding (FilmCraft codecs), importer, the expression engine and scripting"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
|
@ -14,6 +14,7 @@ effectcraft-engine = { workspace = true }
|
|||
effectcraft-project = { workspace = true }
|
||||
effectcraft-media = { workspace = true }
|
||||
effectcraft-expr = { workspace = true }
|
||||
effectcraft-script = { workspace = true }
|
||||
effectcraft-export = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
//! A fully wired [`Session`]: footage decoding through `effectcraft-media` (FilmCraft's codecs),
|
||||
//! the media importer, the expression engine and Render Queue export through
|
||||
//! `effectcraft-export` (FilmCraft's encoders). Frontends (desktop, CLI, MCP, web) start here.
|
||||
//! the media importer, the expression engine, JavaScript scripting (`effectcraft-script`) and
|
||||
//! Render Queue export through `effectcraft-export` (FilmCraft's encoders). Frontends (desktop,
|
||||
//! CLI, MCP, web) start here.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::project::render_queue::OutputFormat;
|
||||
use effectcraft_engine::{ExportJob, ExportResult, Exporter, Importer, Session};
|
||||
use effectcraft_project::Footage;
|
||||
pub use effectcraft_script as script;
|
||||
|
||||
struct MediaImporter;
|
||||
|
||||
|
|
@ -47,7 +49,7 @@ impl Exporter for FileExporter {
|
|||
}
|
||||
}
|
||||
|
||||
/// A new session with media, import, expressions and export enabled.
|
||||
/// A new session with media, import, expressions, scripting and export enabled.
|
||||
pub fn session() -> Session {
|
||||
Session {
|
||||
exporter: Some(Arc::new(FileExporter::default())),
|
||||
|
|
@ -55,6 +57,7 @@ pub fn session() -> Session {
|
|||
importer: Some(Arc::new(MediaImporter)),
|
||||
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
||||
expr_check: Some(effectcraft_expr::check_syntax),
|
||||
script: Some(effectcraft_script::runner),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -221,6 +224,64 @@ mod tests {
|
|||
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 12.0);
|
||||
}
|
||||
|
||||
/// A script builds a comp, queues it and renders it through the Render Queue.
|
||||
#[test]
|
||||
fn script_renders_through_the_render_queue() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-script");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let out = dir.join("scripted.gif");
|
||||
let mut s = super::session();
|
||||
let code = format!(
|
||||
r#"
|
||||
var comp = app.project.items.addComp("Scripted", 64, 48, 1, 0.2, 10);
|
||||
var sq = comp.layers.addSolid([1, 0.5, 0], "Square", 16, 16, 1);
|
||||
sq.transform.position.setValueAtTime(0, [8, 24]);
|
||||
sq.transform.position.setValueAtTime(0.1, [56, 24]);
|
||||
var item = app.project.renderQueue.items.add(comp);
|
||||
item.outputModule(1).applyTemplate("GIF");
|
||||
item.outputModule(1).file = new File({});
|
||||
app.project.renderQueue.render();
|
||||
[item.status == RQItemStatus.DONE, item.outputModule(1).file.fsName, app.project.renderQueue.numItems]
|
||||
"#,
|
||||
serde_json::to_string(&out.to_string_lossy()).unwrap()
|
||||
);
|
||||
let r = s.execute("script.run", json!({"code": code})).unwrap();
|
||||
assert_eq!(r["ok"], json!(true), "{r}");
|
||||
assert_eq!(r["result"][0], json!(true), "{r}");
|
||||
assert_eq!(r["result"][2], json!(1));
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
}
|
||||
|
||||
/// importFile, layers.add(footage) and project save/open from a script.
|
||||
#[test]
|
||||
fn script_imports_footage_and_saves() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-script-import");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let q = |n: &str| serde_json::to_string(&dir.join(n).to_string_lossy()).unwrap();
|
||||
let mut s = super::session();
|
||||
let code = format!(
|
||||
r#"
|
||||
var c = app.project.items.addComp("Src", 32, 32, 1, 1, 10);
|
||||
c.layers.addSolid([0, 1, 0], "G", 32, 32, 1);
|
||||
app.run("comp.saveFrameAs", {{comp: c.id, path: {png}}});
|
||||
var still = app.project.importFile(new ImportOptions(new File({png})));
|
||||
var main = app.project.items.addComp("Main", 64, 64, 1, 1, 10);
|
||||
var l = main.layers.add(still);
|
||||
app.project.save(new File({proj}));
|
||||
[still instanceof FootageItem, still.width, still.mainSource.isStill, l.source.name, app.project.file.name]
|
||||
"#,
|
||||
png = q("frame.png"),
|
||||
proj = q("p.ecproj")
|
||||
);
|
||||
let r = s.execute("script.run", json!({"code": code})).unwrap();
|
||||
assert_eq!(r["ok"], json!(true), "{r}");
|
||||
assert_eq!(r["result"], json!([true, 32, true, "frame.png", "p.ecproj"]), "{r}");
|
||||
let r = s.execute("script.run", json!({"code": format!("app.open(new File({})); app.project.numItems", q("p.ecproj"))})).unwrap();
|
||||
assert_eq!(r["result"], json!(5), "{r}"); // Src, Solids, G, frame.png, Main
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expression_syntax_errors_disable_the_expression() {
|
||||
let mut s = super::session();
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ filmcraft-media = { workspace = true }
|
|||
filmcraft-frame = { workspace = true }
|
||||
filmcraft-color = { workspace = true }
|
||||
filmcraft-time = { workspace = true }
|
||||
exr = { workspace = true }
|
||||
image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr"] }
|
||||
rayon = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
//! Auxiliary channels from multi-layer OpenEXR files (depth, object / material IDs, normals,
|
||||
//! Cryptomatte, …) for the 3D Channel effects, read with the pure-Rust `exr` crate.
|
||||
//!
|
||||
//! Every channel of every layer becomes a plane named `layer.channel` (or just `channel` for
|
||||
//! the unnamed layer), placed in the display window (pixels outside a layer's data window are
|
||||
//! 0, or the background depth for depth channels). Cryptomatte manifests come from the
|
||||
//! `cryptomatte/<key>/name` and `cryptomatte/<key>/manifest` header attributes (the published
|
||||
//! Cryptomatte metadata convention: the manifest is a JSON object of name → hex hash).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_raster::AuxChannels;
|
||||
use effectcraft_raster::channels3d::BACKGROUND_DEPTH;
|
||||
|
||||
fn is_depth(name: &str) -> bool {
|
||||
let last = name.rsplit('.').next().unwrap_or(name);
|
||||
last.eq_ignore_ascii_case("z") || name.to_ascii_lowercase().contains("depth")
|
||||
}
|
||||
|
||||
/// Parse a Cryptomatte manifest (`{"name": "hexhash", …}`).
|
||||
pub fn parse_manifest(json: &str) -> Vec<(String, u32)> {
|
||||
let Ok(serde_json::Value::Object(m)) = serde_json::from_str::<serde_json::Value>(json) else { return vec![] };
|
||||
let mut v: Vec<(String, u32)> = m.iter().filter_map(|(k, h)| Some((k.clone(), u32::from_str_radix(h.as_str()?.trim(), 16).ok()?))).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Read all channels of an EXR file's bytes. `None` when it is not a readable EXR.
|
||||
pub fn read_exr_channels(bytes: &[u8]) -> Option<AuxChannels> {
|
||||
use exr::prelude::*;
|
||||
let img = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(std::io::Cursor::new(bytes)).ok()?;
|
||||
let dw = img.attributes.display_window;
|
||||
let (w, h) = (dw.size.0, dw.size.1);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
let mut crypto: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
|
||||
let mut collect_meta = |other: &HashMap<Text, AttributeValue>| {
|
||||
for (k, v) in other {
|
||||
let key = k.to_string();
|
||||
let Some(rest) = key.strip_prefix("cryptomatte/") else { continue };
|
||||
let Some((id, field)) = rest.split_once('/') else { continue };
|
||||
let AttributeValue::Text(t) = v else { continue };
|
||||
let e = crypto.entry(id.to_string()).or_default();
|
||||
match field {
|
||||
"name" => e.0 = Some(t.to_string()),
|
||||
"manifest" => e.1 = Some(t.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
collect_meta(&img.attributes.other);
|
||||
for layer in img.layer_data.iter() {
|
||||
collect_meta(&layer.attributes.other);
|
||||
let prefix = layer.attributes.layer_name.as_ref().map(|t| t.to_string()).filter(|s| !s.is_empty());
|
||||
let (lw, lh) = (layer.size.0, layer.size.1);
|
||||
let pos = layer.attributes.layer_position;
|
||||
let (ox, oy) = (pos.0 as i64 - dw.position.0 as i64, pos.1 as i64 - dw.position.1 as i64);
|
||||
for ch in layer.channel_data.list.iter() {
|
||||
let name = match &prefix {
|
||||
Some(p) => format!("{p}.{}", ch.name),
|
||||
None => ch.name.to_string(),
|
||||
};
|
||||
let fill = if is_depth(&name) { BACKGROUND_DEPTH } else { 0.0 };
|
||||
let mut plane = vec![fill; w * h];
|
||||
let vals: Vec<f32> = ch.sample_data.values_as_f32().collect();
|
||||
if vals.len() < lw * lh {
|
||||
continue;
|
||||
}
|
||||
for y in 0..lh {
|
||||
let ty = y as i64 + oy;
|
||||
if ty < 0 || ty >= h as i64 {
|
||||
continue;
|
||||
}
|
||||
for x in 0..lw {
|
||||
let tx = x as i64 + ox;
|
||||
if tx < 0 || tx >= w as i64 {
|
||||
continue;
|
||||
}
|
||||
plane[ty as usize * w + tx as usize] = vals[y * lw + x];
|
||||
}
|
||||
}
|
||||
aux.channels.push((name, plane));
|
||||
}
|
||||
}
|
||||
for (_, (name, manifest)) in crypto {
|
||||
if let (Some(n), Some(m)) = (name, manifest) {
|
||||
aux.manifests.push((n, parse_manifest(&m)));
|
||||
}
|
||||
}
|
||||
aux.manifests.sort();
|
||||
Some(aux)
|
||||
}
|
||||
|
||||
type Cache = Mutex<Vec<(String, Option<Arc<AuxChannels>>)>>;
|
||||
|
||||
/// Read (and cache, a few files) the channels of the EXR at `path` via `read`.
|
||||
pub(crate) fn cached(path: &str, read: impl FnOnce() -> Option<Arc<[u8]>>) -> Option<Arc<AuxChannels>> {
|
||||
static CACHE: OnceLock<Cache> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(Vec::new()));
|
||||
if let Some(v) = cache.lock().ok().and_then(|c| c.iter().find(|(p, _)| p == path).map(|(_, v)| v.clone())) {
|
||||
return v;
|
||||
}
|
||||
let v = read().and_then(|b| read_exr_channels(&b)).map(Arc::new);
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
if c.len() >= 4 {
|
||||
c.remove(0);
|
||||
}
|
||||
c.push((path.to_string(), v.clone()));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_layered_channels_and_cryptomatte_metadata() {
|
||||
use exr::prelude::*;
|
||||
let (w, h) = (4usize, 2usize);
|
||||
let z: Vec<f32> = (0..w * h).map(|i| i as f32 * 10.0).collect();
|
||||
let id: Vec<f32> = (0..w * h).map(|i| (i % 2) as f32 + 1.0).collect();
|
||||
let r: Vec<f32> = vec![0.5; w * h];
|
||||
let channels = AnyChannels::sort(
|
||||
vec![
|
||||
AnyChannel::new("R", FlatSamples::F32(r)),
|
||||
AnyChannel::new("Z", FlatSamples::F32(z.clone())),
|
||||
AnyChannel::new("ObjectID", FlatSamples::F32(id.clone())),
|
||||
]
|
||||
.into(),
|
||||
);
|
||||
let mut layer = Layer::new((w, h), LayerAttributes::named("depth"), Encoding::FAST_LOSSLESS, channels);
|
||||
layer.attributes.other.insert(Text::from("cryptomatte/abc1234/name"), AttributeValue::Text(Text::from("CryptoObject")));
|
||||
layer
|
||||
.attributes
|
||||
.other
|
||||
.insert(Text::from("cryptomatte/abc1234/manifest"), AttributeValue::Text(Text::from(r#"{"bunny":"13851a76","floor":"0a1b2c3d"}"#)));
|
||||
let image = Image::from_layer(layer);
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image.write().to_buffered(&mut bytes).unwrap();
|
||||
let aux = read_exr_channels(bytes.get_ref()).unwrap();
|
||||
assert_eq!((aux.width, aux.height), (4, 2));
|
||||
assert_eq!(aux.get("depth.Z").unwrap(), z.as_slice());
|
||||
assert_eq!(aux.depth().unwrap(), z.as_slice());
|
||||
assert_eq!(aux.object_id().unwrap(), id.as_slice());
|
||||
assert_eq!(aux.manifests, vec![("CryptoObject".to_string(), vec![("bunny".to_string(), 0x1385_1a76), ("floor".to_string(), 0x0a1b_2c3d)])]);
|
||||
assert!(read_exr_channels(b"not an exr").is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
//! (PNG, JPEG, GIF, WebP, TIFF, BMP, OpenEXR).
|
||||
|
||||
mod convert;
|
||||
pub mod exr_channels;
|
||||
mod pool;
|
||||
mod probe;
|
||||
|
||||
|
|
|
|||
|
|
@ -503,6 +503,18 @@ impl FootageSource for MediaPool {
|
|||
}
|
||||
}
|
||||
|
||||
fn aux(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
|
||||
if footage.missing || !footage.has_video {
|
||||
return None;
|
||||
}
|
||||
let loc = Inner::locate(footage, t);
|
||||
if loc.media_t.is_some() || !loc.key.path.to_ascii_lowercase().ends_with(".exr") {
|
||||
return None;
|
||||
}
|
||||
let path = loc.key.path.clone();
|
||||
crate::exr_channels::cached(&path, || self.inner.read(&path).ok())
|
||||
}
|
||||
|
||||
fn audio(&self, _item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
if !footage.has_audio || footage.missing {
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
//! Auxiliary (3D) channels that travel with a layer's pixels: depth, object / material IDs,
|
||||
//! surface normals, texture UVs, coverage and Cryptomatte ID/coverage pairs.
|
||||
//!
|
||||
//! They come from multi-layer OpenEXR footage (any channel by its full `layer.channel` name)
|
||||
//! or from the compositor's own render passes for precomps of 3D comps. The 3D Channel effects
|
||||
//! read them through `EffectHost::aux`.
|
||||
//!
|
||||
//! Conventions (shared by the EXR reader and the render pass):
|
||||
//!
|
||||
//! * channel planes are row-major `width × height` `f32`s covering the layer's source rectangle,
|
||||
//! with `scale` aux pixels per layer pixel;
|
||||
//! * depth is camera-space distance in pixels (`Z`), background `BACKGROUND_DEPTH`;
|
||||
//! * `ObjectID` / `MaterialID` are small integers (0 = background);
|
||||
//! * Cryptomatte layers follow the published Cryptomatte specification (Psyop, 2015): a layer
|
||||
//! named `CryptoObject` stores rank pairs in `CryptoObject00.R/G/B/A`, `CryptoObject01.R/…`
|
||||
//! (ID float, coverage, ID float, coverage, …), IDs are MurmurHash3 (x86, 32-bit, seed 0) of
|
||||
//! the object name reinterpreted as a float with the exponent nudged off 0 and 255, and the
|
||||
//! manifest maps names to hashes.
|
||||
|
||||
/// Depth written where nothing was rendered.
|
||||
pub const BACKGROUND_DEPTH: f32 = 1.0e10;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AuxChannels {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Aux pixels per layer pixel.
|
||||
pub scale: f64,
|
||||
/// Named planes (`Z`, `ObjectID`, `CryptoObject00.R`, …).
|
||||
pub channels: Vec<(String, Vec<f32>)>,
|
||||
/// Cryptomatte manifests: (layer name, [(object name, hash)]).
|
||||
pub manifests: Vec<(String, Vec<(String, u32)>)>,
|
||||
}
|
||||
|
||||
impl AuxChannels {
|
||||
pub fn new(width: u32, height: u32, scale: f64) -> AuxChannels {
|
||||
AuxChannels { width, height, scale, channels: vec![], manifests: vec![] }
|
||||
}
|
||||
|
||||
/// A channel by exact name (case-insensitive).
|
||||
pub fn get(&self, name: &str) -> Option<&[f32]> {
|
||||
self.channels.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_slice())
|
||||
}
|
||||
|
||||
/// The first channel present among `names` (also matching `*.name` suffixes, so `Z` finds
|
||||
/// `depth.Z`).
|
||||
pub fn find(&self, names: &[&str]) -> Option<&[f32]> {
|
||||
for n in names {
|
||||
if let Some(v) = self.get(n) {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
for n in names {
|
||||
let suffix = format!(".{}", n.to_ascii_lowercase());
|
||||
if let Some((_, v)) = self.channels.iter().find(|(c, _)| c.to_ascii_lowercase().ends_with(&suffix)) {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn names(&self) -> Vec<&str> {
|
||||
self.channels.iter().map(|(n, _)| n.as_str()).collect()
|
||||
}
|
||||
|
||||
pub fn depth(&self) -> Option<&[f32]> {
|
||||
self.find(&["Z", "depth", "Depth", "zDepth", "Z-Depth"])
|
||||
}
|
||||
pub fn object_id(&self) -> Option<&[f32]> {
|
||||
self.find(&["ObjectID", "object_id", "objectId", "id"])
|
||||
}
|
||||
pub fn material_id(&self) -> Option<&[f32]> {
|
||||
self.find(&["MaterialID", "material_id", "materialId"])
|
||||
}
|
||||
pub fn coverage(&self) -> Option<&[f32]> {
|
||||
self.find(&["Coverage", "coverage"])
|
||||
}
|
||||
|
||||
/// Cryptomatte layer names present (prefixes with a `00.R` channel).
|
||||
pub fn crypto_layers(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> =
|
||||
self.channels.iter().filter_map(|(n, _)| n.strip_suffix("00.R").or_else(|| n.strip_suffix("00.r")).map(str::to_string)).collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// Rank pairs (id, coverage) of Cryptomatte layer `layer` at aux pixel index `i`.
|
||||
pub fn crypto_ranks(&self, layer: &str, i: usize) -> Vec<(f32, f32)> {
|
||||
let mut out = vec![];
|
||||
for k in 0.. {
|
||||
let (r, g, b, a) = (
|
||||
self.get(&format!("{layer}{k:02}.R")),
|
||||
self.get(&format!("{layer}{k:02}.G")),
|
||||
self.get(&format!("{layer}{k:02}.B")),
|
||||
self.get(&format!("{layer}{k:02}.A")),
|
||||
);
|
||||
let (Some(r), Some(g)) = (r, g) else { break };
|
||||
out.push((r[i], g[i]));
|
||||
if let (Some(b), Some(a)) = (b, a) {
|
||||
out.push((b[i], a[i]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Aux pixel index of a layer-space point (nearest; `None` outside).
|
||||
#[inline]
|
||||
pub fn index_at(&self, lx: f64, ly: f64) -> Option<usize> {
|
||||
let x = (lx * self.scale).floor();
|
||||
let y = (ly * self.scale).floor();
|
||||
if x < 0.0 || y < 0.0 || x >= self.width as f64 || y >= self.height as f64 {
|
||||
return None;
|
||||
}
|
||||
Some(y as usize * self.width as usize + x as usize)
|
||||
}
|
||||
|
||||
/// Manifest entry name for a hash in Cryptomatte layer `layer`.
|
||||
pub fn manifest_name(&self, layer: &str, hash: u32) -> Option<&str> {
|
||||
self.manifests.iter().find(|(l, _)| l == layer)?.1.iter().find(|(_, h)| *h == hash).map(|(n, _)| n.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// MurmurHash3 x86 32-bit (public-domain algorithm by Austin Appleby).
|
||||
pub fn murmur3_32(data: &[u8], seed: u32) -> u32 {
|
||||
const C1: u32 = 0xcc9e_2d51;
|
||||
const C2: u32 = 0x1b87_3593;
|
||||
let mut h = seed;
|
||||
let chunks = data.chunks_exact(4);
|
||||
let tail = chunks.remainder();
|
||||
for c in chunks {
|
||||
let mut k = u32::from_le_bytes([c[0], c[1], c[2], c[3]]);
|
||||
k = k.wrapping_mul(C1).rotate_left(15).wrapping_mul(C2);
|
||||
h ^= k;
|
||||
h = h.rotate_left(13).wrapping_mul(5).wrapping_add(0xe654_6b64);
|
||||
}
|
||||
let mut k = 0u32;
|
||||
for (i, b) in tail.iter().enumerate() {
|
||||
k ^= (*b as u32) << (8 * i);
|
||||
}
|
||||
if !tail.is_empty() {
|
||||
k = k.wrapping_mul(C1).rotate_left(15).wrapping_mul(C2);
|
||||
h ^= k;
|
||||
}
|
||||
h ^= data.len() as u32;
|
||||
h ^= h >> 16;
|
||||
h = h.wrapping_mul(0x85eb_ca6b);
|
||||
h ^= h >> 13;
|
||||
h = h.wrapping_mul(0xc2b2_ae35);
|
||||
h ^= h >> 16;
|
||||
h
|
||||
}
|
||||
|
||||
/// Cryptomatte hash of a name (the manifest value).
|
||||
pub fn crypto_hash(name: &str) -> u32 {
|
||||
let h = murmur3_32(name.as_bytes(), 0);
|
||||
// Keep the float finite and normal: flip the low exponent bit when it is all 0s or 1s.
|
||||
let e = (h >> 23) & 0xff;
|
||||
if e == 0 || e == 255 { h ^ (1 << 23) } else { h }
|
||||
}
|
||||
|
||||
/// The ID float stored in Cryptomatte channels for a hash.
|
||||
pub fn crypto_float(hash: u32) -> f32 {
|
||||
f32::from_bits(hash)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn murmur3_reference_values() {
|
||||
// Published MurmurHash3_x86_32 test vectors.
|
||||
assert_eq!(murmur3_32(b"", 0), 0);
|
||||
assert_eq!(murmur3_32(b"", 1), 0x514e_28b7);
|
||||
assert_eq!(murmur3_32(b"", 0xffff_ffff), 0x81f1_6f39);
|
||||
assert_eq!(murmur3_32(&[0, 0, 0, 0], 0), 0x2362_f9de);
|
||||
assert_eq!(murmur3_32(b"a", 0x9747_b28c), 0x7fa0_9ea6);
|
||||
assert_eq!(murmur3_32(b"aaaa", 0x9747_b28c), 0x5a97_808a);
|
||||
assert_eq!(murmur3_32(b"Hello, world!", 0x9747_b28c), 0x2488_4cba);
|
||||
assert_eq!(murmur3_32(b"The quick brown fox jumps over the lazy dog", 0x9747_b28c), 0x2fa8_26cd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypto_floats_are_normal_and_round_trip() {
|
||||
for n in ["bunny", "set", "ground", "a", ""] {
|
||||
let h = crypto_hash(n);
|
||||
let f = crypto_float(h);
|
||||
assert!(f.is_finite() && (f == 0.0 || f.is_normal() || f.abs() > 0.0), "{n}");
|
||||
assert_eq!(f.to_bits(), h);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_helpers() {
|
||||
let mut a = AuxChannels::new(2, 1, 1.0);
|
||||
a.channels.push(("depth.Z".into(), vec![5.0, 6.0]));
|
||||
a.channels.push(("CryptoObject00.R".into(), vec![1.0, 2.0]));
|
||||
a.channels.push(("CryptoObject00.G".into(), vec![0.5, 1.0]));
|
||||
assert_eq!(a.depth(), Some(&[5.0, 6.0][..]));
|
||||
assert_eq!(a.crypto_layers(), vec!["CryptoObject".to_string()]);
|
||||
assert_eq!(a.crypto_ranks("CryptoObject", 0), vec![(1.0, 0.5)]);
|
||||
assert_eq!(a.index_at(1.5, 0.2), Some(1));
|
||||
assert_eq!(a.index_at(2.5, 0.2), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
//! heavy operation is row-parallel with rayon.
|
||||
|
||||
pub mod blur;
|
||||
pub mod channels3d;
|
||||
pub mod flow;
|
||||
pub mod warp;
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ use effectcraft_color::{BlendMode, blend_pixel};
|
|||
use rayon::prelude::*;
|
||||
|
||||
pub use blur::{box_blur, directional_blur, gaussian_blur, radial_blur};
|
||||
pub use channels3d::AuxChannels;
|
||||
pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample};
|
||||
|
||||
pub type Px = [f32; 4];
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ pub trait FootageSource: Send + Sync {
|
|||
fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
/// Auxiliary 3D channels of `item` at source time `t` (multi-layer OpenEXR: depth, IDs,
|
||||
/// Cryptomatte, any named channel), with `scale` relative to the file's pixels. `None` when
|
||||
/// the footage has none.
|
||||
fn aux(&self, _item: ItemId, _footage: &Footage, _t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
|
||||
None
|
||||
}
|
||||
/// The parsed 3D model of a [`effectcraft_project::FootageKind::Model`] item (Advanced 3D).
|
||||
fn model(&self, _item: ItemId, _footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
|
||||
None
|
||||
|
|
@ -111,6 +117,29 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
|||
sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone())
|
||||
}
|
||||
|
||||
fn aux(&self) -> Option<Arc<effectcraft_raster::AuxChannels>> {
|
||||
match &self.layer.source {
|
||||
LayerSource::Footage { item } => {
|
||||
let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None };
|
||||
let a = self.r.footage.aux(*item, f, self.ctx.source_time(self.layer))?;
|
||||
// Aux pixels per layer pixel (the layer is the footage at its nominal size).
|
||||
if f.width > 0 && a.width > 0 {
|
||||
let mut a = (*a).clone();
|
||||
a.scale = a.width as f64 / f.width as f64;
|
||||
return Some(Arc::new(a));
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
LayerSource::Comp { item } => {
|
||||
if self.r.depth > MAX_FX_DEPTH || self.r.project.comp_contains(*item, self.ctx.comp_id) {
|
||||
return None;
|
||||
}
|
||||
self.r.nested().comp_aux(*item, self.ctx.source_time(self.layer)).map(Arc::new)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
|
||||
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
|
||||
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
|
||||
|
|
@ -537,8 +566,15 @@ impl<'a> Renderer<'a> {
|
|||
let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
|
||||
let mask_shapes = masks::shapes(ctx, layer);
|
||||
let host = FxHost { r: self, ctx, layer, index: Default::default() };
|
||||
let env =
|
||||
EffectEnv { masks: &mask_shapes, host: Some(&host), comp_time: ctx.time.seconds(), frame_rate: ctx.comp.frame_rate.as_f64(), effect_index: 0 };
|
||||
let env = EffectEnv {
|
||||
masks: &mask_shapes,
|
||||
host: Some(&host),
|
||||
comp_time: ctx.time.seconds(),
|
||||
frame_rate: ctx.comp.frame_rate.as_f64(),
|
||||
effect_index: 0,
|
||||
working_space: self.pipe.space,
|
||||
working_linear: self.pipe.linear,
|
||||
};
|
||||
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
|
||||
let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx
|
||||
.groups()
|
||||
|
|
@ -1178,6 +1214,13 @@ impl<'a> Renderer<'a> {
|
|||
self.depth
|
||||
}
|
||||
|
||||
/// The auxiliary 3D channels of comp `comp_id` at comp time `t` (depth, layer IDs, normals,
|
||||
/// UVs, Cryptomatte; see `three_d::compose::aux_pass`), at the renderer's output scale.
|
||||
pub fn comp_aux(&self, comp_id: ItemId, t: Tick) -> Option<effectcraft_raster::AuxChannels> {
|
||||
let ctx = self.eval_ctx(comp_id, t)?;
|
||||
Some(three_d::compose::aux_pass(self, &ctx))
|
||||
}
|
||||
|
||||
/// Evaluation context of comp `comp_id` at comp time `t`.
|
||||
pub fn eval_ctx(&self, comp_id: ItemId, t: Tick) -> Option<EvalCtx<'a>> {
|
||||
Some(self.ctx(comp_id, self.project.comp(comp_id)?, t))
|
||||
|
|
@ -1298,6 +1341,8 @@ pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath {
|
|||
#[cfg(test)]
|
||||
mod tests_audio_fx;
|
||||
#[cfg(test)]
|
||||
mod tests_aux;
|
||||
#[cfg(test)]
|
||||
mod tests_collapse;
|
||||
#[cfg(test)]
|
||||
mod tests_color;
|
||||
|
|
|
|||
|
|
@ -174,7 +174,17 @@ fn blocks_match_one_long_mix() {
|
|||
let x = ((t * 12345.678).sin() * 43758.5453).fract() as f32;
|
||||
(x, -x)
|
||||
}
|
||||
for id in ["ec.audio.reverb", "ec.audio.basstreble", "ec.audio.flangechorus", "ec.audio.modulator", "ec.audio.delay", "ec.audio.parametriceq"] {
|
||||
for id in [
|
||||
"ec.audio.reverb",
|
||||
"ec.audio.basstreble",
|
||||
"ec.audio.flangechorus",
|
||||
"ec.audio.modulator",
|
||||
"ec.audio.delay",
|
||||
"ec.audio.parametriceq",
|
||||
"ec.audio.compressor",
|
||||
"ec.audio.gate",
|
||||
"ec.audio.distortion",
|
||||
] {
|
||||
let vals: Vec<(&str, Value)> = match id {
|
||||
"ec.audio.basstreble" => vec![("bass", Value::Scalar(50.0))],
|
||||
"ec.audio.parametriceq" => vec![("band1BoostCut", Value::Scalar(9.0))],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
//! Auxiliary 3D channels: the comp's depth / ID / Cryptomatte pass and the 3D Channel effects on
|
||||
//! a precomp of a 3D comp.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
|
||||
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_hash};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{NoFootage, RenderOpts, Renderer, render_frame};
|
||||
|
||||
const W: u32 = 200;
|
||||
const H: u32 = 100;
|
||||
|
||||
fn zoom() -> f64 {
|
||||
W as f64 * 50.0 / 36.0
|
||||
}
|
||||
|
||||
fn solid3(p: &mut Project, comp: &Comp, name: &str, color: [f32; 3], pos: [f64; 3]) -> Layer {
|
||||
let sid = p.add_item(name, Label::Red, None, ItemKind::Solid(Solid { color, width: 60, height: 60, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(p, comp, name, LayerSource::Solid { item: sid }, (60, 60), None);
|
||||
l.name = name.to_string();
|
||||
l.switches.three_d = true;
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3(pos);
|
||||
l
|
||||
}
|
||||
|
||||
/// Inner 3D comp: "Near" (red, z 0, left) above "Far" (green, z 500, right). Outer comp: the
|
||||
/// precomp layer, optionally with an effect.
|
||||
fn scene(effect: Option<(&str, Vec<(&str, Value)>)>) -> (Project, ItemId, ItemId) {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
|
||||
let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into()));
|
||||
let near = solid3(&mut p, &inner, "Near", [1.0, 0.0, 0.0], [60.0, 50.0, 0.0]);
|
||||
let far = solid3(&mut p, &inner, "Far", [0.0, 1.0, 0.0], [140.0, 50.0, 500.0]);
|
||||
p.comp_mut(iid).unwrap().layers.push(near);
|
||||
p.comp_mut(iid).unwrap().layers.push(far);
|
||||
let outer = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
|
||||
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (W, H), None);
|
||||
if let Some((id, vals)) = effect {
|
||||
let spec = effectcraft_effects::find(id).unwrap();
|
||||
let mut next = p.next_id;
|
||||
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]);
|
||||
p.next_id = next;
|
||||
for (k, v) in vals {
|
||||
g.prop_mut(k).unwrap().value = v.clone();
|
||||
}
|
||||
pre.props.sub_mut("effects").unwrap().children.push(g.into());
|
||||
}
|
||||
p.comp_mut(oid).unwrap().layers.push(pre);
|
||||
(p, iid, oid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_aux_pass_has_depth_ids_and_cryptomatte() {
|
||||
let (p, iid, _) = scene(None);
|
||||
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
|
||||
let a = r.comp_aux(iid, Tick::ZERO).unwrap();
|
||||
assert_eq!((a.width, a.height), (W, H));
|
||||
let at = |name: &str, x: usize, y: usize| a.get(name).unwrap()[y * W as usize + x];
|
||||
// Near plane at the camera's zoom distance, the far one 500 px behind it.
|
||||
assert!((at("Z", 60, 50) as f64 - zoom()).abs() < 1e-2, "{}", at("Z", 60, 50));
|
||||
assert!((at("Z", 114, 50) as f64 - (zoom() + 500.0)).abs() < 1e-2, "{}", at("Z", 114, 50));
|
||||
assert_eq!(at("Z", 5, 5), BACKGROUND_DEPTH);
|
||||
assert_eq!((at("ObjectID", 60, 50), at("ObjectID", 114, 50), at("ObjectID", 5, 5)), (1.0, 2.0, 0.0));
|
||||
assert_eq!(at("Coverage", 60, 50), 1.0);
|
||||
// Planes face the camera.
|
||||
assert!((at("N.Z", 60, 50) + 1.0).abs() < 1e-5);
|
||||
// Cryptomatte rank 0 = the layer under the pixel, full coverage; manifest lists names.
|
||||
assert_eq!(at("CryptoObject00.R", 60, 50).to_bits(), crypto_hash("Near"));
|
||||
assert_eq!(at("CryptoObject00.G", 60, 50), 1.0);
|
||||
assert_eq!(a.manifest_name("CryptoObject", crypto_hash("Far")), Some("Far"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_channel_effects_work_on_a_precomp_of_a_3d_comp() {
|
||||
let near = (60, 50);
|
||||
let far = (114, 50);
|
||||
let alpha = |p: &Project, oid: ItemId, (x, y): (i64, i64)| render_frame(p, oid, Tick::ZERO, 1.0).get(x, y)[3];
|
||||
let (p, _, oid) = scene(Some(("ec.3d.idmatte", vec![("idSelection", Value::Scalar(2.0))])));
|
||||
assert_eq!(alpha(&p, oid, near), 0.0);
|
||||
assert!(alpha(&p, oid, far) > 0.99);
|
||||
let (p, _, oid) = scene(Some(("ec.3d.depthmatte", vec![("depth", Value::Scalar(zoom() + 250.0))])));
|
||||
assert_eq!(alpha(&p, oid, near), 0.0);
|
||||
assert!(alpha(&p, oid, far) > 0.99);
|
||||
let (p, _, oid) = scene(Some(("ec.3d.cryptomatte", vec![("selection", Value::Str("Near".into())), ("display", Value::Enum(1))])));
|
||||
assert!(alpha(&p, oid, near) > 0.99);
|
||||
assert_eq!(alpha(&p, oid, far), 0.0);
|
||||
// Half resolution: the pass follows the render scale.
|
||||
let (p, iid, _) = scene(None);
|
||||
let r = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() });
|
||||
let a = r.comp_aux(iid, Tick::ZERO).unwrap();
|
||||
assert_eq!((a.width, a.height, a.scale), (W / 2, H / 2, 0.5));
|
||||
assert_eq!(a.get("ObjectID").unwrap()[25 * 100 + 30], 1.0);
|
||||
}
|
||||
|
|
@ -589,3 +589,182 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {
|
|||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ auxiliary render pass
|
||||
|
||||
/// One surface seen through an output pixel (aux pass).
|
||||
#[derive(Clone, Copy)]
|
||||
struct AuxFrag {
|
||||
z: f32,
|
||||
a: f32,
|
||||
obj: f32,
|
||||
mat: f32,
|
||||
n: [f32; 3],
|
||||
uv: [f32; 2],
|
||||
obj_hash: u32,
|
||||
mat_hash: u32,
|
||||
}
|
||||
|
||||
/// The comp's auxiliary channels at the context time, at output resolution: camera-space depth
|
||||
/// (`Z`), `ObjectID` (layer number), `MaterialID` (source item id), `Coverage`, camera-space
|
||||
/// normals (`N.X/Y/Z`), texture coordinates (`UV.U/V`) and Cryptomatte `CryptoObject` (layer
|
||||
/// names) / `CryptoMaterial` (source names) with two ranks each, plus their manifests.
|
||||
///
|
||||
/// Layers are gathered in stack order (top first); a run of 3D layers contributes its planes
|
||||
/// sorted near → far at each pixel, a 2D layer its coverage on the plane of the comp. The
|
||||
/// front-most surface with ≥ 50 % opacity (else the strongest one) provides depth, IDs, normal
|
||||
/// and UV; Cryptomatte coverage is each surface's visible share (`α · Π(1 − α_front)`).
|
||||
pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxChannels {
|
||||
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_float, crypto_hash};
|
||||
let s = r.opts.scale;
|
||||
let (w, h) = (((ctx.comp.width as f64 * s).round() as u32).max(1), ((ctx.comp.height as f64 * s).round() as u32).max(1));
|
||||
let out = (w, h);
|
||||
let (wu, n) = (w as usize, w as usize * h as usize);
|
||||
let cam = camera_for(r, ctx);
|
||||
let visible: Vec<&Layer> = r.visible_layers(ctx).into_iter().filter(|l| !l.switches.adjustment).collect();
|
||||
let number = |l: &Layer| ctx.comp.layers.iter().position(|x| x.id == l.id).map_or(0.0, |i| i as f32 + 1.0);
|
||||
let source_name = |l: &Layer| l.source.item().and_then(|i| r.project.item(i)).map(|it| it.name.clone()).unwrap_or_else(|| l.name.clone());
|
||||
let source_id = |l: &Layer| l.source.item().map_or(0.0, |i| i.0 as f32);
|
||||
let mut manifest_obj: Vec<(String, u32)> = vec![];
|
||||
let mut manifest_mat: Vec<(String, u32)> = vec![];
|
||||
for l in &visible {
|
||||
let (on, mn) = (l.name.clone(), source_name(l));
|
||||
if !manifest_obj.iter().any(|(x, _)| *x == on) {
|
||||
manifest_obj.push((on.clone(), crypto_hash(&on)));
|
||||
}
|
||||
if !manifest_mat.iter().any(|(x, _)| *x == mn) {
|
||||
manifest_mat.push((mn.clone(), crypto_hash(&mn)));
|
||||
}
|
||||
}
|
||||
// Groups in stack order: runs of 3D layers, single 2D layers.
|
||||
let mut groups: Vec<&[&Layer]> = vec![];
|
||||
let mut i = 0;
|
||||
while i < visible.len() {
|
||||
let mut j = i + 1;
|
||||
if visible[i].is_3d() {
|
||||
while j < visible.len() && visible[j].is_3d() {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
groups.push(&visible[i..j]);
|
||||
i = j;
|
||||
}
|
||||
let mut frags: Vec<Vec<AuxFrag>> = vec![Vec::new(); n];
|
||||
let plane_z = cam.depth(vec3(ctx.comp.width as f64 * 0.5, ctx.comp.height as f64 * 0.5, 0.0)) as f32;
|
||||
for g in groups.iter().rev() {
|
||||
if g[0].is_3d() {
|
||||
let items: Vec<Item> = g.iter().enumerate().flat_map(|(k, l)| prepare_all(r, ctx, l, k, out, true)).collect();
|
||||
let meta: Vec<(f32, f32, u32, u32, [f32; 3], [f64; 2])> = items
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let nrm = it.geos.first().map_or(vec3(0.0, 0.0, -1.0), |gm| cam.view.apply_vec(gm.normal));
|
||||
let mut nv = [nrm.x as f32, nrm.y as f32, nrm.z as f32];
|
||||
if nv[2] > 0.0 {
|
||||
nv = nv.map(|v| -v);
|
||||
}
|
||||
let (sw, sh) = crate::source_size(r.project, it.layer);
|
||||
let size = if sw == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [sw as f64, sh as f64] };
|
||||
(number(it.layer), source_id(it.layer), crypto_hash(&it.layer.name), crypto_hash(&source_name(it.layer)), nv, size)
|
||||
})
|
||||
.collect();
|
||||
frags.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
|
||||
let yi = y as i64;
|
||||
let mut local: Vec<AuxFrag> = Vec::new();
|
||||
for (x, cell) in row.iter_mut().enumerate() {
|
||||
let xi = x as i64;
|
||||
local.clear();
|
||||
for (it, m) in items.iter().zip(&meta) {
|
||||
if xi < it.bbox[0] || xi >= it.bbox[2] || yi < it.bbox[1] || yi >= it.bbox[3] {
|
||||
continue;
|
||||
}
|
||||
let Some(gm) = it.geos.first() else { continue };
|
||||
let Some((u, v, z)) = unproject(gm, x as f64 + 0.5, y as f64 + 0.5) else { continue };
|
||||
let a = it.texel(u, v)[3] * it.opacity;
|
||||
if a > 1e-4 {
|
||||
let uv = [(u / m.5[0]) as f32, (v / m.5[1]) as f32];
|
||||
local.push(AuxFrag { z: z as f32, a: a.min(1.0), obj: m.0, mat: m.1, n: m.4, uv, obj_hash: m.2, mat_hash: m.3 });
|
||||
}
|
||||
}
|
||||
local.sort_by(|p, q| p.z.total_cmp(&q.z));
|
||||
cell.extend_from_slice(&local);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let l = g[0];
|
||||
let mut img = Image::new(w, h);
|
||||
if let Some(b) = r.layer_buf(ctx, l) {
|
||||
r.place(ctx, l, &b, &mut img, BlendMode::Normal, ctx.opacity(l) as f32);
|
||||
}
|
||||
let (obj, mat, oh, mh) = (number(l), source_id(l), crypto_hash(&l.name), crypto_hash(&source_name(l)));
|
||||
frags.par_chunks_mut(wu).zip(img.data.par_chunks(wu)).enumerate().for_each(|(y, (row, px))| {
|
||||
for (x, (cell, p)) in row.iter_mut().zip(px).enumerate() {
|
||||
if p[3] > 1e-4 {
|
||||
let uv = [(x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32];
|
||||
cell.push(AuxFrag { z: plane_z, a: p[3].min(1.0), obj, mat, n: [0.0, 0.0, -1.0], uv, obj_hash: oh, mat_hash: mh });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Resolve per pixel.
|
||||
const NAMES: [&str; 9] = ["Z", "ObjectID", "MaterialID", "Coverage", "N.X", "N.Y", "N.Z", "UV.U", "UV.V"];
|
||||
const CRYPTO: usize = 16;
|
||||
let resolved: Vec<([f32; 9], [f32; CRYPTO])> = frags
|
||||
.par_iter()
|
||||
.map(|fs| {
|
||||
let mut o = [BACKGROUND_DEPTH, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
let mut crypto = [0.0f32; CRYPTO];
|
||||
if fs.is_empty() {
|
||||
return (o, crypto);
|
||||
}
|
||||
let mut t = 1.0f32;
|
||||
let mut obj: Vec<(u32, f32)> = vec![];
|
||||
let mut mat: Vec<(u32, f32)> = vec![];
|
||||
let mut best: Option<(usize, f32)> = None;
|
||||
let mut front: Option<usize> = None;
|
||||
for (k, f) in fs.iter().enumerate() {
|
||||
let c = f.a * t;
|
||||
if front.is_none() && f.a >= 0.5 {
|
||||
front = Some(k);
|
||||
}
|
||||
if best.is_none_or(|(_, bc)| c > bc) {
|
||||
best = Some((k, c));
|
||||
}
|
||||
for (list, hsh) in [(&mut obj, f.obj_hash), (&mut mat, f.mat_hash)] {
|
||||
match list.iter_mut().find(|(x, _)| *x == hsh) {
|
||||
Some(e) => e.1 += c,
|
||||
None => list.push((hsh, c)),
|
||||
}
|
||||
}
|
||||
t *= 1.0 - f.a;
|
||||
if t <= 1e-5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let f = fs[front.or(best.map(|b| b.0)).unwrap_or(0)];
|
||||
o = [f.z, f.obj, f.mat, 1.0 - t, f.n[0], f.n[1], f.n[2], f.uv[0], f.uv[1]];
|
||||
for (base, list) in [(0usize, &mut obj), (8usize, &mut mat)] {
|
||||
list.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
for (rank, (hsh, c)) in list.iter().take(4).enumerate() {
|
||||
crypto[base + rank * 2] = crypto_float(*hsh);
|
||||
crypto[base + rank * 2 + 1] = *c;
|
||||
}
|
||||
}
|
||||
(o, crypto)
|
||||
})
|
||||
.collect();
|
||||
let mut aux = effectcraft_raster::AuxChannels::new(w, h, s);
|
||||
for (k, name) in NAMES.iter().enumerate() {
|
||||
aux.channels.push((name.to_string(), resolved.iter().map(|r| r.0[k]).collect()));
|
||||
}
|
||||
for (base, layer) in [(0usize, "CryptoObject"), (8usize, "CryptoMaterial")] {
|
||||
for rank_pair in 0..2 {
|
||||
for (c, ch) in ["R", "G", "B", "A"].iter().enumerate() {
|
||||
let k = base + rank_pair * 4 + c;
|
||||
aux.channels.push((format!("{layer}{rank_pair:02}.{ch}"), resolved.iter().map(|r| r.1[k]).collect()));
|
||||
}
|
||||
}
|
||||
}
|
||||
aux.manifests = vec![("CryptoObject".into(), manifest_obj), ("CryptoMaterial".into(), manifest_mat)];
|
||||
aux
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "effectcraft-script"
|
||||
description = "EffectCraft scripting: JavaScript (boa) with an After Effects-style object model (app, project, CompItem, Layer, Property…) over the engine's undoable commands"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
effectcraft-engine = { workspace = true }
|
||||
effectcraft-project = { workspace = true }
|
||||
effectcraft-keyframe = { workspace = true }
|
||||
effectcraft-time = { workspace = true }
|
||||
effectcraft-render = { workspace = true }
|
||||
effectcraft-path = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
boa_engine = { workspace = true }
|
||||
|
||||
# On the web, boa's clock and Math.random need the browser (getrandom `wasm_js`, web-time).
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
boa_engine = { workspace = true, features = ["js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
effectcraft-expr = { workspace = true }
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
//! # effectcraft-script (L4)
|
||||
//!
|
||||
//! Scripting with an After Effects-style object model, on the boa JavaScript engine (the one the
|
||||
//! expressions use). Scripts written for After Effects' documented scripting API mostly run
|
||||
//! unchanged:
|
||||
//!
|
||||
//! ```text
|
||||
//! app.beginUndoGroup("Build");
|
||||
//! var comp = app.project.items.addComp("Main", 1920, 1080, 1, 10, 30);
|
||||
//! var solid = comp.layers.addSolid([1, 0, 0], "Red", 1920, 1080, 1);
|
||||
//! solid.property("ADBE Transform Group").property("ADBE Position").setValueAtTime(0, [0, 540]);
|
||||
//! solid.Effects.addProperty("ADBE Gaussian Blur 2").property("Blurriness").setValue(20);
|
||||
//! app.endUndoGroup();
|
||||
//! ```
|
||||
//!
|
||||
//! * **Object model** (`prelude.js`): `app` (project, open/newProject, undo groups,
|
||||
//! `executeCommand`/`findMenuCommandId`, `scheduleTask`), `Project`, `ItemCollection`,
|
||||
//! `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`, `AVLayer`/`TextLayer`/
|
||||
//! `ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property` (values, keyframes,
|
||||
//! eases, expressions), `MaskPropertyGroup`, `TextDocument`, `Shape`, `KeyframeEase`,
|
||||
//! `MarkerValue`, `RenderQueue`/`RenderQueueItem`/`OutputModule`, `ImportOptions`, `File`/
|
||||
//! `Folder`, `$`, `alert`/`writeLn`, and the enums (`BlendingMode`, `KeyframeInterpolationType`,
|
||||
//! `TrackMatteType`, `LightType`, `ParagraphJustification`, `PropertyValueType`…).
|
||||
//! * **Edits are engine commands**: every mutating call runs a command through
|
||||
//! [`Session::execute`](effectcraft_engine::Session::execute), so it is undoable, journaled
|
||||
//! and identical to the UI's action; `app.beginUndoGroup`/`endUndoGroup` fold everything in
|
||||
//! between into one undo step.
|
||||
//! * **Match names** ([`matchnames`]): After Effects' documented match names for our properties
|
||||
//! and effects (`ADBE Transform Group`, `ADBE Position`, `ADBE Gaussian Blur 2`…).
|
||||
//! * **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.
|
||||
//!
|
||||
//! 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
|
||||
//! panel, `effectcraft-cli script` and the MCP `run_script` tool use.
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
pub mod matchnames;
|
||||
mod model;
|
||||
mod runtime;
|
||||
|
||||
use effectcraft_engine::{ScriptRequest, Session};
|
||||
|
||||
pub use runtime::{Outcome, ScriptError, run};
|
||||
|
||||
/// 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.
|
||||
pub fn install(s: &mut Session) {
|
||||
s.script = Some(runner);
|
||||
}
|
||||
|
||||
/// Run `code` as a one-off script named `name`.
|
||||
pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome {
|
||||
run(s, &ScriptRequest { code, name, console: false })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,443 @@
|
|||
//! After Effects-compatible match names for our property tree and effects, so common scripts
|
||||
//! (`layer.property("ADBE Transform Group").property("ADBE Position")`,
|
||||
//! `layer.Effects.addProperty("ADBE Gaussian Blur 2")`) work unchanged.
|
||||
//!
|
||||
//! The names are the stable identifiers After Effects documents for scripting (Scripting Guide
|
||||
//! ▸ match names). Properties without a documented equivalent keep our own match id
|
||||
//! (`perChar3d`, `ec.blur.bilateral`), which `property()` and `addProperty()` accept too.
|
||||
|
||||
use effectcraft_project::{LayerSource, Node};
|
||||
|
||||
/// Match name of a layer (its root property group).
|
||||
pub fn layer(source: &LayerSource) -> &'static str {
|
||||
match source {
|
||||
LayerSource::Text => "ADBE Text Layer",
|
||||
LayerSource::Shape => "ADBE Vector Layer",
|
||||
LayerSource::Camera => "ADBE Camera Layer",
|
||||
LayerSource::Light { .. } => "ADBE Light Layer",
|
||||
_ => "ADBE AV Layer",
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level groups of a layer.
|
||||
const LAYER_GROUPS: &[(&str, &str)] = &[
|
||||
("transform", "ADBE Transform Group"),
|
||||
("masks", "ADBE Mask Parade"),
|
||||
("effects", "ADBE Effect Parade"),
|
||||
("text", "ADBE Text Properties"),
|
||||
("contents", "ADBE Root Vectors Group"),
|
||||
("materialOptions", "ADBE Material Options Group"),
|
||||
("cameraOptions", "ADBE Camera Options Group"),
|
||||
("lightOptions", "ADBE Light Options Group"),
|
||||
("audio", "ADBE Audio Group"),
|
||||
("layerStyles", "ADBE Layer Styles"),
|
||||
("motionTrackers", "ADBE MTrackers"),
|
||||
("timeRemap", "ADBE Time Remapping"),
|
||||
];
|
||||
|
||||
const TRANSFORM: &[(&str, &str)] = &[
|
||||
("anchor", "ADBE Anchor Point"),
|
||||
// Cameras and lights store the Point of Interest where AVLayers keep the Anchor Point.
|
||||
("poi", "ADBE Anchor Point"),
|
||||
("position", "ADBE Position"),
|
||||
("positionX", "ADBE Position_0"),
|
||||
("positionY", "ADBE Position_1"),
|
||||
("positionZ", "ADBE Position_2"),
|
||||
("scale", "ADBE Scale"),
|
||||
("orientation", "ADBE Orientation"),
|
||||
("rotationX", "ADBE Rotate X"),
|
||||
("rotationY", "ADBE Rotate Y"),
|
||||
("rotation", "ADBE Rotate Z"),
|
||||
("opacity", "ADBE Opacity"),
|
||||
];
|
||||
|
||||
const MASK: &[(&str, &str)] =
|
||||
&[("path", "ADBE Mask Shape"), ("feather", "ADBE Mask Feather"), ("opacity", "ADBE Mask Opacity"), ("expansion", "ADBE Mask Offset")];
|
||||
|
||||
const TEXT: &[(&str, &str)] = &[
|
||||
("sourceText", "ADBE Text Document"),
|
||||
("pathOptions", "ADBE Text Path Options"),
|
||||
("moreOptions", "ADBE Text More Options"),
|
||||
("animators", "ADBE Text Animators"),
|
||||
];
|
||||
|
||||
const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")];
|
||||
|
||||
const CAMERA: &[(&str, &str)] = &[
|
||||
("zoom", "ADBE Camera Zoom"),
|
||||
("dof", "ADBE Camera Depth of Field"),
|
||||
("focusDistance", "ADBE Camera Focus Distance"),
|
||||
("aperture", "ADBE Camera Aperture"),
|
||||
("blurLevel", "ADBE Camera Blur Level"),
|
||||
];
|
||||
|
||||
const LIGHT: &[(&str, &str)] = &[
|
||||
("intensity", "ADBE Light Intensity"),
|
||||
("color", "ADBE Light Color"),
|
||||
("coneAngle", "ADBE Light Cone Angle"),
|
||||
("coneFeather", "ADBE Light Cone Feather 2"),
|
||||
("falloff", "ADBE Light Falloff Type"),
|
||||
("radius", "ADBE Light Falloff Start"),
|
||||
("falloffDistance", "ADBE Light Falloff Distance"),
|
||||
("castsShadows", "ADBE Light Casts Shadows"),
|
||||
("shadowDarkness", "ADBE Light Shadow Darkness"),
|
||||
("shadowDiffusion", "ADBE Light Shadow Diffusion"),
|
||||
];
|
||||
|
||||
const MATERIAL: &[(&str, &str)] = &[
|
||||
("castsShadows", "ADBE Casts Shadows"),
|
||||
("lightTransmission", "ADBE Light Transmission"),
|
||||
("acceptsShadows", "ADBE Accepts Shadows"),
|
||||
("acceptsLights", "ADBE Accepts Lights"),
|
||||
("ambient", "ADBE Ambient Coefficient"),
|
||||
("diffuse", "ADBE Diffuse Coefficient"),
|
||||
("specularIntensity", "ADBE Specular Coefficient"),
|
||||
("specularShininess", "ADBE Shininess Coefficient"),
|
||||
("metal", "ADBE Metal Coefficient"),
|
||||
];
|
||||
|
||||
/// Shape items (Contents ▸ Add): our match id → AE match name.
|
||||
pub const SHAPE_ITEMS: &[(&str, &str)] = &[
|
||||
("group", "ADBE Vector Group"),
|
||||
("rect", "ADBE Vector Shape - Rect"),
|
||||
("ellipse", "ADBE Vector Shape - Ellipse"),
|
||||
("star", "ADBE Vector Shape - Star"),
|
||||
("path", "ADBE Vector Shape - Group"),
|
||||
("fill", "ADBE Vector Graphic - Fill"),
|
||||
("stroke", "ADBE Vector Graphic - Stroke"),
|
||||
("gfill", "ADBE Vector Graphic - G-Fill"),
|
||||
("gstroke", "ADBE Vector Graphic - G-Stroke"),
|
||||
("merge", "ADBE Vector Filter - Merge"),
|
||||
("offset", "ADBE Vector Filter - Offset"),
|
||||
("pucker", "ADBE Vector Filter - PB"),
|
||||
("repeater", "ADBE Vector Filter - Repeater"),
|
||||
("round", "ADBE Vector Filter - RC"),
|
||||
("trim", "ADBE Vector Filter - Trim"),
|
||||
("twist", "ADBE Vector Filter - Twist"),
|
||||
("wiggle", "ADBE Vector Filter - Roughen"),
|
||||
("zigzag", "ADBE Vector Filter - Zigzag"),
|
||||
];
|
||||
|
||||
/// Properties inside shape items: (item match id, property match id, AE match name).
|
||||
const SHAPE_PROPS: &[(&str, &str, &str)] = &[
|
||||
("group", "blend", "ADBE Vector Blend Mode"),
|
||||
("group", "contents", "ADBE Vectors Group"),
|
||||
("group", "transform", "ADBE Vector Transform Group"),
|
||||
("transform", "anchor", "ADBE Vector Anchor"),
|
||||
("transform", "position", "ADBE Vector Position"),
|
||||
("transform", "scale", "ADBE Vector Scale"),
|
||||
("transform", "skew", "ADBE Vector Skew"),
|
||||
("transform", "skewAxis", "ADBE Vector Skew Axis"),
|
||||
("transform", "rotation", "ADBE Vector Rotation"),
|
||||
("transform", "opacity", "ADBE Vector Group Opacity"),
|
||||
("rect", "direction", "ADBE Vector Shape Direction"),
|
||||
("rect", "size", "ADBE Vector Rect Size"),
|
||||
("rect", "position", "ADBE Vector Rect Position"),
|
||||
("rect", "roundness", "ADBE Vector Rect Roundness"),
|
||||
("ellipse", "direction", "ADBE Vector Shape Direction"),
|
||||
("ellipse", "size", "ADBE Vector Ellipse Size"),
|
||||
("ellipse", "position", "ADBE Vector Ellipse Position"),
|
||||
("star", "direction", "ADBE Vector Shape Direction"),
|
||||
("star", "type", "ADBE Vector Star Type"),
|
||||
("star", "points", "ADBE Vector Star Points"),
|
||||
("star", "position", "ADBE Vector Star Position"),
|
||||
("star", "rotation", "ADBE Vector Star Rotation"),
|
||||
("star", "innerRadius", "ADBE Vector Star Inner Radius"),
|
||||
("star", "outerRadius", "ADBE Vector Star Outer Radius"),
|
||||
("star", "innerRoundness", "ADBE Vector Star Inner Roundess"),
|
||||
("star", "outerRoundness", "ADBE Vector Star Outer Roundess"),
|
||||
("path", "direction", "ADBE Vector Shape Direction"),
|
||||
("path", "path", "ADBE Vector Shape"),
|
||||
("fill", "blend", "ADBE Vector Blend Mode"),
|
||||
("fill", "composite", "ADBE Vector Composite Order"),
|
||||
("fill", "rule", "ADBE Vector Fill Rule"),
|
||||
("fill", "color", "ADBE Vector Fill Color"),
|
||||
("fill", "opacity", "ADBE Vector Fill Opacity"),
|
||||
("stroke", "blend", "ADBE Vector Blend Mode"),
|
||||
("stroke", "composite", "ADBE Vector Composite Order"),
|
||||
("stroke", "color", "ADBE Vector Stroke Color"),
|
||||
("stroke", "opacity", "ADBE Vector Stroke Opacity"),
|
||||
("stroke", "width", "ADBE Vector Stroke Width"),
|
||||
("stroke", "cap", "ADBE Vector Stroke Line Cap"),
|
||||
("stroke", "join", "ADBE Vector Stroke Line Join"),
|
||||
("stroke", "miter", "ADBE Vector Stroke Miter Limit"),
|
||||
("stroke", "dashes", "ADBE Vector Stroke Dashes"),
|
||||
("trim", "start", "ADBE Vector Trim Start"),
|
||||
("trim", "end", "ADBE Vector Trim End"),
|
||||
("trim", "offset", "ADBE Vector Trim Offset"),
|
||||
("trim", "mode", "ADBE Vector Trim Type"),
|
||||
("repeater", "copies", "ADBE Vector Repeater Copies"),
|
||||
("repeater", "offset", "ADBE Vector Repeater Offset"),
|
||||
("repeater", "composite", "ADBE Vector Repeater Order"),
|
||||
("repeater", "transform", "ADBE Vector Repeater Transform"),
|
||||
("round", "radius", "ADBE Vector RoundCorner Radius"),
|
||||
("pucker", "amount", "ADBE Vector PuckerBloat Amount"),
|
||||
("twist", "angle", "ADBE Vector Twist Angle"),
|
||||
("twist", "center", "ADBE Vector Twist Center"),
|
||||
("merge", "mode", "ADBE Vector Merge Type"),
|
||||
];
|
||||
|
||||
/// Effects: our effect id → AE match name (the documented ones; the rest keep our id).
|
||||
pub const EFFECTS: &[(&str, &str)] = &[
|
||||
("ec.blur.gaussian", "ADBE Gaussian Blur 2"),
|
||||
("ec.blur.fastbox", "ADBE Box Blur2"),
|
||||
("ec.blur.directional", "ADBE Motion Blur"),
|
||||
("ec.blur.radial", "ADBE Radial Blur"),
|
||||
("ec.blur.cameralens", "ADBE Camera Lens Blur"),
|
||||
("ec.blur.channel", "ADBE Channel Blur"),
|
||||
("ec.blur.compound", "ADBE Compound Blur"),
|
||||
("ec.blur.sharpen", "ADBE Sharpen"),
|
||||
("ec.blur.smart", "ADBE Smart Blur"),
|
||||
("ec.blur.unsharp", "ADBE Unsharp Mask2"),
|
||||
("ec.channel.invert", "ADBE Invert"),
|
||||
("ec.channel.setmatte", "ADBE Set Matte3"),
|
||||
("ec.channel.setchannels", "ADBE Set Channels"),
|
||||
("ec.channel.shiftchannels", "ADBE Shift Channels"),
|
||||
("ec.channel.minimax", "ADBE Minimax"),
|
||||
("ec.channel.blend", "ADBE Blend"),
|
||||
("ec.channel.arithmetic", "ADBE Arithmetic"),
|
||||
("ec.channel.calculations", "ADBE Calculations"),
|
||||
("ec.channel.solidcomposite", "ADBE Solid Composite"),
|
||||
("ec.color.brightnesscontrast", "ADBE Brightness & Contrast 2"),
|
||||
("ec.color.curves", "ADBE CurvesCustom"),
|
||||
("ec.color.levels", "ADBE Easy Levels2"),
|
||||
("ec.color.levelsic", "ADBE Pro Levels2"),
|
||||
("ec.color.huesaturation", "ADBE HUE SATURATION"),
|
||||
("ec.color.exposure", "ADBE Exposure2"),
|
||||
("ec.color.tint", "ADBE Tint"),
|
||||
("ec.color.tritone", "ADBE Tritone"),
|
||||
("ec.color.blackwhite", "ADBE Black&White"),
|
||||
("ec.color.channelmixer", "ADBE CHANNEL MIXER"),
|
||||
("ec.color.colorbalance", "ADBE Color Balance 2"),
|
||||
("ec.color.colorbalancehls", "ADBE Color Balance (HLS)"),
|
||||
("ec.color.changecolor", "ADBE Change Color"),
|
||||
("ec.color.changetocolor", "ADBE Change To Color"),
|
||||
("ec.color.colorama", "APC Colorama"),
|
||||
("ec.color.leavecolor", "ADBE Leave Color"),
|
||||
("ec.color.photofilter", "ADBE Photo Filter"),
|
||||
("ec.color.vibrance", "ADBE Vibrance"),
|
||||
("ec.color.lumetri", "ADBE Lumetri"),
|
||||
("ec.color.shadowhighlight", "ADBE Shadow/Highlight"),
|
||||
("ec.color.selectivecolor", "ADBE Selective Color"),
|
||||
("ec.color.gammapedestalgain", "ADBE Gamma/Pedestal/Gain"),
|
||||
("ec.color.equalize", "ADBE Equalize"),
|
||||
("ec.color.autocontrast", "ADBE AutoContrast"),
|
||||
("ec.color.autolevels", "ADBE AutoLevels"),
|
||||
("ec.color.autocolor", "ADBE AutoColor"),
|
||||
("ec.control.slider", "ADBE Slider Control"),
|
||||
("ec.control.angle", "ADBE Angle Control"),
|
||||
("ec.control.checkbox", "ADBE Checkbox Control"),
|
||||
("ec.control.color", "ADBE Color Control"),
|
||||
("ec.control.point", "ADBE Point Control"),
|
||||
("ec.control.point3d", "ADBE Point3D Control"),
|
||||
("ec.control.layer", "ADBE Layer Control"),
|
||||
("ec.control.dropdown", "ADBE Dropdown Control"),
|
||||
("ec.distort.transform", "ADBE Geometry2"),
|
||||
("ec.distort.cornerpin", "ADBE Corner Pin"),
|
||||
("ec.distort.bulge", "ADBE Bulge"),
|
||||
("ec.distort.mirror", "ADBE Mirror"),
|
||||
("ec.distort.offset", "ADBE Offset"),
|
||||
("ec.distort.ripple", "ADBE Ripple"),
|
||||
("ec.distort.spherize", "ADBE Spherize"),
|
||||
("ec.distort.twirl", "ADBE Twirl"),
|
||||
("ec.distort.wavewarp", "ADBE Wave Warp"),
|
||||
("ec.distort.turbulentdisplace", "ADBE Turbulent Displace"),
|
||||
("ec.distort.displacementmap", "ADBE Displacement Map"),
|
||||
("ec.distort.meshwarp", "ADBE MESH WARP"),
|
||||
("ec.distort.polar", "ADBE Polar Coordinates"),
|
||||
("ec.distort.opticscompensation", "ADBE Optics Compensation"),
|
||||
("ec.distort.magnify", "ADBE Magnify"),
|
||||
("ec.distort.warpstabilizer", "ADBE SubspaceStabilizer"),
|
||||
("ec.distort.puppet", "ADBE FreePin3"),
|
||||
("ec.generate.fill", "ADBE Fill"),
|
||||
("ec.generate.gradientramp", "ADBE Ramp"),
|
||||
("ec.generate.fourcolor", "ADBE 4ColorGradient"),
|
||||
("ec.generate.stroke", "ADBE Stroke"),
|
||||
("ec.generate.grid", "ADBE Grid"),
|
||||
("ec.generate.checkerboard", "ADBE Checkerboard"),
|
||||
("ec.generate.circle", "ADBE Circle"),
|
||||
("ec.generate.ellipse", "ADBE Ellipse"),
|
||||
("ec.generate.cellpattern", "ADBE Cell Pattern"),
|
||||
("ec.generate.lensflare", "ADBE Lens Flare"),
|
||||
("ec.generate.vegas", "ADBE Vegas"),
|
||||
("ec.generate.writeon", "ADBE Write On"),
|
||||
("ec.generate.audiospectrum", "ADBE AudSpect"),
|
||||
("ec.generate.audiowaveform", "ADBE AudWave"),
|
||||
("ec.generate.beam", "ADBE Laser"),
|
||||
("ec.generate.radiowaves", "ADBE Radio Waves"),
|
||||
("ec.generate.scribble", "ADBE Scribble Fill"),
|
||||
("ec.generate.paintbucket", "ADBE Paint Bucket"),
|
||||
("ec.key.colorkey", "ADBE Color Key"),
|
||||
("ec.key.luma", "ADBE Luma Key"),
|
||||
("ec.key.linearcolor", "ADBE Linear Color Key2"),
|
||||
("ec.key.colorrange", "ADBE Color Range"),
|
||||
("ec.key.colordifference", "ADBE Color Difference Key"),
|
||||
("ec.key.extract", "ADBE Extract"),
|
||||
("ec.key.spill", "ADBE Spill2"),
|
||||
("ec.matte.simplechoker", "ADBE Simple Choker"),
|
||||
("ec.matte.mattechoker", "ADBE Matte Choker"),
|
||||
("ec.noise.noise", "ADBE Noise"),
|
||||
("ec.noise.fractal", "ADBE Fractal Noise"),
|
||||
("ec.noise.median", "ADBE Median"),
|
||||
("ec.noise.addgrain", "VISINF Grain Implant"),
|
||||
("ec.noise.dustscratches", "ADBE Dust & Scratches"),
|
||||
("ec.noise.turbulent", "ADBE Turbulent Noise"),
|
||||
("ec.perspective.dropshadow", "ADBE Drop Shadow"),
|
||||
("ec.perspective.radialshadow", "ADBE Radial Shadow"),
|
||||
("ec.perspective.bevelalpha", "ADBE Bevel Alpha"),
|
||||
("ec.perspective.beveledges", "ADBE Bevel Edges"),
|
||||
("ec.perspective.3dglasses", "ADBE 3D Glasses2"),
|
||||
("ec.stylize.glow", "ADBE Glo2"),
|
||||
("ec.stylize.mosaic", "ADBE Mosaic"),
|
||||
("ec.stylize.posterize", "ADBE Posterize"),
|
||||
("ec.stylize.threshold", "ADBE Threshold2"),
|
||||
("ec.stylize.findedges", "ADBE Find Edges"),
|
||||
("ec.stylize.emboss", "ADBE Emboss"),
|
||||
("ec.stylize.coloremboss", "ADBE Color Emboss"),
|
||||
("ec.stylize.roughenedges", "ADBE Roughen Edges"),
|
||||
("ec.stylize.scatter", "ADBE Scatter"),
|
||||
("ec.stylize.strobe", "ADBE Strobe"),
|
||||
("ec.stylize.motiontile", "ADBE Tile"),
|
||||
("ec.stylize.texturize", "ADBE Texturize"),
|
||||
("ec.stylize.cartoon", "ADBE Cartoonify"),
|
||||
("ec.stylize.brushstrokes", "ADBE Brush Strokes"),
|
||||
("ec.text.numbers", "ADBE Numbers2"),
|
||||
("ec.text.timecode", "ADBE Timecode"),
|
||||
("ec.time.echo", "ADBE Echo"),
|
||||
("ec.time.posterizetime", "ADBE Posterize Time"),
|
||||
("ec.time.timedisplacement", "ADBE Time Displacement"),
|
||||
("ec.time.timedifference", "ADBE Time Difference"),
|
||||
("ec.time.pixelmotionblur", "ADBE Pixel Motion Blur"),
|
||||
("ec.time.timewarp", "ADBE Timewarp"),
|
||||
("ec.transition.linearwipe", "ADBE Linear Wipe"),
|
||||
("ec.transition.radialwipe", "ADBE Radial Wipe"),
|
||||
("ec.transition.venetian", "ADBE Venetian Blinds"),
|
||||
("ec.transition.blockdissolve", "ADBE Block Dissolve"),
|
||||
("ec.transition.gradientwipe", "ADBE Gradient Wipe"),
|
||||
("ec.transition.iriswipe", "ADBE Iris Wipe"),
|
||||
("ec.utility.applylut", "ADBE Apply Color LUT2"),
|
||||
("ec.utility.cineon", "ADBE Cineon Converter2"),
|
||||
("ec.utility.growbounds", "ADBE Grow Bounds"),
|
||||
];
|
||||
|
||||
fn find<'a>(t: &'a [(&str, &'a str)], k: &str) -> Option<&'a str> {
|
||||
t.iter().find(|(a, _)| *a == k).map(|(_, b)| *b)
|
||||
}
|
||||
|
||||
/// AE match name of an effect id (falls back to the id).
|
||||
pub fn effect(id: &str) -> String {
|
||||
find(EFFECTS, id).map(str::to_string).unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// Our effect id for an AE effect match name.
|
||||
pub fn effect_from_match(m: &str) -> Option<&'static str> {
|
||||
EFFECTS.iter().find(|(_, b)| b.eq_ignore_ascii_case(m)).map(|(a, _)| *a)
|
||||
}
|
||||
|
||||
/// Our shape item kind (`layer.addShapeItem`) for an AE match name, our match id or a display
|
||||
/// name (`Rectangle`, `Fill`, `Trim Paths`).
|
||||
pub fn shape_kind(name: &str) -> Option<&'static str> {
|
||||
if let Some((k, _)) = SHAPE_ITEMS.iter().find(|(k, m)| m.eq_ignore_ascii_case(name) || k.eq_ignore_ascii_case(name)) {
|
||||
return Some(k);
|
||||
}
|
||||
let n: String = name.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
Some(match n.as_str() {
|
||||
"rectangle" | "rectanglepath" => "rect",
|
||||
"ellipsepath" => "ellipse",
|
||||
"polystar" | "polystarpath" => "star",
|
||||
"polygon" => "polygon",
|
||||
"group" | "vectorgroup" => "group",
|
||||
"fill" => "fill",
|
||||
"stroke" => "stroke",
|
||||
"gradientfill" => "gfill",
|
||||
"gradientstroke" => "gstroke",
|
||||
"trimpaths" | "trim" => "trim",
|
||||
"repeater" => "repeater",
|
||||
"roundcorners" => "round",
|
||||
"offsetpaths" => "offset",
|
||||
"puckerbloat" | "puckerandbloat" => "pucker",
|
||||
"twist" => "twist",
|
||||
"zigzag" => "zigzag",
|
||||
"wigglepaths" => "wiggle",
|
||||
"mergepaths" => "merge",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Match name of the node at the end of `chain` (layer root's children → … → node). `effect`
|
||||
/// params are numbered `<effect match name>-0001…` in parameter order, like After Effects.
|
||||
pub fn node(chain: &[&Node]) -> String {
|
||||
let Some(last) = chain.last() else { return String::new() };
|
||||
let id = last.match_id();
|
||||
let parent = chain.len().checked_sub(2).map(|i| chain[i]);
|
||||
let grand = chain.len().checked_sub(3).map(|i| chain[i]);
|
||||
let top = chain.first().map(|n| n.match_id()).unwrap_or("");
|
||||
let own = || id.to_string();
|
||||
if chain.len() == 1 {
|
||||
return find(LAYER_GROUPS, id).map(str::to_string).unwrap_or_else(own);
|
||||
}
|
||||
let pid = parent.map(|p| p.match_id()).unwrap_or("");
|
||||
match top {
|
||||
"transform" if chain.len() == 2 => find(TRANSFORM, id).map(str::to_string).unwrap_or_else(own),
|
||||
"masks" if chain.len() == 2 => "ADBE Mask Atom".into(),
|
||||
"masks" if chain.len() == 3 => find(MASK, id).map(str::to_string).unwrap_or_else(own),
|
||||
"effects" if chain.len() == 2 => effect(id),
|
||||
"effects" if chain.len() == 3 => {
|
||||
let fx = parent.and_then(Node::as_group);
|
||||
match fx {
|
||||
Some(g) => {
|
||||
let i = g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1;
|
||||
format!("{}-{i:04}", effect(&g.match_id))
|
||||
}
|
||||
None => own(),
|
||||
}
|
||||
}
|
||||
"text" if chain.len() == 2 => find(TEXT, id).map(str::to_string).unwrap_or_else(own),
|
||||
"text" if pid == "animators" => "ADBE Text Animator".into(),
|
||||
"text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own),
|
||||
"cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own),
|
||||
"lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own),
|
||||
"materialOptions" => find(MATERIAL, id).map(str::to_string).unwrap_or_else(own),
|
||||
"audio" if id == "levels" => "ADBE Audio Levels".into(),
|
||||
"contents" => {
|
||||
// Items sit in a `contents` list; their properties under the item.
|
||||
if pid == "contents" {
|
||||
if id == "star" || id == "polygon" {
|
||||
return "ADBE Vector Shape - Star".into();
|
||||
}
|
||||
return find(SHAPE_ITEMS, id).map(str::to_string).unwrap_or_else(own);
|
||||
}
|
||||
if let Some((_, _, m)) = SHAPE_PROPS.iter().find(|(item, prop, _)| *item == pid && *prop == id) {
|
||||
return m.to_string();
|
||||
}
|
||||
own()
|
||||
}
|
||||
_ => own(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn effect_names_round_trip() {
|
||||
for (id, m) in EFFECTS {
|
||||
assert_eq!(effect(id), *m);
|
||||
assert_eq!(effect_from_match(m), Some(*id));
|
||||
}
|
||||
assert_eq!(effect("ec.blur.ccvector"), "ec.blur.ccvector");
|
||||
let mut ids: Vec<&str> = EFFECTS.iter().map(|(a, _)| *a).collect();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), EFFECTS.len(), "duplicate effect ids");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_kinds() {
|
||||
assert_eq!(shape_kind("ADBE Vector Shape - Rect"), Some("rect"));
|
||||
assert_eq!(shape_kind("Trim Paths"), Some("trim"));
|
||||
assert_eq!(shape_kind("ADBE Vector Graphic - Fill"), Some("fill"));
|
||||
assert_eq!(shape_kind("nope"), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,679 @@
|
|||
//! Read-only queries behind the object model (`__query(kind, json)`): project, items, layers,
|
||||
//! property nodes, values and keyframes as JSON shaped for the prelude. Every edit goes through
|
||||
//! engine commands instead (`__exec`), so it is undoable and journaled.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::color::Label;
|
||||
use effectcraft_keyframe::{Interp, TextDoc, Value as KV};
|
||||
use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, PropGroup};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value as J, json};
|
||||
|
||||
use crate::matchnames;
|
||||
|
||||
type R = Result<J, String>;
|
||||
|
||||
fn u(a: &J, k: &str) -> Option<u64> {
|
||||
a.get(k).and_then(J::as_u64)
|
||||
}
|
||||
fn f(a: &J, k: &str) -> Option<f64> {
|
||||
a.get(k).and_then(J::as_f64)
|
||||
}
|
||||
|
||||
fn comp_of<'a>(s: &'a Session, a: &J) -> Result<(ItemId, &'a Comp), String> {
|
||||
let id = ItemId(u(a, "comp").ok_or("missing comp")?);
|
||||
s.project.comp(id).map(|c| (id, c)).ok_or_else(|| "the composition no longer exists".to_string())
|
||||
}
|
||||
|
||||
fn layer_of<'a>(s: &'a Session, a: &J) -> Result<(ItemId, &'a Comp, &'a Layer), String> {
|
||||
let (cid, c) = comp_of(s, a)?;
|
||||
let l = c.layer(LayerId(u(a, "layer").ok_or("missing layer")?)).ok_or("the layer no longer exists (it was deleted)")?;
|
||||
Ok((cid, c, l))
|
||||
}
|
||||
|
||||
pub fn query(s: &mut Session, kind: &str, a: &J) -> R {
|
||||
match kind {
|
||||
"project" => Ok(project(s)),
|
||||
"item" => item(s, a),
|
||||
"layer" => layer(s, a),
|
||||
"node" => node(s, a),
|
||||
"child" => child(s, a),
|
||||
"value" => value(s, a),
|
||||
"keys" => keys(s, a),
|
||||
"textdoc" => textdoc(s, a),
|
||||
"markers" => markers(s, a),
|
||||
"sourceRect" => source_rect(s, a),
|
||||
"resolveAdd" => resolve_add(s, a),
|
||||
"menuCommandId" => Ok(menu_command_id(a.get("name").and_then(J::as_str).unwrap_or(""))),
|
||||
"commandAt" => {
|
||||
let i = u(a, "index").unwrap_or(0) as usize;
|
||||
Ok(effectcraft_engine::command_specs().get(i.wrapping_sub(1)).map(|c| json!(c.id)).unwrap_or(J::Null))
|
||||
}
|
||||
"exprError" => {
|
||||
let code = a.get("code").and_then(J::as_str).unwrap_or("");
|
||||
Ok(match s.expr_check {
|
||||
Some(check) => json!(check(code).err().unwrap_or_default()),
|
||||
None => json!(""),
|
||||
})
|
||||
}
|
||||
"labels" => Ok(json!(Label::ALL.iter().map(|l| l.name()).collect::<Vec<_>>())),
|
||||
"effects" => Ok(json!(
|
||||
effectcraft_engine::effects::registry()
|
||||
.iter()
|
||||
.map(|e| json!({"id": e.id, "name": e.name, "matchName": matchnames::effect(e.id), "category": e.category}))
|
||||
.collect::<Vec<_>>()
|
||||
)),
|
||||
_ => Err(format!("unknown query `{kind}`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn project(s: &Session) -> J {
|
||||
let p = &s.project;
|
||||
let items: Vec<u64> = p.items.keys().map(|k| k.0).collect();
|
||||
let active = s.active_comp_id().map(|c| c.0).or_else(|| (s.state.project_selection.len() == 1).then(|| s.state.project_selection[0].0));
|
||||
json!({
|
||||
"items": items,
|
||||
"active": active,
|
||||
"selection": s.state.project_selection.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"path": s.path,
|
||||
"dirty": s.is_dirty(),
|
||||
"bitsPerChannel": p.settings.bit_depth.bits(),
|
||||
"linearBlending": p.settings.blend_linear,
|
||||
"linearizeWorkingSpace": p.settings.linearize,
|
||||
"workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(),
|
||||
"timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames,
|
||||
"renderQueue": p.render_queue.len(),
|
||||
"revision": s.revision,
|
||||
})
|
||||
}
|
||||
|
||||
fn label_index(l: Label) -> usize {
|
||||
Label::ALL.iter().position(|x| *x == l).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn item(s: &Session, a: &J) -> R {
|
||||
let id = ItemId(u(a, "id").ok_or("missing id")?);
|
||||
let it = s.project.item(id).ok_or("the item no longer exists (it was deleted)")?;
|
||||
let mut o = json!({
|
||||
"id": id.0,
|
||||
"name": it.name,
|
||||
"typeName": it.type_name(),
|
||||
"parent": it.parent.map(|p| p.0).unwrap_or(0),
|
||||
"comment": it.comment,
|
||||
"label": label_index(it.label),
|
||||
"selected": s.state.project_selection.contains(&id),
|
||||
});
|
||||
let used_in: Vec<u64> = s.project.comps().filter(|(_, c)| c.layers.iter().any(|l| l.source.item() == Some(id))).map(|(cid, _)| cid.0).collect();
|
||||
o["usedIn"] = json!(used_in);
|
||||
match &it.kind {
|
||||
ItemKind::Folder => {
|
||||
o["kind"] = json!("folder");
|
||||
o["children"] = json!(s.project.items.values().filter(|c| c.parent == Some(id)).map(|c| c.id.0).collect::<Vec<_>>());
|
||||
}
|
||||
ItemKind::Comp(c) => {
|
||||
let cid = id;
|
||||
o["kind"] = json!("comp");
|
||||
let sel: Vec<u64> = s.state.selected_layers.iter().filter(|l| c.layer(**l).is_some()).map(|l| l.0).collect();
|
||||
let fps = c.frame_rate.as_f64();
|
||||
if let Some(m) = o.as_object_mut() {
|
||||
m.extend(
|
||||
json!({
|
||||
"width": c.width, "height": c.height, "pixelAspect": c.pixel_aspect,
|
||||
"duration": c.duration.seconds(), "frameRate": fps, "frameDuration": c.frame_duration().seconds(),
|
||||
"layers": c.layers.iter().map(|l| l.id.0).collect::<Vec<_>>(),
|
||||
"selectedLayers": sel,
|
||||
"selectedProperties": s.state.selected_props.iter().filter(|(l, _)| c.layer(*l).is_some()).map(|(l, p)| json!([l.0, p])).collect::<Vec<_>>(),
|
||||
"time": s.time_of(cid).seconds(),
|
||||
"workAreaStart": c.work_area.0.seconds(), "workAreaDuration": (c.work_area.1 - c.work_area.0).seconds(),
|
||||
"bgColor": c.background, "displayStartTime": c.display_start.seconds(),
|
||||
"hideShyLayers": c.hide_shy, "motionBlur": c.enable_motion_blur, "frameBlending": c.enable_frame_blending,
|
||||
"draft3d": c.draft_3d, "shutterAngle": c.shutter_angle, "shutterPhase": c.shutter_phase,
|
||||
"motionBlurSamplesPerFrame": c.motion_blur_samples,
|
||||
"renderer": match c.renderer { effectcraft_project::Renderer::Classic3D => "ADBE Classic 3D", effectcraft_project::Renderer::Advanced3D => "ADBE Advanced 3d" },
|
||||
"open": s.state.open_comps.contains(&cid), "active": s.active_comp_id() == Some(cid),
|
||||
"hasVideo": true, "hasAudio": c.layers.iter().any(|l| l.switches.audio && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. })),
|
||||
"posterTime": c.poster_time.seconds(),
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
ItemKind::Footage(fo) => {
|
||||
o["kind"] = json!("footage");
|
||||
o["file"] = json!(fo.path);
|
||||
o["width"] = json!(fo.width);
|
||||
o["height"] = json!(fo.height);
|
||||
o["pixelAspect"] = json!(fo.pixel_aspect);
|
||||
o["duration"] = json!(if fo.kind == effectcraft_project::FootageKind::Still { 0.0 } else { fo.duration.seconds() });
|
||||
o["frameRate"] = json!(if fo.kind == effectcraft_project::FootageKind::Still { 0.0 } else { fo.frame_rate.as_f64() });
|
||||
o["hasVideo"] = json!(fo.has_video);
|
||||
o["hasAudio"] = json!(fo.has_audio);
|
||||
o["footageMissing"] = json!(fo.missing);
|
||||
o["isStill"] = json!(fo.kind == effectcraft_project::FootageKind::Still);
|
||||
o["loop"] = json!(fo.loop_count);
|
||||
}
|
||||
ItemKind::Solid(so) => {
|
||||
o["kind"] = json!("solid");
|
||||
o["width"] = json!(so.width);
|
||||
o["height"] = json!(so.height);
|
||||
o["pixelAspect"] = json!(so.pixel_aspect);
|
||||
o["color"] = json!(so.color);
|
||||
o["duration"] = json!(0.0);
|
||||
o["frameRate"] = json!(0.0);
|
||||
o["hasVideo"] = json!(true);
|
||||
o["hasAudio"] = json!(false);
|
||||
o["isStill"] = json!(true);
|
||||
}
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
fn layer(s: &Session, a: &J) -> R {
|
||||
let (cid, c, l) = layer_of(s, a)?;
|
||||
let kind = match &l.source {
|
||||
LayerSource::Text => "text",
|
||||
LayerSource::Shape => "shape",
|
||||
LayerSource::Camera => "camera",
|
||||
LayerSource::Light { .. } => "light",
|
||||
_ => "av",
|
||||
};
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, l);
|
||||
let t = s.time_of(cid);
|
||||
Ok(json!({
|
||||
"id": l.id.0,
|
||||
"comp": cid.0,
|
||||
"kind": kind,
|
||||
"name": l.name,
|
||||
"index": c.index_of(l.id).unwrap_or(0),
|
||||
"matchName": matchnames::layer(&l.source),
|
||||
"root": l.props.uid,
|
||||
"source": l.source.item().map(|i| i.0),
|
||||
"nullLayer": matches!(l.source, LayerSource::Null),
|
||||
"lightType": match &l.source { LayerSource::Light { kind } => kind.label(), _ => "" },
|
||||
"enabled": l.switches.video,
|
||||
"audioEnabled": l.switches.audio,
|
||||
"solo": l.switches.solo,
|
||||
"locked": l.switches.locked,
|
||||
"shy": l.switches.shy,
|
||||
"collapse": l.switches.collapse,
|
||||
"quality": format!("{:?}", l.switches.quality),
|
||||
"samplingQuality": format!("{:?}", l.switches.sampling),
|
||||
"effectsActive": l.switches.effects,
|
||||
"frameBlending": format!("{:?}", l.switches.frame_blend),
|
||||
"motionBlur": l.switches.motion_blur,
|
||||
"adjustmentLayer": l.switches.adjustment,
|
||||
"threeDLayer": l.switches.three_d,
|
||||
"guideLayer": l.switches.guide,
|
||||
"preserveTransparency": l.preserve_transparency,
|
||||
"inPoint": l.in_point.seconds(),
|
||||
"outPoint": l.out_point.seconds(),
|
||||
"startTime": l.start_time.seconds(),
|
||||
"stretch": l.stretch,
|
||||
"time": l.layer_time(t).seconds(),
|
||||
"active": l.switches.video && l.is_active_at(t),
|
||||
"parent": l.parent.map(|p| p.0),
|
||||
"blendingMode": l.blend_mode.label(),
|
||||
"trackMatte": l.track_matte.map(|m| json!({"layer": m.layer.0, "kind": format!("{:?}", m.kind)})),
|
||||
"isTrackMatte": c.layers.iter().any(|x| x.track_matte.is_some_and(|m| m.layer == l.id)),
|
||||
"label": label_index(l.label),
|
||||
"comment": l.comment,
|
||||
"selected": s.state.selected_layers.contains(&l.id),
|
||||
"autoOrient": format!("{:?}", l.auto_orient),
|
||||
"width": w, "height": h,
|
||||
"hasVideo": l.source.is_av(),
|
||||
"hasAudio": matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }) && l.props.sub("audio").is_some(),
|
||||
"timeRemapEnabled": l.props.prop("timeRemap").is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Node at the end of a uid chain below the layer root, and the chain itself.
|
||||
fn chain(l: &Layer, uid: u64) -> Option<Vec<&Node>> {
|
||||
l.props.node_chain(uid)
|
||||
}
|
||||
|
||||
/// PropertyValueType of a property.
|
||||
fn value_type(p: &effectcraft_project::Property) -> &'static str {
|
||||
if matches!(p.ui, ParamUi::Mask) {
|
||||
return "MASK_INDEX";
|
||||
}
|
||||
match &p.value {
|
||||
KV::Scalar(_) | KV::Bool(_) | KV::Enum(_) => "OneD",
|
||||
KV::Vec2(_) => {
|
||||
if p.spatial {
|
||||
"TwoD_SPATIAL"
|
||||
} else {
|
||||
"TwoD"
|
||||
}
|
||||
}
|
||||
KV::Vec3(_) => {
|
||||
if p.spatial {
|
||||
"ThreeD_SPATIAL"
|
||||
} else {
|
||||
"ThreeD"
|
||||
}
|
||||
}
|
||||
KV::Color(_) => "COLOR",
|
||||
KV::Path(_) => "SHAPE",
|
||||
KV::Text(_) => "TEXT_DOCUMENT",
|
||||
KV::Layer(_) => "LAYER_INDEX",
|
||||
KV::Gradient(_) | KV::Str(_) => "CUSTOM_VALUE",
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups whose children are instances (effects, masks, shape items, animators).
|
||||
fn indexed_parade(g: &PropGroup) -> bool {
|
||||
matches!(g.match_id.as_str(), "effects" | "masks" | "contents" | "animators" | "selectors" | "layerStyles" | "motionTrackers")
|
||||
}
|
||||
|
||||
fn node_json(c: &Comp, l: &Layer, uid: u64) -> R {
|
||||
if uid == 0 || uid == l.props.uid {
|
||||
let n = l.props.children.len();
|
||||
return Ok(json!({
|
||||
"uid": l.props.uid, "kind": "group", "name": l.name, "matchName": matchnames::layer(&l.source), "matchId": "layer",
|
||||
"index": c.index_of(l.id).unwrap_or(0), "depth": 0, "parent": null, "isLayer": true, "numProperties": n,
|
||||
"children": l.props.children.iter().map(Node::uid).collect::<Vec<_>>(), "propertyType": "NAMED_GROUP",
|
||||
"enabled": l.switches.video, "canSetEnabled": true,
|
||||
}));
|
||||
}
|
||||
let ch = chain(l, uid).ok_or("the property no longer exists (it was deleted)")?;
|
||||
let node = *ch.last().ok_or("bad property")?;
|
||||
let parent_group: &PropGroup = if ch.len() >= 2 { ch[ch.len() - 2].as_group().ok_or("bad tree")? } else { &l.props };
|
||||
let index = parent_group.children.iter().position(|x| x.uid() == uid).map(|i| i + 1).unwrap_or(0);
|
||||
let parent_uid = if ch.len() >= 2 { ch[ch.len() - 2].uid() } else { l.props.uid };
|
||||
let match_name = matchnames::node(&ch);
|
||||
let instance_parent = indexed_parade(parent_group) && ch.len() >= 2 || (ch.len() == 2 && ch[0].match_id() == "contents");
|
||||
let mut o = json!({
|
||||
"uid": uid, "name": node.name(), "matchName": match_name, "matchId": node.match_id(), "index": index,
|
||||
"depth": ch.len(), "parent": parent_uid, "isLayer": false,
|
||||
"isEffect": matches!(node.as_group().map(|g| &g.kind), Some(GroupKind::Effect { .. })),
|
||||
"isMask": matches!(node.as_group().map(|g| &g.kind), Some(GroupKind::Mask { .. })),
|
||||
});
|
||||
match node {
|
||||
Node::Group(g) => {
|
||||
let instance = matches!(g.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. });
|
||||
o["kind"] = json!("group");
|
||||
o["numProperties"] = json!(g.children.len());
|
||||
o["children"] = json!(g.children.iter().map(Node::uid).collect::<Vec<_>>());
|
||||
o["propertyType"] = json!(if indexed_parade(g) { "INDEXED_GROUP" } else { "NAMED_GROUP" });
|
||||
o["enabled"] = json!(g.enabled);
|
||||
o["canSetEnabled"] = json!(instance);
|
||||
o["instance"] = json!(instance || instance_parent);
|
||||
o["effectId"] = json!(match &g.kind {
|
||||
GroupKind::Effect { effect } => Some(effect.clone()),
|
||||
_ => None,
|
||||
});
|
||||
if let GroupKind::Mask { mode, inverted, color, locked, .. } = &g.kind {
|
||||
o["maskMode"] = json!(mode.label());
|
||||
o["inverted"] = json!(inverted);
|
||||
o["color"] = json!([color[0] as f64 / 255.0, color[1] as f64 / 255.0, color[2] as f64 / 255.0]);
|
||||
o["locked"] = json!(locked);
|
||||
}
|
||||
}
|
||||
Node::Prop(p) => {
|
||||
o["kind"] = json!("prop");
|
||||
o["propertyType"] = json!("PROPERTY");
|
||||
o["valueType"] = json!(value_type(p));
|
||||
o["type"] = json!(p.value.kind_name());
|
||||
o["dims"] = json!(p.value.dims());
|
||||
o["spatial"] = json!(p.spatial);
|
||||
o["numKeys"] = json!(p.keys.len());
|
||||
o["expression"] = json!(p.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default());
|
||||
o["expressionEnabled"] = json!(p.has_expression());
|
||||
o["canVaryOverTime"] = json!(!p.static_only);
|
||||
o["canSetExpression"] = json!(!p.static_only && !matches!(p.ui, ParamUi::Hidden));
|
||||
o["hidden"] = json!(matches!(p.ui, ParamUi::Hidden));
|
||||
o["enabled"] = json!(true);
|
||||
o["canSetEnabled"] = json!(false);
|
||||
o["isModified"] = json!(p.is_animated() || p.has_expression());
|
||||
if let ParamUi::Slider { min, max, .. } = p.ui {
|
||||
o["min"] = json!(min);
|
||||
o["max"] = json!(max);
|
||||
}
|
||||
if let ParamUi::Popup { options } = &p.ui {
|
||||
o["options"] = json!(options);
|
||||
}
|
||||
o["ui"] = json!(match &p.ui {
|
||||
ParamUi::Percent => "%",
|
||||
ParamUi::Angle => "degrees",
|
||||
ParamUi::Pixels | ParamUi::Point | ParamUi::Point3 => "pixels",
|
||||
_ => "",
|
||||
});
|
||||
// Position with Separate Dimensions.
|
||||
o["separated"] = json!(node.match_id() == "position" && parent_group.match_id == "transform" && parent_group.get("positionX").is_some());
|
||||
o["separationFollower"] = json!(matches!(node.match_id(), "positionX" | "positionY" | "positionZ"));
|
||||
}
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
fn node(s: &Session, a: &J) -> R {
|
||||
let (_, c, l) = layer_of(s, a)?;
|
||||
node_json(c, l, u(a, "uid").unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Normalized name for loose lookups: `Anchor Point`, `anchorPoint`, `anchor_point` → `anchorpoint`.
|
||||
fn loose(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// AE-style aliases for our match ids (attribute names scripts use).
|
||||
fn alias(k: &str) -> Option<&'static str> {
|
||||
Some(match k {
|
||||
"anchorpoint" => "anchor",
|
||||
"pointofinterest" => "poi",
|
||||
"xrotation" => "rotationX",
|
||||
"yrotation" => "rotationY",
|
||||
"zrotation" => "rotation",
|
||||
"xposition" => "positionX",
|
||||
"yposition" => "positionY",
|
||||
"zposition" => "positionZ",
|
||||
"effect" | "effects" => "effects",
|
||||
"mask" | "masks" => "masks",
|
||||
"content" | "contents" => "contents",
|
||||
"maskshape" | "maskpath" => "path",
|
||||
"maskfeather" => "feather",
|
||||
"maskopacity" => "opacity",
|
||||
"maskexpansion" | "maskoffset" => "expansion",
|
||||
"sourcetext" => "sourceText",
|
||||
"timeremap" => "timeRemap",
|
||||
"audiolevels" => "levels",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn child(s: &Session, a: &J) -> R {
|
||||
let (_, c, l) = layer_of(s, a)?;
|
||||
let uid = u(a, "uid").unwrap_or(0);
|
||||
let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? };
|
||||
let key = a.get("key").cloned().unwrap_or(J::Null);
|
||||
let found = match &key {
|
||||
J::Number(n) => n.as_u64().and_then(|i| g.children.get((i as usize).checked_sub(1)?)),
|
||||
J::String(k) => {
|
||||
let chain_of = |n: &Node| -> String {
|
||||
let mut ch = if uid == 0 || uid == l.props.uid { vec![] } else { chain(l, uid).unwrap_or_default() };
|
||||
ch.push(n);
|
||||
matchnames::node(&ch)
|
||||
};
|
||||
let lk = loose(k);
|
||||
g.children
|
||||
.iter()
|
||||
.find(|n| chain_of(n) == *k)
|
||||
.or_else(|| g.children.iter().find(|n| n.name() == k))
|
||||
.or_else(|| g.children.iter().find(|n| n.match_id() == k))
|
||||
.or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k)))
|
||||
.or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk))
|
||||
.or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match found {
|
||||
Some(n) => node_json(c, l, n.uid()),
|
||||
None => Ok(J::Null),
|
||||
}
|
||||
}
|
||||
|
||||
/// A value as the object model shows it: popups 1-based, checkboxes 0/1, layer references as
|
||||
/// layer indices, paths as `{vertices, inTangents, outTangents, closed}`, text as the document.
|
||||
fn js_value(c: &Comp, v: &KV) -> J {
|
||||
match v {
|
||||
KV::Bool(b) => json!(if *b { 1 } else { 0 }),
|
||||
KV::Enum(i) => json!(*i + 1),
|
||||
KV::Layer(l) => json!(l.and_then(|id| c.index_of(LayerId(id))).unwrap_or(0)),
|
||||
KV::Path(p) => json!({"vertices": p.vertices, "inTangents": p.in_tangents, "outTangents": p.out_tangents, "closed": p.closed}),
|
||||
KV::Text(d) => text_json(d),
|
||||
v => v.to_json(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A text document for `TextDocument`: the serialized document plus the first character's style.
|
||||
fn text_json(d: &TextDoc) -> J {
|
||||
let mut doc = serde_json::to_value(d).unwrap_or(J::Null);
|
||||
let st = d.style_at(0);
|
||||
if let Some(o) = doc.as_object_mut() {
|
||||
o.insert("first".into(), effectcraft_keyframe::text_doc::char_style_json(&st));
|
||||
o.insert("para".into(), effectcraft_keyframe::text_doc::para_style_json(&d.para(0)));
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
fn value(s: &Session, a: &J) -> R {
|
||||
let (cid, c, l) = layer_of(s, a)?;
|
||||
let uid = u(a, "uid").ok_or("missing uid")?;
|
||||
let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?;
|
||||
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
|
||||
let pre = a.get("pre").and_then(J::as_bool).unwrap_or(false);
|
||||
let v = if pre || !p.has_expression() {
|
||||
p.value_at(l.layer_time(t))
|
||||
} else {
|
||||
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
|
||||
ctx.expr = s.expr.as_deref();
|
||||
ctx.value(l, p)
|
||||
};
|
||||
Ok(js_value(c, &v))
|
||||
}
|
||||
|
||||
fn interp(i: Interp) -> &'static str {
|
||||
match i {
|
||||
Interp::Linear => "LINEAR",
|
||||
Interp::Bezier => "BEZIER",
|
||||
Interp::Hold => "HOLD",
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(s: &Session, a: &J) -> R {
|
||||
let (cid, c, l) = layer_of(s, a)?;
|
||||
let uid = u(a, "uid").ok_or("missing uid")?;
|
||||
let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?;
|
||||
let sel: Vec<Tick> = s.state.selected_keys.iter().filter(|k| k.layer == l.id && k.prop == uid).map(|k| k.time).collect();
|
||||
let _ = cid;
|
||||
let ease = |e: &[effectcraft_keyframe::Ease], n: usize| -> J {
|
||||
let n = n.max(1);
|
||||
// Influence is stored as a fraction; scripts see percent.
|
||||
let mut v: Vec<J> = e.iter().map(|x| json!({"speed": x.speed, "influence": x.influence * 100.0})).collect();
|
||||
let d = e.first().cloned().unwrap_or_default();
|
||||
while v.len() < n {
|
||||
v.push(json!({"speed": d.speed, "influence": d.influence * 100.0}));
|
||||
}
|
||||
J::Array(v)
|
||||
};
|
||||
let dims = if p.spatial { 1 } else { p.value.dims().max(1) };
|
||||
let out: Vec<J> = p
|
||||
.keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
json!({
|
||||
"time": l.comp_time(k.time).seconds(),
|
||||
"layerTime": k.time.seconds(),
|
||||
"value": js_value(c, &k.value),
|
||||
"inInterp": interp(k.in_interp), "outInterp": interp(k.out_interp),
|
||||
"inEase": ease(&k.in_ease, dims), "outEase": ease(&k.out_ease, dims),
|
||||
"continuous": k.continuous, "autoBezier": k.auto_bezier, "roving": k.roving,
|
||||
"spatialIn": k.spatial_in, "spatialOut": k.spatial_out,
|
||||
"spatialContinuous": k.spatial_continuous, "spatialAutoBezier": k.spatial_auto,
|
||||
"selected": sel.contains(&k.time),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(J::Array(out))
|
||||
}
|
||||
|
||||
/// Apply `layer.setText` attribute keys to the Source Text document at `time` and return the new
|
||||
/// document (for `prop.set` / `prop.addKey`).
|
||||
fn textdoc(s: &Session, a: &J) -> R {
|
||||
let (cid, _, l) = layer_of(s, a)?;
|
||||
let uid = u(a, "uid").ok_or("missing uid")?;
|
||||
let p = l.props.find(uid).ok_or("the property no longer exists")?;
|
||||
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
|
||||
let KV::Text(doc) = p.value_at(l.layer_time(t)) else { return Err("not a Source Text property".into()) };
|
||||
let mut d = *doc;
|
||||
if let Some(attrs) = a.get("attrs").and_then(J::as_object) {
|
||||
if let Some(text) = attrs.get("text").and_then(J::as_str) {
|
||||
d.set_text(text);
|
||||
}
|
||||
for (k, v) in attrs {
|
||||
if k == "text" || v.is_null() {
|
||||
continue;
|
||||
}
|
||||
match d.set_attr(k, v, None) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Err(format!("TextDocument: unknown attribute `{k}`")),
|
||||
Err(e) => return Err(format!("TextDocument.{k}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::to_value(&d).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn markers(s: &Session, a: &J) -> R {
|
||||
let (_, c) = comp_of(s, a)?;
|
||||
let (list, to_comp): (&[effectcraft_project::Marker], Option<&Layer>) = match u(a, "layer") {
|
||||
Some(lid) => {
|
||||
let l = c.layer(LayerId(lid)).ok_or("the layer no longer exists")?;
|
||||
(&l.markers, Some(l))
|
||||
}
|
||||
None => (&c.markers, None),
|
||||
};
|
||||
let out: Vec<J> = list
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let t = to_comp.map(|l| l.comp_time(m.time)).unwrap_or(m.time);
|
||||
json!({
|
||||
"time": t.seconds(), "duration": m.duration.seconds(), "comment": m.comment, "chapter": m.chapter, "url": m.url,
|
||||
"frameTarget": m.frame_target, "label": label_index(m.label), "protectedRegion": m.protected,
|
||||
"cuePointName": m.cue_point.as_ref().map(|c| c.name.clone()).unwrap_or_default(),
|
||||
"eventCuePoint": m.cue_point.as_ref().is_some_and(|c| !c.navigation),
|
||||
"params": m.cue_point.as_ref().map(|c| c.params.clone()).unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(J::Array(out))
|
||||
}
|
||||
|
||||
fn source_rect(s: &Session, a: &J) -> R {
|
||||
let (cid, c, l) = layer_of(s, a)?;
|
||||
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
|
||||
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
|
||||
ctx.expr = s.expr.as_deref();
|
||||
let r = match &l.source {
|
||||
LayerSource::Text => {
|
||||
let paths: Vec<_> = effectcraft_render::text::glyph_paths(&ctx, l).into_iter().map(|(p, _)| p).collect();
|
||||
effectcraft_path::bounds(&paths).map(|b| [b.y0, b.x0, b.width(), b.height()]).unwrap_or([0.0; 4])
|
||||
}
|
||||
LayerSource::Shape => match l.props.sub("contents") {
|
||||
Some(contents) => {
|
||||
let buf = effectcraft_render::shapes::render(&ctx, l, contents, 1.0);
|
||||
if buf.img.width <= 4 { [0.0; 4] } else { [2.0 - buf.offset[1], 2.0 - buf.offset[0], buf.img.width as f64 - 4.0, buf.img.height as f64 - 4.0] }
|
||||
}
|
||||
None => [0.0; 4],
|
||||
},
|
||||
_ => {
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, l);
|
||||
[0.0, 0.0, w as f64, h as f64]
|
||||
}
|
||||
};
|
||||
Ok(json!({"top": r[0], "left": r[1], "width": r[2], "height": r[3]}))
|
||||
}
|
||||
|
||||
/// What `PropertyGroup.addProperty(name)` adds to the group `uid`.
|
||||
fn resolve_add(s: &Session, a: &J) -> R {
|
||||
let (_, _, l) = layer_of(s, a)?;
|
||||
let uid = u(a, "uid").unwrap_or(0);
|
||||
let name = a.get("name").and_then(J::as_str).unwrap_or("");
|
||||
let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? };
|
||||
let in_contents = g.match_id == "contents";
|
||||
let in_group = l.props.sub("contents").is_some_and(|c| c.uid == g.uid || c.find_group(g.uid).is_some());
|
||||
Ok(match g.match_id.as_str() {
|
||||
"effects" => {
|
||||
let id = matchnames::effect_from_match(name).map(str::to_string).or_else(|| effectcraft_engine::effects::lookup(name).map(|e| e.id.to_string()));
|
||||
match id {
|
||||
Some(id) => json!({"kind": "effect", "effect": id}),
|
||||
None => json!({"error": format!("Can not add a property with name \"{name}\" to this PropertyGroup (unknown effect)")}),
|
||||
}
|
||||
}
|
||||
"masks" => {
|
||||
if name == "ADBE Mask Atom" || loose(name) == "mask" {
|
||||
json!({"kind": "mask"})
|
||||
} else {
|
||||
json!({"error": format!("Can not add \"{name}\" to Masks (use \"ADBE Mask Atom\")")})
|
||||
}
|
||||
}
|
||||
"animators" => json!({"kind": "animator"}),
|
||||
_ if in_contents && in_group => match matchnames::shape_kind(name) {
|
||||
Some(k) => {
|
||||
json!({"kind": "shape", "shape": k, "group": if l.props.sub("contents").is_some_and(|c| c.uid == g.uid) { J::Null } else { json!(g.uid) }})
|
||||
}
|
||||
None => json!({"error": format!("Can not add \"{name}\" to Contents")}),
|
||||
},
|
||||
"properties" if l.props.group("text/animators").is_some_and(|a| a.find_group(g.uid).is_some()) => {
|
||||
let animator = l.props.group("text/animators").and_then(|a| a.groups().find(|x| x.find_group(g.uid).is_some()).map(|x| x.uid));
|
||||
match text_animator_property(name) {
|
||||
Some(k) => json!({"kind": "animatorProperty", "property": k, "animator": animator}),
|
||||
None => json!({"error": format!("Can not add \"{name}\" to an animator's Properties")}),
|
||||
}
|
||||
}
|
||||
"selectors" if l.props.group("text/animators").is_some_and(|a| a.find_group(g.uid).is_some()) => {
|
||||
let animator = l.props.group("text/animators").and_then(|a| a.groups().find(|x| x.find_group(g.uid).is_some()).map(|x| x.uid));
|
||||
let kind = match name {
|
||||
"ADBE Text Wiggly Selector" => "wiggly",
|
||||
"ADBE Text Expressible Selector" => "expression",
|
||||
n if loose(n).contains("wiggly") => "wiggly",
|
||||
n if loose(n).contains("expression") => "expression",
|
||||
_ => "range",
|
||||
};
|
||||
json!({"kind": "selector", "selector": kind, "animator": animator})
|
||||
}
|
||||
_ => json!({"error": format!("Can not add a property to \"{}\"", g.name)}),
|
||||
})
|
||||
}
|
||||
|
||||
/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match
|
||||
/// name or a display name.
|
||||
fn text_animator_property(name: &str) -> Option<&'static str> {
|
||||
const AE: &[(&str, &str)] = &[
|
||||
("ADBE Text Anchor Point 3D", "anchor"),
|
||||
("ADBE Text Position 3D", "position"),
|
||||
("ADBE Text Scale 3D", "scale"),
|
||||
("ADBE Text Skew", "skew"),
|
||||
("ADBE Text Rotation", "rotation"),
|
||||
("ADBE Text Opacity", "opacity"),
|
||||
("ADBE Text Fill Color", "fillColor"),
|
||||
("ADBE Text Fill Hue", "fillHue"),
|
||||
("ADBE Text Fill Saturation", "fillSaturation"),
|
||||
("ADBE Text Fill Brightness", "fillBrightness"),
|
||||
("ADBE Text Fill Opacity", "fillOpacity"),
|
||||
("ADBE Text Stroke Color", "strokeColor"),
|
||||
("ADBE Text Stroke Hue", "strokeHue"),
|
||||
("ADBE Text Stroke Saturation", "strokeSaturation"),
|
||||
("ADBE Text Stroke Brightness", "strokeBrightness"),
|
||||
("ADBE Text Stroke Opacity", "strokeOpacity"),
|
||||
("ADBE Text Stroke Width", "strokeWidth"),
|
||||
("ADBE Text Tracking Amount", "tracking"),
|
||||
("ADBE Text Line Anchor", "lineAnchor"),
|
||||
("ADBE Text Line Spacing", "lineSpacing"),
|
||||
("ADBE Text Character Offset", "characterOffset"),
|
||||
("ADBE Text Character Replace", "characterValue"),
|
||||
("ADBE Text Blur", "blur"),
|
||||
];
|
||||
if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) {
|
||||
return Some(k);
|
||||
}
|
||||
let n = loose(name);
|
||||
AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
|
||||
}
|
||||
|
||||
/// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items
|
||||
/// first), 0 when none does.
|
||||
fn menu_command_id(name: &str) -> J {
|
||||
let norm = |s: &str| s.trim().trim_end_matches("...").trim_end_matches('…').trim().to_ascii_lowercase();
|
||||
let n = norm(name);
|
||||
let specs = effectcraft_engine::command_specs();
|
||||
let pick = specs
|
||||
.iter()
|
||||
.position(|c| !c.menu.is_empty() && norm(c.label) == n)
|
||||
.or_else(|| specs.iter().position(|c| norm(c.label) == n))
|
||||
.or_else(|| specs.iter().position(|c| c.id == name));
|
||||
json!(pick.map(|i| i + 1).unwrap_or(0))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,546 @@
|
|||
//! The boa runtime: native functions (`__exec`, `__query`, `__print`, `__undo`, `__task`,
|
||||
//! `__file`), the session hand-off while a script runs, undo groups and error reporting.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::mem::ManuallyDrop;
|
||||
use std::sync::Arc;
|
||||
|
||||
use boa_engine::{Context, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Source, js_string};
|
||||
use effectcraft_engine::project::Project;
|
||||
use effectcraft_engine::{ScriptRequest, Session};
|
||||
use serde_json::{Value as J, json};
|
||||
|
||||
const PRELUDE: &str = include_str!("prelude.js");
|
||||
|
||||
/// Engine commands that create layers: like After Effects, scripts add new layers at the top of
|
||||
/// the stack (the UI inserts above the selection).
|
||||
const CREATES_LAYER: &[&str] =
|
||||
&["layer.newSolid", "layer.newNull", "layer.newText", "layer.newShape", "layer.newAdjustment", "layer.newCamera", "layer.newLight", "layer.addItem"];
|
||||
|
||||
/// What a script run produced.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Outcome {
|
||||
/// The value of the script's last expression statement (JSON when it converts; model objects
|
||||
/// as strings like `"[object CompItem]"`).
|
||||
pub result: J,
|
||||
/// `writeLn` / `$.writeln` / `alert` output, one entry per line.
|
||||
pub output: Vec<String>,
|
||||
pub error: Option<ScriptError>,
|
||||
}
|
||||
|
||||
/// An uncaught script error.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ScriptError {
|
||||
pub message: String,
|
||||
pub line: Option<u32>,
|
||||
pub column: Option<u32>,
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
pub fn ok(&self) -> bool {
|
||||
self.error.is_none()
|
||||
}
|
||||
pub fn to_json(&self) -> 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})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// Open undo groups: (name, project snapshot when the outermost group began).
|
||||
groups: Vec<(String, Arc<Project>)>,
|
||||
/// `app.scheduleTask` queue: (id, code, repeat).
|
||||
tasks: Vec<(u32, String, bool)>,
|
||||
next_task: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
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) };
|
||||
}
|
||||
|
||||
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),
|
||||
None => Err("no script is running".into()),
|
||||
},
|
||||
Err(_) => Err("scripts can't run scripts".into()),
|
||||
})
|
||||
}
|
||||
|
||||
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> {
|
||||
Ok(args.get(i).cloned().unwrap_or_default().to_string(ctx)?.to_std_string_escaped())
|
||||
}
|
||||
|
||||
fn arg_json(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<J> {
|
||||
let s = arg_string(args, i, ctx)?;
|
||||
if s.is_empty() || s == "undefined" {
|
||||
return Ok(json!({}));
|
||||
}
|
||||
serde_json::from_str(&s).map_err(|e| throw(format!("bad parameters: {e}")))
|
||||
}
|
||||
|
||||
fn js_str(s: &str) -> JsValue {
|
||||
JsValue::from(JsString::from(s))
|
||||
}
|
||||
|
||||
/// `__exec(commandId, paramsJson)` → result JSON. Runs an engine command (undoable) with the
|
||||
/// target comp made active for its duration.
|
||||
fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let id = arg_string(args, 0, ctx)?;
|
||||
let params = arg_json(args, 1, ctx)?;
|
||||
if id == "script.run" || id == "file.runScript" {
|
||||
return Err(throw("scripts can't run scripts"));
|
||||
}
|
||||
let r = with_active(|a| Ok(exec(&mut a.session, &id, params)));
|
||||
match r {
|
||||
Ok(Ok(v)) => Ok(js_str(&v.to_string())),
|
||||
Ok(Err(e)) | Err(e) => Err(throw(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command for a script: the `comp` it targets is active while it runs (commands act on
|
||||
/// the active comp), then the user's active comp comes back.
|
||||
pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
|
||||
use effectcraft_engine::project::ItemId;
|
||||
let target = params.get("comp").and_then(J::as_u64).map(ItemId).filter(|c| s.project.comp(*c).is_some());
|
||||
let prev = s.state.active_comp;
|
||||
if let Some(c) = target
|
||||
&& prev != Some(c)
|
||||
{
|
||||
s.state.active_comp = Some(c);
|
||||
}
|
||||
if CREATES_LAYER.contains(&id) {
|
||||
s.state.selected_layers.clear();
|
||||
}
|
||||
let r = s.execute(id, params);
|
||||
if let Some(c) = target
|
||||
&& prev != Some(c)
|
||||
&& s.state.active_comp == Some(c)
|
||||
&& id != "comp.open"
|
||||
{
|
||||
match prev {
|
||||
Some(p) if s.project.comp(p).is_some() => s.state.active_comp = Some(p),
|
||||
_ => {
|
||||
if !s.state.open_comps.contains(&c) {
|
||||
s.state.open_comps.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `__query(kind, json)` → JSON.
|
||||
fn native_query(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let kind = arg_string(args, 0, ctx)?;
|
||||
let a = arg_json(args, 1, ctx)?;
|
||||
let r = with_active(|act| match kind.as_str() {
|
||||
"app" => Ok(json!({
|
||||
"version": format!("26.0 (EffectCraft {})", env!("CARGO_PKG_VERSION")),
|
||||
"buildName": env!("CARGO_PKG_VERSION"),
|
||||
"scriptName": act.name,
|
||||
"allowFiles": act.session.prefs.scripting.allow_scripts_write_files,
|
||||
"projectDir": project_dir(&act.session),
|
||||
})),
|
||||
_ => crate::model::query(&mut act.session, &kind, &a),
|
||||
});
|
||||
match r {
|
||||
Ok(v) => Ok(js_str(&v.to_string())),
|
||||
Err(e) => Err(throw(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `__print(text)`: `writeLn`, `$.writeln`, `alert`.
|
||||
fn native_print(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let text = arg_string(args, 0, ctx)?;
|
||||
let _ = with_active(|a| {
|
||||
a.output.extend(text.split('\n').map(str::to_string));
|
||||
Ok(())
|
||||
});
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `__undo("begin" | "end", name)`: After Effects' `app.beginUndoGroup` / `endUndoGroup`. Every
|
||||
/// command run inside the outermost group folds into one undo step named after the group.
|
||||
fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let op = arg_string(args, 0, ctx)?;
|
||||
let name = arg_string(args, 1, ctx)?;
|
||||
with_active(|a| {
|
||||
match op.as_str() {
|
||||
"begin" => {
|
||||
let snap = a.session.project.clone();
|
||||
a.groups.push((if name.is_empty() || name == "undefined" { "Script".into() } else { name }, snap));
|
||||
}
|
||||
_ => {
|
||||
if a.groups.len() == 1 {
|
||||
let (name, snap) = a.groups.pop().unwrap_or_default();
|
||||
collapse(&mut a.session, &name, &snap);
|
||||
} else {
|
||||
a.groups.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(throw)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
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).
|
||||
return;
|
||||
};
|
||||
let first = h.undo[start].1.clone();
|
||||
h.undo.truncate(start);
|
||||
h.undo.push((name.to_string(), first));
|
||||
h.merge_key = None;
|
||||
}
|
||||
|
||||
/// `__task("schedule", code, repeat)` → id; `__task("cancel", id)`.
|
||||
fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let op = arg_string(args, 0, ctx)?;
|
||||
if op == "cancel" {
|
||||
let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32;
|
||||
let _ = with_active(|a| {
|
||||
a.tasks.retain(|t| t.0 != id);
|
||||
Ok(())
|
||||
});
|
||||
return Ok(JsValue::undefined());
|
||||
}
|
||||
let code = arg_string(args, 1, ctx)?;
|
||||
let repeat = args.get(2).cloned().unwrap_or_default().to_boolean();
|
||||
let id = with_active(|a| {
|
||||
a.next_task += 1;
|
||||
let id = a.next_task;
|
||||
a.tasks.push((id, code, repeat));
|
||||
Ok(id)
|
||||
})
|
||||
.map_err(throw)?;
|
||||
Ok(JsValue::from(id))
|
||||
}
|
||||
|
||||
fn project_dir(s: &Session) -> Option<String> {
|
||||
let p = s.path.as_deref()?;
|
||||
std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Lexically normalized absolute path (`a/./b/../c` → `a/c`).
|
||||
fn normalize(p: &str) -> std::path::PathBuf {
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
let path = Path::new(p);
|
||||
let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
|
||||
let mut out = PathBuf::new();
|
||||
for c in abs.components() {
|
||||
match c {
|
||||
Component::ParentDir => {
|
||||
out.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
// Resolve symlinks where the file (or its folder) exists, so links can't escape the project.
|
||||
std::fs::canonicalize(&out)
|
||||
.or_else(|_| out.parent().map(std::fs::canonicalize).unwrap_or(Ok(out.clone())).map(|d| d.join(out.file_name().unwrap_or_default())))
|
||||
.unwrap_or(out)
|
||||
}
|
||||
|
||||
const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network";
|
||||
|
||||
/// The file-access gate: reads under the project's folder are always allowed; other reads, all
|
||||
/// writes and listing other folders need the Allow Scripts to Write Files preference.
|
||||
fn check_access(s: &Session, path: &str, write: bool) -> Result<std::path::PathBuf, String> {
|
||||
let p = normalize(path);
|
||||
if s.prefs.scripting.allow_scripts_write_files {
|
||||
return Ok(p);
|
||||
}
|
||||
if write {
|
||||
return Err(format!("Unable to write {path}: scripts can't write files unless {GATE} is on"));
|
||||
}
|
||||
if let Some(dir) = project_dir(s) {
|
||||
let d = normalize(&dir);
|
||||
if p.starts_with(&d) {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
Err(format!("Unable to access {path}: scripts can only read files in the project's folder unless {GATE} is on"))
|
||||
}
|
||||
|
||||
/// `__file(op, path, data)`: `read` → text, `write`/`append` → bytes, `exists` → bool, `list` →
|
||||
/// names, `mkdir`, `remove`, `isDir`.
|
||||
fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let op = arg_string(args, 0, ctx)?;
|
||||
let path = arg_string(args, 1, ctx)?;
|
||||
let data = if args.len() > 2 { arg_string(args, 2, ctx)? } else { String::new() };
|
||||
let r: Result<J, String> = with_active(|a| {
|
||||
let s = &a.session;
|
||||
match op.as_str() {
|
||||
"access" => Ok(json!(check_access(s, &path, false)?.exists())),
|
||||
"exists" | "isDir" => {
|
||||
// Unreadable locations report "missing" instead of failing.
|
||||
let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) };
|
||||
Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() }))
|
||||
}
|
||||
"read" => {
|
||||
let p = check_access(s, &path, false)?;
|
||||
let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?;
|
||||
Ok(json!(String::from_utf8_lossy(&bytes)))
|
||||
}
|
||||
"write" | "append" => {
|
||||
let p = check_access(s, &path, true)?;
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
let mut bytes = if op == "append" { s.services.read_file(&ps).unwrap_or_default() } else { vec![] };
|
||||
bytes.extend_from_slice(data.as_bytes());
|
||||
s.services.write_file(&ps, &bytes).map_err(|e| format!("Unable to write {path}: {e}"))?;
|
||||
Ok(json!(bytes.len()))
|
||||
}
|
||||
"list" => {
|
||||
let p = check_access(s, &path, false)?;
|
||||
let mut names: Vec<String> = std::fs::read_dir(&p)
|
||||
.map_err(|e| format!("Unable to list {path}: {e}"))?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path().to_string_lossy().to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
Ok(json!(names))
|
||||
}
|
||||
"mkdir" => {
|
||||
let p = check_access(s, &path, true)?;
|
||||
std::fs::create_dir_all(&p).map_err(|e| format!("Unable to create {path}: {e}"))?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
"remove" => {
|
||||
let p = check_access(s, &path, true)?;
|
||||
let r = if p.is_dir() { std::fs::remove_dir(&p) } else { std::fs::remove_file(&p) };
|
||||
Ok(json!(r.is_ok()))
|
||||
}
|
||||
"network" => Err(if s.prefs.scripting.allow_scripts_write_files {
|
||||
"network access from scripts is not supported in EffectCraft".to_string()
|
||||
} else {
|
||||
format!("scripts can't access the network unless {GATE} is on")
|
||||
}),
|
||||
_ => Err(format!("unknown file operation `{op}`")),
|
||||
}
|
||||
});
|
||||
match r {
|
||||
Ok(v) => Ok(js_str(&v.to_string())),
|
||||
Err(e) => Err(throw(e)),
|
||||
}
|
||||
}
|
||||
|
||||
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] = [
|
||||
(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),
|
||||
];
|
||||
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}"))?;
|
||||
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 {
|
||||
// Thrown values: `throw new Error("x")`, `throw "x"`, native errors.
|
||||
let display = e.to_string();
|
||||
let message = match e.as_opaque() {
|
||||
Some(v) => {
|
||||
let o = v.as_object();
|
||||
let msg = o.as_ref().and_then(|o| o.get(js_string!("message"), ctx).ok()).filter(|m| !m.is_undefined());
|
||||
let name = o.as_ref().and_then(|o| o.get(js_string!("name"), ctx).ok()).filter(|m| !m.is_undefined());
|
||||
match (name, msg) {
|
||||
(Some(n), Some(m)) => {
|
||||
let n = n.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
|
||||
let m = m.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
|
||||
if n == "Error" { m } else { format!("{n}: {m}") }
|
||||
}
|
||||
_ => v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_else(|_| display.clone()),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let first = display.lines().next().unwrap_or("").trim();
|
||||
let first = first.split(" at line ").next().unwrap_or(first).trim();
|
||||
let first = match first.rfind(" (") {
|
||||
Some(i) if first.ends_with(')') && first[i..].contains(':') => first[..i].trim(),
|
||||
_ => first,
|
||||
};
|
||||
first.strip_prefix("Error: ").unwrap_or(first).to_string()
|
||||
}
|
||||
};
|
||||
let (line, column) = position(&display, file, code);
|
||||
ScriptError { message, line, column, file: file.to_string() }
|
||||
}
|
||||
|
||||
/// Line and column of an error: `at line N, col M` (syntax errors), else the innermost backtrace
|
||||
/// frame in the user's script (`file:line:col`), else (names boa can't place) the first use of
|
||||
/// the undefined name in `code`.
|
||||
fn position(msg: &str, file: &str, code: &str) -> (Option<u32>, Option<u32>) {
|
||||
if let Some(i) = msg.find("at line ") {
|
||||
let rest = &msg[i + 8..];
|
||||
let line = rest.split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse().ok());
|
||||
let col = rest.find("col ").and_then(|j| rest[j + 4..].split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse().ok());
|
||||
return (line, col);
|
||||
}
|
||||
// `(file:12:5)`: the digits after the file name.
|
||||
let pick = |l: &str| -> Option<(u32, u32)> {
|
||||
let i = l.find(&format!("({file}:"))? + file.len() + 2;
|
||||
let mut it = l[i..].split([':', ')']);
|
||||
Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
|
||||
};
|
||||
let frames: Vec<&str> = msg.lines().skip(1).collect();
|
||||
if let Some((a, b)) = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)) {
|
||||
return (Some(a), Some(b));
|
||||
}
|
||||
// `ReferenceError: name is not defined` without a position.
|
||||
let first = msg.lines().next().unwrap_or("");
|
||||
if let Some(name) = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next()) {
|
||||
for (n, line) in code.lines().enumerate() {
|
||||
let b = line.as_bytes();
|
||||
let mut from = 0;
|
||||
while let Some(i) = line[from..].find(name).map(|i| i + from) {
|
||||
let before = i.checked_sub(1).map(|j| b[j]);
|
||||
let after = b.get(i + name.len()).copied();
|
||||
let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$';
|
||||
if !before.is_some_and(|c| ident(c) || c == b'.') && !after.is_some_and(ident) {
|
||||
return (Some(n as u32 + 1), Some(i as u32 + 1));
|
||||
}
|
||||
from = i + name.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
||||
/// Evaluate `code` in `ctx` and convert the result.
|
||||
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))?;
|
||||
Ok(result_json(&v, ctx))
|
||||
}
|
||||
|
||||
/// The script's completion value as JSON (`__result` in the prelude does the conversion).
|
||||
fn result_json(v: &JsValue, ctx: &mut Context) -> J {
|
||||
if v.is_undefined() {
|
||||
return J::Null;
|
||||
}
|
||||
let global = ctx.global_object();
|
||||
let Some(f) = global.get(js_string!("__result"), ctx).ok().and_then(|f| f.as_object()) else { return J::Null };
|
||||
match f.call(&JsValue::undefined(), std::slice::from_ref(v), ctx) {
|
||||
Ok(s) if s.is_string() => {
|
||||
let s = s.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
|
||||
serde_json::from_str(&s).unwrap_or(J::String(s))
|
||||
}
|
||||
_ => J::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores the caller's session when a run ends (on every exit path).
|
||||
struct Restore<'a> {
|
||||
target: &'a mut Session,
|
||||
}
|
||||
|
||||
impl Drop for Restore<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(a) = ACTIVE.with(|c| c.borrow_mut().take()) {
|
||||
*self.target = a.session;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a script against `session`: the object model sees and edits that session through engine
|
||||
/// commands. Script errors come back in [`Outcome::error`].
|
||||
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 {
|
||||
return Outcome {
|
||||
error: Some(ScriptError { message: "scripts can't run scripts".into(), file: req.name.into(), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
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() }));
|
||||
let guard = Restore { target: session };
|
||||
let file = if req.name.is_empty() { "script" } else { req.name };
|
||||
let mut result = 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() }),
|
||||
}
|
||||
}
|
||||
let ctx = c.as_mut().map(|m| &mut **m).expect("console context");
|
||||
eval_in(ctx, req.code, file)
|
||||
})
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => Err(ScriptError { message: e, file: file.into(), ..Default::default() }),
|
||||
}
|
||||
};
|
||||
// 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();
|
||||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,448 @@
|
|||
//! End-to-end scripts against a headless session: build with the object model, read back through
|
||||
//! the engine.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
use effectcraft_engine::project::{Comp, ItemId, Layer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Outcome, run_code};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
fn ok(s: &mut Session, code: &str) -> Outcome {
|
||||
let o = run_code(s, code, "test.jsx");
|
||||
assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output);
|
||||
o
|
||||
}
|
||||
|
||||
fn comp_named<'a>(s: &'a Session, name: &str) -> (ItemId, &'a Comp) {
|
||||
let it = s.project.items.values().find(|i| i.name == name && i.as_comp().is_some()).unwrap_or_else(|| panic!("no comp {name}"));
|
||||
(it.id, it.as_comp().unwrap())
|
||||
}
|
||||
|
||||
fn layer<'a>(c: &'a Comp, name: &str) -> &'a Layer {
|
||||
c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_a_comp_end_to_end() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
app.beginUndoGroup("Build");
|
||||
var comp = app.project.items.addComp("Main", 640, 360, 1, 4, 25);
|
||||
var bg = comp.layers.addSolid([0.2, 0.4, 0.6], "BG", 640, 360, 1);
|
||||
var txt = comp.layers.addText("Hello");
|
||||
txt.name = "Title";
|
||||
var pos = txt.property("ADBE Transform Group").property("ADBE Position");
|
||||
pos.setValueAtTime(0, [100, 180]);
|
||||
pos.setValueAtTime(2, [540, 180]);
|
||||
txt.transform.opacity.expression = "50 + 25";
|
||||
var blur = bg.Effects.addProperty("ADBE Gaussian Blur 2");
|
||||
blur.property("Blurriness").setValue(12);
|
||||
var nul = comp.layers.addNull();
|
||||
txt.setParentWithJump(nul);
|
||||
bg.blendingMode = BlendingMode.MULTIPLY;
|
||||
var rq = app.project.renderQueue.items.add(comp);
|
||||
rq.outputModule(1).file = new File("/tmp/effectcraft-script-test/out.mp4");
|
||||
app.endUndoGroup();
|
||||
writeLn("layers: " + comp.numLayers);
|
||||
[comp.numLayers, pos.numKeys, pos.keyValue(2)[0], txt.transform.opacity.value, comp.layer(1).name]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.output, vec!["layers: 3"]);
|
||||
assert_eq!(o.result, json!([3, 2, 540, 75, "Null 1"]));
|
||||
let (cid, c) = comp_named(&s, "Main");
|
||||
assert_eq!((c.width, c.height), (640, 360));
|
||||
assert_eq!(c.duration.seconds(), 4.0);
|
||||
// AE adds new layers at the top.
|
||||
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, ["Null 1", "Title", "BG"]);
|
||||
let title = layer(c, "Title");
|
||||
let p = title.props.prop("transform/position").unwrap();
|
||||
assert_eq!(p.keys.len(), 2);
|
||||
assert_eq!(p.keys[1].value, KV::Vec3([540.0, 180.0, 0.0]));
|
||||
assert_eq!(title.props.prop("transform/opacity").unwrap().expr.as_ref().unwrap().text, "50 + 25");
|
||||
assert_eq!(title.parent, Some(layer(c, "Null 1").id));
|
||||
let bg = layer(c, "BG");
|
||||
assert_eq!(bg.blend_mode, effectcraft_engine::color::BlendMode::Multiply);
|
||||
let fx = bg.props.group("effects/#1").unwrap();
|
||||
assert_eq!(fx.match_id, "ec.blur.gaussian");
|
||||
assert_eq!(bg.props.prop("effects/#1/blurriness").unwrap().value, KV::Scalar(12.0));
|
||||
assert_eq!(s.project.render_queue.len(), 1);
|
||||
assert_eq!(s.project.render_queue[0].comp, cid);
|
||||
// One undo step for the whole group.
|
||||
assert_eq!(s.history.undo.len(), 1);
|
||||
assert_eq!(s.history.undo[0].0, "Build");
|
||||
assert!(s.undo());
|
||||
assert!(s.project.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_steps_without_a_group() {
|
||||
let mut s = session();
|
||||
ok(&mut s, r#"var c = app.project.items.addComp("A", 100, 100, 1, 1, 24); c.layers.addNull(); c.layers.addNull();"#);
|
||||
assert_eq!(s.history.undo.len(), 3);
|
||||
// An unbalanced group is closed when the script ends.
|
||||
ok(&mut s, r#"app.beginUndoGroup("Two"); var c = app.project.item(1); c.layers.addNull(); c.layers.addNull();"#);
|
||||
assert_eq!(s.history.undo.len(), 4);
|
||||
assert_eq!(s.history.undo[3].0, "Two");
|
||||
// Nested groups fold into the outermost one.
|
||||
ok(
|
||||
&mut s,
|
||||
r#"app.beginUndoGroup("Outer"); app.beginUndoGroup("Inner"); app.project.item(1).layers.addNull(); app.endUndoGroup(); app.project.item(1).layers.addNull(); app.endUndoGroup();"#,
|
||||
);
|
||||
assert_eq!(s.history.undo.len(), 5);
|
||||
assert_eq!(s.history.undo[4].0, "Outer");
|
||||
s.undo();
|
||||
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_report_line_and_column() {
|
||||
let mut s = session();
|
||||
let o = run_code(&mut s, "var a = 1;\nvar b = 2;\nfoo.bar();\n", "broken.jsx");
|
||||
let e = o.error.expect("error");
|
||||
assert_eq!(e.line, Some(3), "{e:?}");
|
||||
assert!(e.message.contains("foo"), "{e:?}");
|
||||
assert_eq!(e.file, "broken.jsx");
|
||||
let o = run_code(&mut s, "var a = 1;\n\nvar = ;\n", "syntax.jsx");
|
||||
let e = o.error.expect("syntax error");
|
||||
assert_eq!(e.line, Some(3), "{e:?}");
|
||||
let o = run_code(&mut s, "writeLn('before');\nthrow new Error('custom failure');\n", "throw.jsx");
|
||||
let e = o.error.expect("thrown");
|
||||
assert_eq!(e.message, "custom failure");
|
||||
assert_eq!(e.line, Some(2), "{e:?}");
|
||||
assert_eq!(o.output, vec!["before"]);
|
||||
// Errors from the object model point at the user's line.
|
||||
let o = run_code(&mut s, "var c = app.project.items.addComp('C', 10, 10, 1, 1, 24);\n\nc.layer(5);\n", "range.jsx");
|
||||
let e = o.error.expect("range error");
|
||||
assert!(e.message.contains("out of range"), "{e:?}");
|
||||
assert_eq!(e.line, Some(3), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_names_and_lookups() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("M", 200, 200, 1, 2, 30);
|
||||
var l = c.layers.addSolid([1, 1, 1], "S", 200, 200, 1);
|
||||
var tr = l.property("ADBE Transform Group");
|
||||
var fx = l.property("ADBE Effect Parade").addProperty("Gaussian Blur");
|
||||
var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom");
|
||||
var sh = c.layers.addShape();
|
||||
var grp = sh.property("ADBE Root Vectors Group").addProperty("ADBE Vector Group");
|
||||
var rect = grp.property("ADBE Vectors Group").addProperty("ADBE Vector Shape - Rect");
|
||||
var fill = grp.property("Contents").addProperty("ADBE Vector Graphic - Fill");
|
||||
fill.property("ADBE Vector Fill Color").setValue([1, 0, 0]);
|
||||
rect.property("ADBE Vector Rect Size").setValue([50, 60]);
|
||||
[
|
||||
l.matchName, tr.matchName, tr.property("ADBE Position").matchName, tr.property("Anchor Point").matchName,
|
||||
tr.property(1).name, tr.anchorPoint.matchName, l.transform.position.propertyValueType == PropertyValueType.ThreeD_SPATIAL,
|
||||
fx.matchName, fx.property(1).matchName, fx.property("ADBE Gaussian Blur 2-0001").name, m.matchName,
|
||||
m.property("ADBE Mask Shape").name, sh.matchName, grp.matchName, rect.matchName, fill.matchName,
|
||||
tr.propertyType == PropertyType.NAMED_GROUP, l.Effects.propertyType == PropertyType.INDEXED_GROUP,
|
||||
tr.position.parentProperty.matchName, tr.position.propertyDepth, l.property("Nope") === null,
|
||||
fx.isEffect, m.isMask, l.Effects.numProperties
|
||||
]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
o.result,
|
||||
json!([
|
||||
"ADBE AV Layer",
|
||||
"ADBE Transform Group",
|
||||
"ADBE Position",
|
||||
"ADBE Anchor Point",
|
||||
"Anchor Point",
|
||||
"ADBE Anchor Point",
|
||||
true,
|
||||
"ADBE Gaussian Blur 2",
|
||||
"ADBE Gaussian Blur 2-0001",
|
||||
"Blurriness",
|
||||
"ADBE Mask Atom",
|
||||
"Mask Path",
|
||||
"ADBE Vector Layer",
|
||||
"ADBE Vector Group",
|
||||
"ADBE Vector Shape - Rect",
|
||||
"ADBE Vector Graphic - Fill",
|
||||
true,
|
||||
true,
|
||||
"ADBE Transform Group",
|
||||
2,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
1
|
||||
])
|
||||
);
|
||||
let (_, c) = comp_named(&s, "M");
|
||||
let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap();
|
||||
assert_eq!(sh.props.prop("contents/#1/contents/rect/size").unwrap().value, KV::Vec2([50.0, 60.0]));
|
||||
assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_eases_and_interpolation() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("K", 100, 100, 1, 5, 25);
|
||||
var l = c.layers.addNull();
|
||||
var r = l.transform.rotation;
|
||||
r.setValuesAtTimes([0, 1, 2], [0, 90, 180]);
|
||||
r.setInterpolationTypeAtKey(2, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.LINEAR);
|
||||
r.setTemporalEaseAtKey(3, [new KeyframeEase(0, 75)]);
|
||||
var idx = r.addKey(1.5);
|
||||
r.removeKey(1);
|
||||
var out = [r.numKeys, idx, r.keyTime(1), r.keyInInterpolationType(1) == KeyframeInterpolationType.HOLD,
|
||||
r.keyOutInterpolationType(1) == KeyframeInterpolationType.LINEAR, r.keyInTemporalEase(3)[0].influence,
|
||||
r.valueAtTime(1, false), r.isTimeVarying, r.nearestKeyIndex(1.9)];
|
||||
l.startTime = 1;
|
||||
out.push(r.keyTime(1));
|
||||
out
|
||||
"#,
|
||||
);
|
||||
let v = o.result.as_array().unwrap();
|
||||
assert_eq!(v[0], json!(3));
|
||||
assert_eq!(v[1], json!(3));
|
||||
assert_eq!(v[2], json!(1));
|
||||
assert_eq!(v[3], json!(true));
|
||||
assert_eq!(v[4], json!(true));
|
||||
assert!((v[5].as_f64().unwrap() - 75.0).abs() < 1e-6, "{v:?}");
|
||||
assert_eq!(v[6], json!(90));
|
||||
assert_eq!(v[7], json!(true));
|
||||
assert_eq!(v[8], json!(3));
|
||||
// Moving the layer moves its keys (layer time): key 1 now sits at 2 s comp time.
|
||||
assert_eq!(v[9], json!(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_documents() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("T", 400, 200, 1, 2, 30);
|
||||
var t = c.layers.addText("abc");
|
||||
var st = t.property("ADBE Text Properties").property("ADBE Text Document");
|
||||
var doc = st.value;
|
||||
doc.text = "Changed";
|
||||
doc.fontSize = 40;
|
||||
doc.fillColor = [1, 0, 0];
|
||||
doc.justification = ParagraphJustification.CENTER_JUSTIFY;
|
||||
doc.applyStroke = true;
|
||||
doc.strokeWidth = 3;
|
||||
st.setValue(doc);
|
||||
var d2 = t.text.sourceText.value;
|
||||
[d2.text, d2.fontSize, d2.fillColor, d2.justification == ParagraphJustification.CENTER_JUSTIFY, d2.strokeWidth, t instanceof TextLayer, t.name]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!(["Changed", 40, [1, 0, 0], true, 3, true, "abc"]));
|
||||
let (_, c) = comp_named(&s, "T");
|
||||
let KV::Text(d) = &c.layers[0].props.prop("text/sourceText").unwrap().value else { panic!() };
|
||||
assert_eq!(d.text, "Changed");
|
||||
assert_eq!(d.style_at(0).size, 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_items_and_markers() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var f = app.project.items.addFolder("Comps");
|
||||
var c = f.items.addComp("Inner", 100, 100, 1, 3, 30);
|
||||
var main = app.project.items.addComp("Outer", 200, 200, 1, 6, 30);
|
||||
var nested = main.layers.add(c);
|
||||
var a = main.layers.addSolid([1, 0, 0], "A", 50, 50, 1, 2);
|
||||
var b = main.layers.addSolid([0, 1, 0], "B", 50, 50, 1);
|
||||
b.moveAfter(nested);
|
||||
a.enabled = false; a.solo = true; a.locked = true; a.shy = true;
|
||||
a.threeDLayer = true;
|
||||
var cam = main.layers.addCamera("Cam", [100, 100]);
|
||||
var lt = main.layers.addLight("Key", [100, 100]);
|
||||
lt.lightType = LightType.SPOT;
|
||||
var d = a.duplicate();
|
||||
d.name = "A copy";
|
||||
var mv = new MarkerValue("hello");
|
||||
mv.duration = 0.5;
|
||||
main.markerProperty.setValueAtTime(1, mv);
|
||||
b.marker.setValueAtTime(0.5, new MarkerValue("layer marker"));
|
||||
b.inPoint = 1; b.outPoint = 3;
|
||||
var pc = main.layers.precompose([b.index], "Pre", true);
|
||||
[c.parentFolder.name, nested.source.name, a.outPoint, a.enabled, a.solo, a.locked, a.shy, a.threeDLayer,
|
||||
cam instanceof CameraLayer, lt.lightType == LightType.SPOT, main.markerProperty.numKeys, main.markerProperty.keyValue(1).comment,
|
||||
main.markerProperty.keyValue(1).duration, pc.name, pc instanceof CompItem, main.layer("A copy").index, app.project.numItems,
|
||||
main.layers.byName("Pre") !== null, f.numItems]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!(["Comps", "Inner", 2, false, true, true, true, true, true, true, 1, "hello", 0.5, "Pre", true, 3, 7, true, 1]));
|
||||
let (_, c) = comp_named(&s, "Outer");
|
||||
assert_eq!(c.markers.len(), 1);
|
||||
assert!(
|
||||
c.layers
|
||||
.iter()
|
||||
.any(|l| l.name == "Key"
|
||||
&& matches!(l.source, effectcraft_engine::project::LayerSource::Light { kind: effectcraft_engine::project::LightKind::Spot }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn security_gate_for_files() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-script-gate-{}", std::process::id()));
|
||||
let proj = dir.join("proj");
|
||||
std::fs::create_dir_all(&proj).unwrap();
|
||||
std::fs::write(proj.join("data.txt"), "inside").unwrap();
|
||||
std::fs::write(dir.join("secret.txt"), "outside").unwrap();
|
||||
let mut s = session();
|
||||
s.path = Some(proj.join("p.ecproj").to_string_lossy().to_string());
|
||||
let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap();
|
||||
// Reading inside the project folder works.
|
||||
let o = ok(&mut s, &format!("var f = new File({}); f.open('r'); var t = f.read(); f.close(); t", q(&proj.join("data.txt"))));
|
||||
assert_eq!(o.result, json!("inside"));
|
||||
// Outside it (also via `..`) is refused.
|
||||
for path in [dir.join("secret.txt"), proj.join("../secret.txt")] {
|
||||
let o = run_code(&mut s, &format!("var f = new File({}); f.open('r');", q(&path)), "gate.jsx");
|
||||
let e = o.error.expect("blocked");
|
||||
assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}");
|
||||
}
|
||||
// Writing is refused anywhere, the network too.
|
||||
let o = run_code(&mut s, &format!("var f = new File({}); f.open('w'); f.write('x'); f.close();", q(&proj.join("out.txt"))), "gate.jsx");
|
||||
assert!(o.error.is_some());
|
||||
assert!(!proj.join("out.txt").exists());
|
||||
assert!(run_code(&mut s, "new Socket()", "net.jsx").error.is_some());
|
||||
assert!(run_code(&mut s, "system.callSystem('ls')", "sys.jsx").error.is_some());
|
||||
// With the preference on, scripts can write and read anywhere.
|
||||
s.prefs.scripting.allow_scripts_write_files = true;
|
||||
ok(&mut s, &format!("var f = new File({}); f.open('w'); f.writeln('written'); f.close();", q(&dir.join("w.txt"))));
|
||||
assert_eq!(std::fs::read_to_string(dir.join("w.txt")).unwrap(), "written\n");
|
||||
let o = ok(&mut s, &format!("var f = new File({}); f.open('r'); f.readln()", q(&dir.join("secret.txt"))));
|
||||
assert_eq!(o.result, json!("outside"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_entry_points() {
|
||||
let mut s = session();
|
||||
// script.run reports errors in the result, not as a command failure.
|
||||
let r = s.execute("script.run", json!({"code": "writeLn('hi'); 1 + 2"})).unwrap();
|
||||
assert_eq!(r["ok"], json!(true));
|
||||
assert_eq!(r["result"], json!(3));
|
||||
assert_eq!(r["output"], json!("hi"));
|
||||
let r = s.execute("script.run", json!({"code": "\nnope()"})).unwrap();
|
||||
assert_eq!(r["ok"], json!(false));
|
||||
assert_eq!(r["error"]["line"], json!(2));
|
||||
// Scripts can't run scripts.
|
||||
let r = s.execute("script.run", json!({"code": "app.run('script.run', {code: '1'})"})).unwrap();
|
||||
assert!(r["error"]["message"].as_str().unwrap().contains("scripts can't run scripts"), "{r}");
|
||||
// File ▸ Scripts ▸ Run Script File… runs .jsx as JavaScript.
|
||||
let dir = std::env::temp_dir().join(format!("ec-script-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("make.jsx");
|
||||
std::fs::write(&f, "app.project.items.addComp('From File', 320, 240, 1, 2, 24);").unwrap();
|
||||
s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap();
|
||||
assert!(s.project.items.values().any(|i| i.name == "From File"));
|
||||
std::fs::write(&f, "\n\nbroken(;").unwrap();
|
||||
let e = s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(e.contains("make.jsx (line 3)"), "{e}");
|
||||
// JSON command scripts still work.
|
||||
let j = dir.join("steps.json");
|
||||
std::fs::write(&j, r#"[{"command": "comp.new", "params": {"name": "From Steps"}}]"#).unwrap();
|
||||
s.execute("file.runScript", json!({"path": j.to_string_lossy()})).unwrap();
|
||||
assert!(s.project.items.values().any(|i| i.name == "From Steps"));
|
||||
// Menu commands by name.
|
||||
let o = ok(&mut s, "app.findMenuCommandId('New Project') > 0 && app.findMenuCommandId('No Such Thing') === 0");
|
||||
assert_eq!(o.result, json!(true));
|
||||
let o = ok(&mut s, "app.executeCommand(app.findMenuCommandId('New Project')); app.project.numItems");
|
||||
assert_eq!(o.result, json!(0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_keeps_variables() {
|
||||
let mut s = session();
|
||||
let req = |code: &'static str| effectcraft_engine::ScriptRequest { code, name: "console", console: true };
|
||||
let r = crate::run(&mut s, &req("var counter = 41;"));
|
||||
assert!(r.error.is_none(), "{r:?}");
|
||||
let r = crate::run(&mut s, &req("counter + 1"));
|
||||
assert_eq!(r.result, json!(42));
|
||||
let _ = Value::Null;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_task_runs_after_the_script() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
"app.scheduleTask('writeLn(\"later\")', 100, false); var id = app.scheduleTask('writeLn(\"never\")', 10, false); app.cancelTask(id); writeLn('now');",
|
||||
);
|
||||
assert_eq!(o.output, vec!["now", "later"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_switches_timing_and_comp_settings() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("S", 320, 240, 1, 5, 30);
|
||||
var matte = c.layers.addText("M");
|
||||
var fill = c.layers.addSolid([1, 1, 1], "Fill", 320, 240, 1);
|
||||
fill.moveToEnd();
|
||||
fill.setTrackMatte(matte, TrackMatteType.LUMA);
|
||||
var t1 = fill.trackMatteType == TrackMatteType.LUMA && fill.hasTrackMatte && fill.trackMatteLayer.name == "M";
|
||||
fill.stretch = 200;
|
||||
fill.label = 3;
|
||||
c.width = 400;
|
||||
c.duration = 8;
|
||||
c.bgColor = [0.1, 0.2, 0.3];
|
||||
c.time = 2;
|
||||
c.workAreaStart = 1;
|
||||
c.workAreaDuration = 3;
|
||||
var extra = c.layers.addNull();
|
||||
extra.remove();
|
||||
fill.selected = true;
|
||||
var sel = c.selectedLayers.length;
|
||||
fill.transform.opacity.expression = "thisLayer.nope(";
|
||||
var err = fill.transform.opacity.expressionError;
|
||||
fill.transform.opacity.expressionEnabled = false;
|
||||
var copy = c.duplicate();
|
||||
var f = app.project.items.addFolder("Bin");
|
||||
copy.parentFolder = f;
|
||||
[t1, fill.stretch, fill.label, c.width, c.duration, c.bgColor[2], c.time, c.workAreaStart, c.workAreaDuration,
|
||||
c.numLayers, sel, err.length > 0, fill.transform.opacity.expressionEnabled, copy.name, f.numItems, copy.parentFolder.name]
|
||||
"#,
|
||||
);
|
||||
let v = o.result;
|
||||
assert_eq!(v[0], json!(true), "{v}");
|
||||
assert_eq!(v[1], json!(200));
|
||||
assert_eq!(v[2], json!(3));
|
||||
assert_eq!(v[3], json!(400));
|
||||
assert_eq!(v[4], json!(8));
|
||||
assert!((v[5].as_f64().unwrap() - 0.3).abs() < 1e-6);
|
||||
assert_eq!(v[6], json!(2));
|
||||
assert_eq!(v[7], json!(1));
|
||||
assert_eq!(v[8], json!(3));
|
||||
assert_eq!(v[9], json!(2));
|
||||
assert_eq!(v[10], json!(1));
|
||||
assert_eq!(v[11], json!(true));
|
||||
assert_eq!(v[12], json!(false));
|
||||
assert_eq!(v[13], json!("S 2"));
|
||||
assert_eq!(v[14], json!(1));
|
||||
assert_eq!(v[15], json!("Bin"));
|
||||
// Removing the copy through the object model.
|
||||
let o = ok(&mut s, "var n = app.project.numItems; app.project.item(1).remove(); n - app.project.numItems");
|
||||
assert_eq!(o.result, json!(1));
|
||||
}
|
||||
|
|
@ -34,10 +34,11 @@ pub enum PanelKind {
|
|||
History,
|
||||
Markers,
|
||||
MaskInterpolation,
|
||||
ScriptConsole,
|
||||
}
|
||||
|
||||
impl PanelKind {
|
||||
pub const ALL: [PanelKind; 24] = [
|
||||
pub const ALL: [PanelKind; 25] = [
|
||||
PanelKind::Project,
|
||||
PanelKind::EffectControls,
|
||||
PanelKind::Composition,
|
||||
|
|
@ -62,6 +63,7 @@ impl PanelKind {
|
|||
PanelKind::History,
|
||||
PanelKind::Markers,
|
||||
PanelKind::MaskInterpolation,
|
||||
PanelKind::ScriptConsole,
|
||||
];
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
|
|
@ -89,6 +91,7 @@ impl PanelKind {
|
|||
PanelKind::History => "History",
|
||||
PanelKind::Markers => "Markers",
|
||||
PanelKind::MaskInterpolation => "Mask Interpolation",
|
||||
PanelKind::ScriptConsole => "Script Console",
|
||||
}
|
||||
}
|
||||
pub fn id(self) -> String {
|
||||
|
|
|
|||
|
|
@ -782,7 +782,7 @@ 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(&["jsonl", "json", "txt"])),
|
||||
"file.runScript" => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
|
||||
"file.replaceFootage" => ("path", Ask::Import),
|
||||
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
|
||||
_ => return None,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ pub mod project;
|
|||
pub mod properties;
|
||||
pub mod puppet_tool;
|
||||
pub mod render_queue;
|
||||
pub mod script_console;
|
||||
pub mod settings;
|
||||
pub mod shortcut_editor;
|
||||
pub mod text_panels;
|
||||
|
|
@ -84,6 +85,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
|
|||
PanelKind::History => misc::history(app, ui, rect),
|
||||
PanelKind::Markers => misc::markers(app, ui, rect),
|
||||
PanelKind::MaskInterpolation => anim_tools::mask_interpolation(app, ui, rect),
|
||||
PanelKind::ScriptConsole => script_console::show(app, ui, rect),
|
||||
PanelKind::Wiggler => anim_tools::wiggler(app, ui, rect),
|
||||
PanelKind::Smoother => anim_tools::smoother(app, ui, rect),
|
||||
PanelKind::MotionSketch => anim_tools::motion_sketch(app, ui, rect),
|
||||
|
|
|
|||
|
|
@ -530,16 +530,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
dresp.on_hover_text("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings");
|
||||
let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0));
|
||||
if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text("Delete selected project items").clicked() {
|
||||
let sel = app.session.state.project_selection.clone();
|
||||
let _ = app.session.edit("Delete Items", None, |proj, st| {
|
||||
for id in &sel {
|
||||
proj.items.remove(id);
|
||||
}
|
||||
st.project_selection.clear();
|
||||
Ok(())
|
||||
});
|
||||
app.session.sanitize_state();
|
||||
if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text("Delete selected project items").clicked()
|
||||
&& !app.session.state.project_selection.is_empty()
|
||||
{
|
||||
actions.push(("project.delete".into(), json!({})));
|
||||
}
|
||||
app.auto.add("project.delete", tr, "Delete");
|
||||
for (id, params) in actions {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
//! Window ▸ Script Console: a JavaScript REPL over the After Effects-style scripting object model
|
||||
//! (`app.project`, comps, layers, properties…). Each run goes through the `script.run` command
|
||||
//! in the console's persistent context, so variables survive between runs; edits are undoable.
|
||||
|
||||
use egui::{Align2, Rect, Sense, pos2, vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::EffectcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// One line of console output.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConsoleLine {
|
||||
pub text: String,
|
||||
/// `input`, `output`, `result` or `error`.
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// Script Console state (serde, so agents can read it).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ScriptConsole {
|
||||
pub input: String,
|
||||
pub log: Vec<ConsoleLine>,
|
||||
/// Previously run inputs (Up/Down recall).
|
||||
#[serde(default)]
|
||||
pub history: Vec<String>,
|
||||
}
|
||||
|
||||
impl ScriptConsole {
|
||||
fn push(&mut self, kind: &str, text: &str) {
|
||||
for l in text.lines() {
|
||||
self.log.push(ConsoleLine { text: l.to_string(), kind: kind.to_string() });
|
||||
}
|
||||
if self.log.len() > 2000 {
|
||||
let extra = self.log.len() - 2000;
|
||||
self.log.drain(..extra);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a `script.run` result.
|
||||
pub fn record(&mut self, code: &str, r: &Result<Value, String>) {
|
||||
self.push("input", &format!("> {}", code.trim()));
|
||||
match r {
|
||||
Ok(v) => {
|
||||
if let Some(o) = v["output"].as_str().filter(|o| !o.is_empty()) {
|
||||
self.push("output", o);
|
||||
}
|
||||
if let Some(e) = v.get("error").filter(|e| !e.is_null()) {
|
||||
let line = e["line"].as_u64().map(|l| format!("line {l}: ")).unwrap_or_default();
|
||||
self.push("error", &format!("{line}{}", e["message"].as_str().unwrap_or("error")));
|
||||
} else if !v["result"].is_null() {
|
||||
self.push("result", &v["result"].to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => self.push("error", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the console input.
|
||||
pub fn run(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let code = app.ui.script_console.input.trim().to_string();
|
||||
if code.is_empty() {
|
||||
return;
|
||||
}
|
||||
let r = crate::menus::invoke(app, ctx, "script.run", json!({"code": code, "name": "Script Console", "console": true}));
|
||||
let con = &mut app.ui.script_console;
|
||||
con.record(&code, &r);
|
||||
if con.history.last() != Some(&code) {
|
||||
con.history.push(code);
|
||||
}
|
||||
con.input.clear();
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let input_h = 86.0;
|
||||
let bar_h = 26.0;
|
||||
let out_rect = Rect::from_min_max(rect.min, pos2(rect.max.x, rect.max.y - input_h - bar_h));
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
p.rect_filled(out_rect, 0.0, t.field_bg);
|
||||
// Output, newest at the bottom.
|
||||
let line_h = 16.0;
|
||||
let visible = ((out_rect.height() - 8.0) / line_h).max(1.0) as usize;
|
||||
let log = &app.ui.script_console.log;
|
||||
let start = log.len().saturating_sub(visible);
|
||||
for (i, l) in log[start..].iter().enumerate() {
|
||||
let color = match l.kind.as_str() {
|
||||
"error" => t.danger,
|
||||
"input" => t.text_faint,
|
||||
"result" => t.accent,
|
||||
_ => t.text,
|
||||
};
|
||||
p.text(pos2(out_rect.min.x + 8.0, out_rect.min.y + 4.0 + i as f32 * line_h), Align2::LEFT_TOP, &l.text, Tokens::mono(11.5), color);
|
||||
}
|
||||
if log.is_empty() {
|
||||
p.text(
|
||||
out_rect.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
"JavaScript with the After Effects scripting object model — try app.project.numItems",
|
||||
Tokens::ui(12.0),
|
||||
t.text_faint,
|
||||
);
|
||||
}
|
||||
app.auto.add("scriptConsole.output", out_rect, "Script Console output");
|
||||
// Input.
|
||||
let in_rect = Rect::from_min_size(pos2(rect.min.x + 4.0, out_rect.max.y + 4.0), vec2(rect.width() - 8.0, input_h - 8.0));
|
||||
let mut run_now = false;
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(in_rect));
|
||||
let te = egui::TextEdit::multiline(&mut app.ui.script_console.input)
|
||||
.id(egui::Id::new("script-console-input"))
|
||||
.font(Tokens::mono(12.0))
|
||||
.desired_width(in_rect.width())
|
||||
.desired_rows(4)
|
||||
.hint_text("Script… (⌘/Ctrl+Enter runs)");
|
||||
let resp = child.add_sized(in_rect.size(), te);
|
||||
if resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command) {
|
||||
run_now = true;
|
||||
}
|
||||
app.auto.add("scriptConsole.input", in_rect, "Script Console input");
|
||||
// Buttons.
|
||||
let bar = Rect::from_min_size(pos2(rect.min.x, rect.max.y - bar_h), vec2(rect.width(), bar_h));
|
||||
let run_r = Rect::from_min_size(pos2(bar.max.x - 64.0, bar.min.y + 2.0), vec2(56.0, 20.0));
|
||||
let clear_r = Rect::from_min_size(pos2(bar.max.x - 128.0, bar.min.y + 2.0), vec2(56.0, 20.0));
|
||||
let run_resp = ui.interact(run_r, egui::Id::new("script-console-run"), Sense::click());
|
||||
p.rect_filled(run_r, 3.0, if run_resp.hovered() { t.accent_hover } else { t.accent });
|
||||
p.text(run_r.center(), Align2::CENTER_CENTER, "Run", Tokens::ui(12.0), egui::Color32::WHITE);
|
||||
let clear_resp = ui.interact(clear_r, egui::Id::new("script-console-clear"), Sense::click());
|
||||
p.rect_stroke(clear_r, 3.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
p.text(clear_r.center(), Align2::CENTER_CENTER, "Clear", Tokens::ui(12.0), t.text);
|
||||
app.auto.add("scriptConsole.run", run_r, "Run");
|
||||
app.auto.add("scriptConsole.clear", clear_r, "Clear");
|
||||
if run_resp.clicked() {
|
||||
run_now = true;
|
||||
}
|
||||
if clear_resp.clicked() {
|
||||
app.ui.script_console.log.clear();
|
||||
}
|
||||
if run_now {
|
||||
let ctx = ui.ctx().clone();
|
||||
run(app, &ctx);
|
||||
}
|
||||
}
|
||||
|
|
@ -490,6 +490,9 @@ impl Default for AnimToolsState {
|
|||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct UiState {
|
||||
pub tool: Tool,
|
||||
/// Window ▸ Script Console input, output and history.
|
||||
#[serde(default)]
|
||||
pub script_console: crate::panels::script_console::ScriptConsole,
|
||||
/// Tool shown in each toolbar slot.
|
||||
pub slot_tools: Vec<Tool>,
|
||||
pub workspace: String,
|
||||
|
|
@ -593,6 +596,7 @@ impl Default for UiState {
|
|||
fn default() -> Self {
|
||||
UiState {
|
||||
tool: Tool::Selection,
|
||||
script_console: Default::default(),
|
||||
slot_tools: Tool::SLOTS.iter().map(|s| s[0]).collect(),
|
||||
workspace: "Default".into(),
|
||||
dock: crate::dock::workspace("Default"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
//! Window ▸ Script Console: the panel shows, runs JavaScript through `script.run` (persistent
|
||||
//! context) and logs output, results and errors (egui_kittest, UI logic only).
|
||||
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui_kittest::Harness;
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
|
||||
let c = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
|
||||
h.input_mut().events.push(egui::Event::PointerMoved(c));
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_console_runs_scripts() {
|
||||
let s = effectcraft_host::session();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(PanelKind::ScriptConsole);
|
||||
h.run_steps(3);
|
||||
for id in ["scriptConsole.input", "scriptConsole.output", "scriptConsole.run", "scriptConsole.clear"] {
|
||||
assert!(h.state().auto.find(id).is_some(), "missing {id}");
|
||||
}
|
||||
h.state_mut().ui.script_console.input = "var c = app.project.items.addComp('Console', 320, 180, 1, 2, 24); writeLn('made'); c.name".into();
|
||||
h.run_steps(1);
|
||||
click(&mut h, "scriptConsole.run");
|
||||
assert!(h.state().session.project.items.values().any(|i| i.name == "Console"));
|
||||
// Variables persist between runs; errors are logged with their line.
|
||||
h.state_mut().ui.script_console.input = "c.numLayers + 5".into();
|
||||
click(&mut h, "scriptConsole.run");
|
||||
h.state_mut().ui.script_console.input = "nope()".into();
|
||||
click(&mut h, "scriptConsole.run");
|
||||
let log: Vec<(String, String)> = h.state().ui.script_console.log.iter().map(|l| (l.kind.clone(), l.text.clone())).collect();
|
||||
assert!(log.contains(&("output".into(), "made".into())), "{log:?}");
|
||||
assert!(log.contains(&("result".into(), "\"Console\"".into())), "{log:?}");
|
||||
assert!(log.contains(&("result".into(), "5".into())), "{log:?}");
|
||||
assert!(log.iter().any(|(k, t)| k == "error" && t.contains("line 1") && t.contains("nope")), "{log:?}");
|
||||
assert!(h.state_mut().session.undo(), "console edits are undoable");
|
||||
click(&mut h, "scriptConsole.clear");
|
||||
assert!(h.state().ui.script_console.log.is_empty());
|
||||
}
|
||||
|
||||
/// Headless look at the panel (wgpu offscreen); run with
|
||||
/// `SC_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test ui_script_console -- --ignored`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn script_console_snapshot() {
|
||||
let s = effectcraft_host::session();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(PanelKind::ScriptConsole);
|
||||
h.run_steps(2);
|
||||
for code in ["var c = app.project.items.addComp('Console', 320, 180, 1, 2, 24); writeLn('made ' + c.name); c.id", "c.layers.addText('Hi').name", "nope()"] {
|
||||
h.state_mut().ui.script_console.input = code.into();
|
||||
h.run_steps(1);
|
||||
click(&mut h, "scriptConsole.run");
|
||||
}
|
||||
h.state_mut().ui.script_console.input = "app.project.numItems".into();
|
||||
h.run_steps(3);
|
||||
let img = h.render().expect("render");
|
||||
let out = std::env::var("SC_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/script_console.png").into());
|
||||
if let Some(d) = std::path::Path::new(&out).parent() {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
img.save(&out).unwrap();
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MC
|
|||
|---|---|
|
||||
| `list_commands {filter?, enabled_only?}` | Discover command ids, their param docs, and whether each can run now. |
|
||||
| `execute_command {command, params?}` | Run any command (undoable). |
|
||||
| `run_script {code, name?}` | Run JavaScript with the After Effects-style scripting object model (`app.project`, `comp.layers.addText(…)`, `layer.property("ADBE Transform Group").property("ADBE Position").setValueAtTime(…)`…). Returns `{ok, result, output, error: {message, line, column}}`; edits are undoable. |
|
||||
| `get_project` / `get_comp {comp?}` | Project items, comp settings and layers. |
|
||||
| `get_layer {layer, comp?, time?, flat?}` | A layer's property tree. Every node has a `path`. |
|
||||
| `get_property {layer, path, comp?, time?}` | Value at a time, keyframes and expression. |
|
||||
|
|
@ -191,6 +192,19 @@ effectcraft-cli exec file.exportLottie '{"comp":"Main","path":"main.json","inclu
|
|||
effectcraft-cli exec file.importLottie '{"path":"anim.json"}' main.ecproj --save
|
||||
```
|
||||
|
||||
Scripts written against After Effects' documented scripting API run with `script`:
|
||||
|
||||
```sh
|
||||
effectcraft-cli script build.jsx --save-as main.ecproj # empty project unless one is given
|
||||
effectcraft-cli script --eval 'app.project.item(1).numLayers' main.ecproj --json
|
||||
effectcraft-cli script tweak.jsx --bridge 9877 # against the live app
|
||||
```
|
||||
|
||||
`writeLn`/`$.writeln`/`alert` output is printed, then the value of the last expression. A script
|
||||
error exits with status 1 and `file:line:col: message`. Scripts may read files only in the
|
||||
project's folder and may not write files or use the network unless the user turns on Preferences ▸
|
||||
Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network.
|
||||
|
||||
`file.exportLottie` returns `{path, bytes, warnings}`: the warnings list every feature Lottie
|
||||
cannot express (most effects, cameras and lights, layer styles, audio, video footage…), so an
|
||||
agent can check what a player will not show. A `.lottie` path writes a dotLottie archive.
|
||||
|
|
|
|||
|
|
@ -31,13 +31,15 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
|
|||
| L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer |
|
||||
| L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express |
|
||||
| L4 | `engine` | `Session`: project, undo history, editor state, the command registry and menus |
|
||||
| L4 | `host` | A fully wired `Session` (media, expressions, exporter) for the frontends |
|
||||
| L4 | `script` | Scripting: JavaScript (boa) with an After Effects-style object model (`app.project`, comps, layers, properties, render queue) whose edits run engine commands; AE match names |
|
||||
| L4 | `host` | A fully wired `Session` (media, expressions, scripting, exporter) for the frontends |
|
||||
| L5 | `ui-egui` | The desktop interface: docking, panels, viewer, timeline, graph editor, dialogs, control channel |
|
||||
| L5 | `automation` | The MCP server, headless or bridged to the running app |
|
||||
| L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) |
|
||||
|
||||
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path, track`,
|
||||
`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine`.
|
||||
`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine, script`,
|
||||
`script → engine`.
|
||||
|
||||
## 2. Time
|
||||
|
||||
|
|
@ -211,6 +213,28 @@ Expressions are JavaScript, run by boa, with After Effects' object model (`thisC
|
|||
`time`, `value`, `wiggle`, `loopOut`, vector maths on arrays, and so on). A syntax error keeps the
|
||||
text, disables the expression and shows a warning, like After Effects.
|
||||
|
||||
## 5a. Scripting
|
||||
|
||||
`effectcraft-script` runs JavaScript (boa, a fresh context per run; the Script Console keeps one)
|
||||
with an object model written from the behaviour the After Effects Scripting Guide documents:
|
||||
`app`, `Project`, `ItemCollection`, `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`,
|
||||
`AVLayer`/`TextLayer`/`ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property`
|
||||
(values, keyframes, eases, expressions), `TextDocument`, `Shape`, `KeyframeEase`, `MarkerValue`,
|
||||
`RenderQueue`/`RenderQueueItem`/`OutputModule`, `File`/`Folder` and the enums. The model lives in
|
||||
`prelude.js`; it reads the session through `__query` (JSON snapshots of items, layers, property
|
||||
nodes, values and keys) and edits only through `__exec`, which runs engine commands, so every
|
||||
scripted edit is undoable and identical to the UI's. While a script runs the session is moved
|
||||
into a thread-local the natives borrow (nothing borrowed enters the JS heap); the comp a call
|
||||
targets is made active for that command only. `app.beginUndoGroup`/`endUndoGroup` fold the undo
|
||||
steps in between into one named step. Match names (`ADBE Transform Group`, `ADBE Position`,
|
||||
`ADBE Gaussian Blur 2`, `ADBE Vector Shape - Rect`…) come from a table in `matchnames.rs`; effect
|
||||
parameters are `<effect match name>-0001…`. `File` reads are limited to the project's folder, and
|
||||
writes and the network are refused, unless Preferences ▸ Scripting & Expressions ▸ Allow Scripts
|
||||
to Write Files and Access Network is on. Entry points: the `script.run` command
|
||||
(`Session::script`, set by the host), File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`; `.json`
|
||||
command scripts still run as steps), Window ▸ Script Console, `effectcraft-cli script` and the
|
||||
MCP `run_script` tool.
|
||||
|
||||
## 6. Commands, the UI seam and automation
|
||||
|
||||
Every action is a command: an id, a label, a menu path, a shortcut, a parameter description,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ rendered, does not count as done.
|
|||
| P1 (professional daily use) | ≈ 38% |
|
||||
| P2 (long tail) | ≈ 17% |
|
||||
| Features done / partial / missing | 18 / 60 / 14 of 92 |
|
||||
| **Effects** | **257 of 298** After Effects 2026 effects by name (86%); ≈ 70% allowing for simplified implementations |
|
||||
| **Effects** | **297 of 298** After Effects 2026 effects by name (99.7%, after M9.11 and Warp Stabilizer; was 257); only the 3D Camera Tracker is left |
|
||||
| Remaining work | ≈ **167 agent-hours** (139 h of features + ≈ 20% for 1:1 polish against After Effects) |
|
||||
| Wall-clock estimate | ≈ **42–50 hours** with five agents working in parallel and one integrating; ≈ 31–35 h for P0 + P1 only |
|
||||
|
||||
|
|
@ -75,10 +75,10 @@ library groundwork for mask tracking and Warp Stabilizer.
|
|||
|
||||
What is left is concentrated in large systems: Roto Brush and Refine Edge, the 3D camera tracker,
|
||||
the Warp Stabilizer and mask-tracking UI on top of
|
||||
the new tracking library, a JavaScript scripting object model, SVG/PSD import, WebM, a disk cache,
|
||||
the missing effect categories (3D Channel, Immersive Video, OCIO), and wasm threads. (Native
|
||||
macOS menus, the Composition Flowchart, Timeline column/search depth, the Home screen and every
|
||||
After Effects workspace landed in the UI-polish wave.)
|
||||
the new tracking library, SVG/PSD import, WebM, a disk cache, and wasm threads. (The missing
|
||||
effect categories, 3D Channel, Immersive Video and OCIO, landed in M9.11; the scripting object
|
||||
model in M14.4; native macOS menus, the Composition Flowchart, Timeline column/search depth, the
|
||||
Home screen and every After Effects workspace in the UI-polish wave.)
|
||||
|
||||
## By area
|
||||
|
||||
|
|
@ -88,29 +88,41 @@ After Effects workspace landed in the UI-polish wave.)
|
|||
| Output | 85% | 4.2 | multiple output modules, pre-render, WebM, audio-only |
|
||||
| Audio | 85% | 0.5 | audio to keyframes |
|
||||
| Import | 78% | 4.5 | PSD, SVG as shapes, Lottie |
|
||||
| Automation | 76% | 3.3 | a JavaScript scripting object model |
|
||||
| Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets |
|
||||
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
|
||||
| Compositions | 72% | 6.0 | Essential Graphics panel (the marker dialog and Composition Flowchart landed) |
|
||||
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
|
||||
| Text | ≈ 86% | 1.3 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no extruded strokes (extruded, bevelled text landed in M7.6); per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
|
||||
| Web | 60% | 2.5 | threads, browser storage, audio |
|
||||
| 3D | 72% | 12.0 | 3D camera tracker, multi-view layouts, Advanced 3D motion blur and blend modes, cameras/lights from models; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) landed in M7.4–M7.6 |
|
||||
| Effects | 58% | 13.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU versions of more effects, 41 missing effects |
|
||||
| Effects | ≈ 75% | 5.0 | GPU versions of more effects and the 3D Camera Tracker (every other After Effects effect exists since M9.11 and M12.5) |
|
||||
| Interface | 70% | 6.0 | Timeline horizontal scrolling of many columns, a richer Learn area (native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
|
||||
| Masks & roto | 52% | 10.0 | Roto Brush, variable-width mask feather points (mask tracking and Mask Interpolation landed in M6.6) |
|
||||
| Preview | 55% | 6.1 | GPU 3D and adjustment layers, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
| Tracking | ≈ 70% | 4.0 | 3D camera tracker, face tracking, Subspace Warp's mesh warp and rolling-shutter repair (point tracker, mask tracking and Warp Stabilizer landed in M6.x / M12.5) |
|
||||
| Tracking | ≈ 70% | 4.0 | 3D camera tracker, face tracking and Subspace Warp's mesh warp (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking and Warp Stabilizer landed in M6.x / M12.5) |
|
||||
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
|
||||
|
||||
## Effects still missing
|
||||
|
||||
Immersive Video (all 12), 3D Channel (all 8, including Cryptomatte), Color Stabilizer and the five
|
||||
OCIO transforms, CC Flo Motion, Liquify, Rolling Shutter Repair, Compressor,
|
||||
Distortion and Gate (audio), Camera-Shake Deblur, CC Radial Blur, CC Hair, Particle Playground,
|
||||
Keylight (our Screen Key stands in), Color Profile Converter and the 3D Camera Tracker. Boris FX
|
||||
The 3D Camera Tracker (built on the tracking library by other work). Boris FX
|
||||
Mocha and Cineware are third-party and not counted.
|
||||
|
||||
### Added in M9.11 (39 effects)
|
||||
|
||||
| Category | Effects | Notes |
|
||||
|---|---|---|
|
||||
| 3D Channel | 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, Fog 3D, ID Matte, IDentifier | Read auxiliary channels: multi-layer OpenEXR (any `layer.channel`, Cryptomatte manifests from the header) or the compositor's own depth / layer-ID / normal / UV / Cryptomatte pass for precomps of 3D comps. |
|
||||
| Immersive Video | VR Blur, VR Chromatic Aberrations, VR Color Gradients, VR Converter, VR De-Noise, VR Digital Glitch, VR Fractal Noise, VR Glow, VR Plane to Sphere, VR Rotate Sphere, VR Sharpen, VR Sphere To Plane | Seam-aware equirectangular processing (wrap-around, pole continuation, latitude-scaled filters), mono / over-under / side-by-side layouts; VR Converter between equirectangular, cube maps (4:3, 3:2, 6:1), sphere map and 2D. |
|
||||
| Color Correction | OCIO CDL / Color Space / Display / File / Look Transform, Color Stabilizer | Built-in minimal OCIO-style config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published matrices and transfer functions; `.cube` / `.3dl` / `.csp` LUTs and ASC `.cc` / `.ccc` / `.cdl`. Custom `.ocio` config files are not read yet. |
|
||||
| Distort | CC Flo Motion, Liquify, Rolling Shutter Repair | Liquify strokes are effect data replayed into a displacement mesh (`liquify.stroke` / `liquify.clear` commands; no interactive viewer brush yet). Rolling Shutter Repair uses the KLT tracker. |
|
||||
| Blur & Sharpen | Camera-Shake Deblur, CC Radial Blur | Deblur substitutes aligned patches from sharper neighbouring frames. |
|
||||
| Audio | Compressor, Distortion, Gate | Applied in the mixdown like the other audio effects. |
|
||||
| Simulation | CC Hair, Particle Playground | Particle Playground: cannon, grid, layer exploder, layer map, gravity, repel, wall, persistent property mapper (no Particle Exploder, text particles or ephemeral mapper yet). |
|
||||
| Keying | Key Light | The full Keylight 1.2 control set under a generic name ("Keylight" is a vendor trademark); `lookup("Keylight (1.2)")` finds it. |
|
||||
| Utility | Color Profile Converter | Our colour spaces and ACES; rendering intents (perceptual gamut compression, relative / absolute colorimetric, saturation). |
|
||||
| Obsolete | mocha shape | Mocha's export format is not public: reads a documented JSON shape format instead. |
|
||||
|
||||
## Highest-value gaps, in order
|
||||
|
||||
1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const LAYERS: &[(&str, u8)] = &[
|
|||
("lottie", 3),
|
||||
("format", 3),
|
||||
("engine", 4),
|
||||
("script", 4),
|
||||
("host", 4),
|
||||
("ui-egui", 5),
|
||||
("automation", 5),
|
||||
|
|
@ -64,6 +65,8 @@ const SAME_LAYER: &[(&str, &str)] = &[
|
|||
("gpu", "render"),
|
||||
("lottie", "format"),
|
||||
("host", "engine"),
|
||||
("script", "engine"),
|
||||
("host", "script"),
|
||||
("cli", "effectcraft"),
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue