mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-08 16:30:46 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
Generated
+1
@@ -1653,6 +1653,7 @@ version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -155,7 +155,7 @@ tools, and Front, Top, Left and Custom views.
|
||||
|
||||
A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and
|
||||
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1**
|
||||
MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
|
||||
MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF, Targa and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
|
||||
rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and
|
||||
resize, Render Settings and Output Module templates with defaults, post-render actions, storage
|
||||
overflow and render logs. The same queue runs from the command line. Every encoder is pure
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
//! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`)
|
||||
//! effectcraft-cli run <id> <json> [<id> <json> …] run several commands in order
|
||||
//! effectcraft-cli run --args-file F the same, one argument per line of F
|
||||
//! effectcraft-cli run --commands FILE|- steps {"command", "params"} from FILE or stdin
|
||||
//! effectcraft-cli props <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
|
||||
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
|
||||
//! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent]
|
||||
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S]
|
||||
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff] [--start S] [--end S]
|
||||
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
|
||||
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
|
||||
@@ -98,6 +99,10 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
exec <command-id> [--params JSON] run one engine command (exec --list: list them, as `commands`)
|
||||
run <id> <json> [<id> <json> ...] run several commands in order
|
||||
run --args-file F the same, one argument per line of F (no command-line length limit)
|
||||
run --commands FILE|- steps from FILE, or standard input for -: a JSON array of
|
||||
{\"command\": ID, \"params\": {...}} (or \"id\" for \"command\"), or one
|
||||
such object per line (JSON Lines); at most 64 MiB and 100000 steps.
|
||||
Repeatable; run after the <id> <json> pairs and --args-file
|
||||
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
|
||||
get <comp> <layer> <path> [--time S] read a property
|
||||
set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
@@ -107,7 +112,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off]
|
||||
[--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality N]
|
||||
[--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] | --queue
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff;
|
||||
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
|
||||
a relative --out is relative to the working directory
|
||||
--progress: progress lines on stderr when it is not a terminal
|
||||
@@ -151,6 +156,7 @@ const VALUED: &[&str] = &[
|
||||
"--depth",
|
||||
"--eval",
|
||||
"--args-file",
|
||||
"--commands",
|
||||
// render
|
||||
"--template",
|
||||
"--values",
|
||||
@@ -337,6 +343,87 @@ impl From<String> for Failure {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input.
|
||||
const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// Most steps one `run --commands` takes.
|
||||
const MAX_COMMANDS_STEPS: usize = 100_000;
|
||||
|
||||
/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past
|
||||
/// [`MAX_COMMANDS_BYTES`]. This reader, [`parse_commands`] and [`command_step`] are ported from
|
||||
/// vectorcraft-cli's (storytold/vectorcraft#3630), with a tuple in place of its `Step` enum.
|
||||
fn read_commands(source: &str) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
let mut bytes = vec![];
|
||||
let read = if source == "-" {
|
||||
std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)
|
||||
} else {
|
||||
std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes))
|
||||
};
|
||||
read.map_err(|e| format!("--commands {source}: {e}"))?;
|
||||
if bytes.len() as u64 > MAX_COMMANDS_BYTES {
|
||||
return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024)));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text"))
|
||||
}
|
||||
|
||||
/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params":
|
||||
/// {…}}` objects (`"id"` may stand for `"command"`), or one such object per line (JSON Lines, blank
|
||||
/// lines skipped). `first` is the run's index of the first of them, which an error names with the
|
||||
/// entry's place in the text.
|
||||
fn parse_commands(source: &str, text: &str, first: usize) -> Result<Vec<(String, Value)>, String> {
|
||||
// Notepad on Windows starts UTF-8 files with a byte order mark.
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps");
|
||||
// Each entry with where it is in the text: its line for JSON Lines.
|
||||
let mut entries: Vec<(Option<usize>, Value)> = vec![];
|
||||
if text.trim_start().starts_with('[') {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?;
|
||||
let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) };
|
||||
if items.len() > MAX_COMMANDS_STEPS {
|
||||
return Err(too_many());
|
||||
}
|
||||
entries.extend(items.into_iter().map(|v| (None, v)));
|
||||
} else {
|
||||
for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
|
||||
if entries.len() == MAX_COMMANDS_STEPS {
|
||||
return Err(too_many());
|
||||
}
|
||||
let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?;
|
||||
entries.push((Some(n + 1), v));
|
||||
}
|
||||
}
|
||||
entries
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(k, (line, v))| {
|
||||
command_step(v).map_err(|e| {
|
||||
let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}"));
|
||||
format!("--commands {source}: step {} ({place}): {e}", first + k)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The command id and params of one `--commands` entry, `{"command": ID, "params": {…}}`.
|
||||
fn command_step(v: Value) -> Result<(String, Value), String> {
|
||||
let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"comp.new\", \"params\": {}}".into()) };
|
||||
let id = match (o.remove("command"), o.remove("id")) {
|
||||
(Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id,
|
||||
(Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()),
|
||||
(None, None) => return Err("no `command`".into()),
|
||||
_ => return Err("`command` must be a string, a command id".into()),
|
||||
};
|
||||
let params = match o.remove("params") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(p @ Value::Object(_)) => p,
|
||||
Some(_) => return Err(format!("`params` of {id} must be a JSON object")),
|
||||
};
|
||||
if let Some(key) = o.keys().next() {
|
||||
return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`"));
|
||||
}
|
||||
Ok((id, params))
|
||||
}
|
||||
|
||||
fn usage_err<T>(m: &str) -> Result<T, Failure> {
|
||||
Err(Failure::Usage(m.to_string()))
|
||||
}
|
||||
@@ -527,14 +614,14 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?;
|
||||
pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from));
|
||||
}
|
||||
if pairs.is_empty() {
|
||||
return usage_err("run needs <command-id> [<json>] pairs");
|
||||
let sources = args.opts("--commands");
|
||||
if pairs.is_empty() && sources.is_empty() {
|
||||
return usage_err("run needs <command-id> [<json>] pairs or --commands FILE|-");
|
||||
}
|
||||
let mut b = backend(args, true)?;
|
||||
let mut results = vec![];
|
||||
let mut steps = vec![];
|
||||
let mut i = 0;
|
||||
while i < pairs.len() {
|
||||
let id = &pairs[i];
|
||||
let id = pairs[i].clone();
|
||||
let params = match pairs.get(i + 1).filter(|p| p.trim_start().starts_with('{')) {
|
||||
Some(p) => {
|
||||
i += 1;
|
||||
@@ -543,9 +630,26 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
None => json!({}),
|
||||
};
|
||||
i += 1;
|
||||
let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?;
|
||||
if let Some(e) = failed_render(id, &r) {
|
||||
return Err(Failure::Error(format!("{id}: {e}")));
|
||||
steps.push((id, params));
|
||||
}
|
||||
if sources.iter().filter(|s| **s == "-").count() > 1 {
|
||||
return usage_err("--commands - is given twice: standard input is read once");
|
||||
}
|
||||
for &source in &sources {
|
||||
let text = read_commands(source).map_err(Failure::Usage)?;
|
||||
steps.extend(parse_commands(source, &text, steps.len()).map_err(Failure::Usage)?);
|
||||
}
|
||||
if steps.is_empty() {
|
||||
return Err(Failure::Usage(format!("--commands {}: no commands", sources.join(", "))));
|
||||
}
|
||||
let mut b = backend(args, true)?;
|
||||
let mut results = vec![];
|
||||
// The step's index closes the message: a generated list repeats ids, and callers match
|
||||
// the leading `<id>: `.
|
||||
for (n, (id, params)) in steps.into_iter().enumerate() {
|
||||
let r = b.exec(&id, params).map_err(|e| Failure::Error(format!("{id}: {e} (step {n})")))?;
|
||||
if let Some(e) = failed_render(&id, &r) {
|
||||
return Err(Failure::Error(format!("{id}: {e} (step {n})")));
|
||||
}
|
||||
if !json_out {
|
||||
emit(&r, false);
|
||||
|
||||
@@ -133,6 +133,78 @@ fn run_reads_arguments_from_a_file() {
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--args-file no/such/file.txt"));
|
||||
}
|
||||
|
||||
/// Run with `stdin` piped in; returns (exit code, stdout, stderr).
|
||||
fn run_with_stdin(args: &[&str], stdin: &str) -> (i32, String, String) {
|
||||
let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap();
|
||||
let out = child.wait_with_output().unwrap();
|
||||
(out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned())
|
||||
}
|
||||
|
||||
/// `run --commands FILE|-` (#2251) reads `{"command", "params"}` steps from a JSON array or JSON
|
||||
/// Lines, from a file or standard input, and runs them as the same positional pairs would: after
|
||||
/// the pairs, so a generated list can follow a hand-written prefix. One command whose params are
|
||||
/// over Linux's 128 KiB per-argument limit goes through.
|
||||
#[test]
|
||||
fn run_reads_commands_from_a_file_or_stdin() {
|
||||
let steps = [
|
||||
("comp.new", json!({"name": "A", "width": 64, "height": 64})),
|
||||
("layer.newSolid", json!({"width": 8, "height": 8, "name": "One"})),
|
||||
("layer.newSolid", json!({"width": 8, "height": 8, "name": "Two"})),
|
||||
("project.summary", json!({})),
|
||||
];
|
||||
let pairs: Vec<String> = steps.iter().flat_map(|(id, p)| [id.to_string(), p.to_string()]).collect();
|
||||
let pairs: Vec<&str> = pairs.iter().map(String::as_str).collect();
|
||||
let expected = ok_json(&[&["run", "--empty"][..], &pairs[..]].concat());
|
||||
let layers = |v: &Value| v[3]["result"]["items"][0]["layers"].as_u64();
|
||||
assert_eq!(layers(&expected), Some(2), "{expected}");
|
||||
|
||||
let objects: Vec<Value> = steps.iter().map(|(id, p)| json!({"command": id, "params": p})).collect();
|
||||
let array = tmp("commands.json");
|
||||
std::fs::write(&array, serde_json::to_string_pretty(&objects).unwrap()).unwrap();
|
||||
assert_eq!(ok_json(&["run", "--commands", array.to_str().unwrap(), "--empty"]), expected);
|
||||
|
||||
let lines: String = objects.iter().map(|o| format!("{o}\n\n")).collect();
|
||||
let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], &lines);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&out).unwrap(), expected);
|
||||
|
||||
// Positional pairs first, then the file.
|
||||
let rest = tmp("commands-rest.jsonl");
|
||||
std::fs::write(&rest, objects[1..].iter().map(|o| format!("{o}\n")).collect::<String>()).unwrap();
|
||||
assert_eq!(ok_json(&[&["run", "--empty", "--commands", rest.to_str().unwrap()][..], &pairs[..2]].concat()), expected);
|
||||
|
||||
let name = "n".repeat(140 * 1024);
|
||||
let big = tmp("commands-big.jsonl");
|
||||
std::fs::write(&big, format!("{}\n{}\n", json!({"command": "comp.new", "params": {"name": name}}), json!({"command": "project.summary"}))).unwrap();
|
||||
let v = ok_json(&["run", "--commands", big.to_str().unwrap(), "--empty"]);
|
||||
assert_eq!(v[1]["result"]["items"][0]["name"].as_str().map(str::len), Some(name.len()));
|
||||
|
||||
for (input, args, error) in [
|
||||
("", &["run", "--commands", "-", "--empty"][..], "--commands -: no commands"),
|
||||
(
|
||||
"{\"command\":\"project.summary\"}\n{\"command\":\"x\",\"param\":{}}\n",
|
||||
&["run", "project.summary", "{}", "--commands", "-", "--empty"],
|
||||
"step 2 (line 2): unknown key `param`",
|
||||
),
|
||||
("[{\"command\":\"project.summary\"}, {\"params\":{}}]", &["run", "--commands", "-", "--empty"], "step 1 (entry 2): no `command`"),
|
||||
("{}", &["run", "--commands", "-", "--commands", "-", "--empty"], "standard input is read once"),
|
||||
] {
|
||||
let (code, out, err) = run_with_stdin(args, input);
|
||||
assert_eq!(code, 2, "{input:?}: {err}");
|
||||
assert!(err.contains(error), "{input:?}: {err}");
|
||||
assert!(out.is_empty(), "{input:?}: nothing ran");
|
||||
}
|
||||
// An empty list after positional pairs is no error: there is still something to run.
|
||||
let (code, _, err) = run_with_stdin(&["run", "project.summary", "--commands", "-", "--empty"], "");
|
||||
assert_eq!(code, 0, "{err}");
|
||||
// A step that fails when it runs is named by its index too.
|
||||
let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], "{\"command\":\"project.summary\"}\n{\"command\":\"no.such\"}\n");
|
||||
assert_eq!(code, 1, "{err}");
|
||||
let e = serde_json::from_str::<Value>(&out).unwrap()["error"].as_str().map(String::from).unwrap_or_default();
|
||||
assert!(e.starts_with("no.such: ") && e.ends_with(" (step 1)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_frame_writes_png() {
|
||||
let out = tmp("f.png");
|
||||
|
||||
@@ -366,6 +366,21 @@ impl ConfigStore for WebConfig {
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
/// The first name part of the keys under `dir/`.
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/'));
|
||||
let mut v: Vec<String> = self
|
||||
.store
|
||||
.lock()
|
||||
.list(&pre)
|
||||
.into_iter()
|
||||
.filter_map(|(k, ..)| k.strip_prefix(&pre)?.split_once('/').map(|(d, _)| d.to_string()))
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
Some(PathBuf::from("/"))
|
||||
}
|
||||
@@ -533,6 +548,22 @@ mod tests {
|
||||
assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..]));
|
||||
}
|
||||
|
||||
/// Scripts in a subfolder of the Scripts folder (`Tools/Hello.jsx`) are listed, as on the desktop.
|
||||
#[test]
|
||||
fn config_store_lists_folders() {
|
||||
let c = WebConfig::new(store());
|
||||
for name in ["Scripts/Top.jsx", "Scripts/Tools/Hello.jsx", "Scripts/Tools/More.jsx", "Scripts/ScriptUI Panels/Kit/A.jsx"] {
|
||||
c.write(name, "x").unwrap();
|
||||
}
|
||||
assert_eq!(c.list("Scripts"), ["Top.jsx"]);
|
||||
assert_eq!(c.list_dirs("Scripts"), ["ScriptUI Panels", "Tools"]);
|
||||
assert_eq!(c.list_dirs("Scripts/ScriptUI Panels"), ["Kit"]);
|
||||
let mut s = effectcraft_engine::Session { config: Some(Arc::new(c)), ..Default::default() };
|
||||
let list = s.execute("file.scripts.list", serde_json::json!({})).unwrap();
|
||||
let installed: Vec<&str> = list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").filter_map(|e| e["name"].as_str()).collect();
|
||||
assert_eq!(installed, ["Tools/Hello.jsx", "Tools/More.jsx", "Top.jsx", "Kit/A.jsx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_store_and_file_ops() {
|
||||
let s = store();
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
//! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too,
|
||||
//! and commands that write to a file they are not given (Save, Increment and Save, rendering the
|
||||
//! Render Queue) are checked against where they would write. Commands that run code or open
|
||||
//! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined.
|
||||
//! other applications (scripts, ScriptUI panels and the events that reach them, Execute File,
|
||||
//! Reveal in Finder…) are refused while confined, as is granting scripts the file and network
|
||||
//! access the user withheld.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
@@ -35,6 +37,15 @@ const REFUSED: &[&str] = &[
|
||||
"file.installScript",
|
||||
"file.installScriptUIPanel",
|
||||
"file.uninstallScript",
|
||||
// A ScriptUI panel's script runs when the panel opens, when an event reaches one of its
|
||||
// controls, and when one of its `app.scheduleTask` tasks comes due.
|
||||
"window.scriptPanel",
|
||||
"window.restoreScriptPanels",
|
||||
"script.runTasks",
|
||||
"scriptui.click",
|
||||
"scriptui.set",
|
||||
"scriptui.mouse",
|
||||
"scriptui.close",
|
||||
"file.executeFile",
|
||||
"file.revealInFinder",
|
||||
"layer.openSource",
|
||||
@@ -64,8 +75,26 @@ const READS: &[(&str, &[&str])] = &[
|
||||
("effect.plugins.load", &["path", "folder"]),
|
||||
("mediaBrowser.go", &["path"]),
|
||||
("mediaBrowser.addFavorite", &["path"]),
|
||||
// #1243: these read the files they are given but were missing, so a relative path went
|
||||
// unchecked.
|
||||
("essential.templateInfo", &["path"]),
|
||||
("file.importProject", &["path"]),
|
||||
("file.watchFolder.poll", &["folder"]),
|
||||
("footage.relink", &["path"]),
|
||||
("layer.newModel", &["path"]),
|
||||
("mediaBrowser.fileInfo", &["path"]),
|
||||
("mediaBrowser.list", &["path"]),
|
||||
("mediaBrowser.import", &["paths"]),
|
||||
("mediaBrowser.removeFavorite", &["path"]),
|
||||
("face.model.install", &["path"]),
|
||||
("roto.model.install", &["path"]),
|
||||
// Reads the template at `path`; what it writes is in `MIXED_WRITES`.
|
||||
("templates.create", &["path"]),
|
||||
];
|
||||
|
||||
/// Commands that read some parameters (listed in [`READS`]) and write others.
|
||||
const MIXED_WRITES: &[(&str, &[&str])] = &[("templates.create", &["projectPath", "footageDir"])];
|
||||
|
||||
/// Commands that write files named by these parameters.
|
||||
const WRITES: &[(&str, &[&str])] = &[
|
||||
("file.save", &["path"]),
|
||||
@@ -84,6 +113,9 @@ const WRITES: &[(&str, &[&str])] = &[
|
||||
("essential.exportTemplate", &["path"]),
|
||||
("shortcuts.export", &["path"]),
|
||||
("renderQueue.add", &["output", "path"]),
|
||||
("file.exportTimeline", &["path"]),
|
||||
// `path` / `output` may also be a file-name template, as for render.preRender.
|
||||
("file.createProxy", &["path", "output"]),
|
||||
];
|
||||
|
||||
/// Commands that write where they are not told: the project's file, the Render Queue's outputs.
|
||||
@@ -138,13 +170,23 @@ impl Confinement {
|
||||
if REFUSED.contains(&id) {
|
||||
return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
|
||||
}
|
||||
// Scripts can't grant themselves the access the user withheld
|
||||
// (`effectcraft_engine::prefs::Scripting::same_permissions`), and neither may a confined
|
||||
// agent: `prefs.set` saves the settings file, so a script run long after this session
|
||||
// would still have it. (`prefs.reset` only puts the defaults back, which grant nothing.)
|
||||
if id == "prefs.set" && prefs_keys(params).iter().any(|k| is_scripting_pref(k)) {
|
||||
return Err(Error::BadArgs(
|
||||
"`prefs.set`: the Scripting settings decide what a script may do, so they can't be changed while file access is confined".into(),
|
||||
));
|
||||
}
|
||||
if id == "engine.batch" {
|
||||
for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() {
|
||||
let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue };
|
||||
let inner_params = step.get("params").cloned().unwrap_or(Value::Null);
|
||||
let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]);
|
||||
let mixed = keys_of(MIXED_WRITES, inner).unwrap_or(&[]);
|
||||
// A step's `$N` reference is resolved only as the batch runs: never for a file.
|
||||
if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) {
|
||||
if named.iter().chain(mixed).filter_map(|k| inner_params.get(*k)).any(has_reference) {
|
||||
return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined")));
|
||||
}
|
||||
if self.check_command(inner, &inner_params)?.is_some() {
|
||||
@@ -170,7 +212,7 @@ impl Confinement {
|
||||
for p in paths {
|
||||
// A Render Queue output may name a template instead of a file.
|
||||
match p.as_str() {
|
||||
Some(s) if id == "render.preRender" && !looks_like_path(s) => {}
|
||||
Some(s) if matches!(id, "render.preRender" | "file.createProxy") && !looks_like_path(s) => {}
|
||||
Some(s) => {
|
||||
self.check(s, access)?;
|
||||
}
|
||||
@@ -178,9 +220,18 @@ impl Confinement {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Parameters of a reading command that it writes to.
|
||||
let mixed = keys_of(MIXED_WRITES, id).unwrap_or(&[]);
|
||||
for key in mixed {
|
||||
if let Some(p) = params.get(*key).filter(|v| !v.is_null()) {
|
||||
let p = p.as_str().ok_or_else(|| Error::BadArgs(format!("`{id}`: `{key}` must be a path")))?;
|
||||
self.check(p, Access::Write)?;
|
||||
}
|
||||
}
|
||||
// Any other absolute path the command is given.
|
||||
let named: Vec<&str> = named.iter().chain(mixed).copied().collect();
|
||||
let mut other = vec![];
|
||||
absolute_strings(params, named, &mut other);
|
||||
absolute_strings(params, &named, &mut other);
|
||||
for s in other {
|
||||
self.check(&s, access)?;
|
||||
}
|
||||
@@ -218,6 +269,22 @@ impl Confinement {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `prefs.set` key reaches the Scripting page. `Prefs::set` turns the key into a JSON
|
||||
/// pointer (`key.replace('.', "/")`), so `scripting/allowScriptsWriteFiles` resolves as well as
|
||||
/// the dotted spelling, and the page on its own replaces every setting on it.
|
||||
fn is_scripting_pref(key: &str) -> bool {
|
||||
key.split(['.', '/']).next().is_some_and(|page| page.eq_ignore_ascii_case("scripting"))
|
||||
}
|
||||
|
||||
/// The settings `prefs.set` is given: `key`, and the keys of `values`.
|
||||
fn prefs_keys(params: &Value) -> Vec<&str> {
|
||||
let mut keys: Vec<&str> = params.get("key").and_then(Value::as_str).into_iter().collect();
|
||||
if let Some(Value::Object(m)) = params.get("values") {
|
||||
keys.extend(m.keys().map(String::as_str));
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn keys_of(table: &[(&str, &'static [&'static str])], id: &str) -> Option<&'static [&'static str]> {
|
||||
table.iter().find(|(c, _)| *c == id).map(|(_, k)| *k)
|
||||
}
|
||||
@@ -274,3 +341,114 @@ fn resolve(p: &Path) -> Option<PathBuf> {
|
||||
at = at.parent()?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Commands whose parameters are named like files but aren't: property paths
|
||||
/// (`path|prop`), project folders, layers to track or target.
|
||||
const NOT_FILES: &[&str] = &[
|
||||
"camera.createFromSolve",
|
||||
"edit.label",
|
||||
"edit.pasteReversedKeyframes",
|
||||
"effect.editDropdown",
|
||||
"essential.addProperty",
|
||||
"essential.canAdd",
|
||||
"essential.linkProperty",
|
||||
"essential.unlinkProperty",
|
||||
"keys.paste",
|
||||
"keys.set",
|
||||
"keys.setEase",
|
||||
"keys.setSpatialTangents",
|
||||
"paths.nullsFollowPoints",
|
||||
"paths.pointsFollowNulls",
|
||||
"paths.tracePath",
|
||||
"project.move",
|
||||
"prop.addKey",
|
||||
"prop.convertExpressionToKeyframes",
|
||||
"prop.deselect",
|
||||
"prop.editGradient",
|
||||
"prop.get",
|
||||
"prop.pickWhip",
|
||||
"prop.reset",
|
||||
"prop.select",
|
||||
"prop.set",
|
||||
"prop.setExpression",
|
||||
"prop.toggleAnimation",
|
||||
"prop.toggleKey",
|
||||
"track.new",
|
||||
"track.property",
|
||||
"track.setTarget",
|
||||
];
|
||||
|
||||
/// Every engine command with a parameter named like a file is classified, so a new one
|
||||
/// can't silently skip confinement (#1243): it reads or writes the files it is given, is
|
||||
/// refused, writes where it isn't told (checked by a lookup), sets Render Queue outputs
|
||||
/// (checked when the queue renders), or is listed in `NOT_FILES`.
|
||||
#[test]
|
||||
fn every_command_with_file_parameters_is_classified() {
|
||||
const FILE_KEYS: &[&str] =
|
||||
&["path", "paths", "folder", "file", "files", "dir", "directory", "output", "outputs", "source", "target", "projectPath", "footageDir"];
|
||||
let mut missing = vec![];
|
||||
for spec in effectcraft_engine::command_specs() {
|
||||
let Some(params) = effectcraft_engine::commands::accepted_params(spec.params) else { continue };
|
||||
let id = spec.id;
|
||||
let classified = keys_of(READS, id).is_some()
|
||||
|| keys_of(WRITES, id).is_some()
|
||||
|| REFUSED.contains(&id)
|
||||
|| IMPLICIT_WRITES.contains(&id)
|
||||
|| id.starts_with("renderQueue.")
|
||||
|| id == "render.addOutputModule"
|
||||
|| NOT_FILES.contains(&id);
|
||||
if params.iter().any(|p| FILE_KEYS.contains(&p.as_str())) && !classified {
|
||||
missing.push(format!("{id} {}", spec.params));
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "add these to READS / WRITES / REFUSED or NOT_FILES:\n{}", missing.join("\n"));
|
||||
// And every listed command exists.
|
||||
for id in READS.iter().chain(WRITES).chain(MIXED_WRITES).map(|(c, _)| *c).chain(NOT_FILES.iter().copied()) {
|
||||
assert!(effectcraft_engine::command_specs().iter().any(|s| s.id == id), "{id} is not a command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_and_outside_paths_of_newly_listed_commands_are_refused() {
|
||||
let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere"));
|
||||
for d in [&read, &write, &outside] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap();
|
||||
let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned();
|
||||
let up = format!("{}/../elsewhere/t.ectemplate", read.display());
|
||||
for (id, key) in [
|
||||
("essential.templateInfo", "path"),
|
||||
("file.importProject", "path"),
|
||||
("footage.relink", "path"),
|
||||
("layer.newModel", "path"),
|
||||
("mediaBrowser.fileInfo", "path"),
|
||||
("mediaBrowser.list", "path"),
|
||||
("face.model.install", "path"),
|
||||
("templates.create", "path"),
|
||||
] {
|
||||
for bad in ["t.ectemplate".to_string(), "../elsewhere/t.ectemplate".to_string(), up.clone(), p(&outside, "t.ectemplate")] {
|
||||
assert!(c.check_command(id, &serde_json::json!({key: bad})).is_err(), "{id} {key}={bad}");
|
||||
}
|
||||
assert!(c.check_command(id, &serde_json::json!({key: p(&read, "t.ectemplate")})).is_ok(), "{id} in a read root");
|
||||
}
|
||||
// Batched too.
|
||||
let steps = serde_json::json!({"steps": [{"command": "essential.templateInfo", "params": {"path": "t.ectemplate"}}]});
|
||||
assert!(c.check_command("engine.batch", &steps).is_err());
|
||||
// Writes: into a write root only.
|
||||
assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&read, "cut.xml")})).is_err());
|
||||
assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&write, "cut.xml")})).is_ok());
|
||||
let create = |project: String| serde_json::json!({"path": p(&read, "t.ectemplate"), "projectPath": project});
|
||||
assert!(c.check_command("templates.create", &create(p(&read, "new.ecproj"))).is_err(), "templates.create writes projectPath");
|
||||
assert!(c.check_command("templates.create", &create(p(&write, "new.ecproj"))).is_ok());
|
||||
assert!(c.check_command("file.createProxy", &serde_json::json!({"output": "[compName]_proxy"})).is_ok(), "a template name");
|
||||
assert!(c.check_command("file.createProxy", &serde_json::json!({"output": p(&outside, "proxy.mov")})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1416,3 +1416,60 @@ fn automation_roots_confine_file_access() {
|
||||
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// #2131 (audit F-11, "Running code"): `script.run` and the Scripts menu are refused while file
|
||||
/// access is confined, but a ScriptUI panel's script runs just as freely — when the panel opens,
|
||||
/// when an event reaches one of its controls, or when one of its tasks comes due — and
|
||||
/// `prefs.set` could grant every script run afterwards the file and network access the user
|
||||
/// withheld, in the settings file, long after the session. All of that is refused too.
|
||||
#[test]
|
||||
fn automation_roots_refuse_running_script_code() {
|
||||
use crate::confine::Confinement;
|
||||
let base = std::env::temp_dir().join(format!("ec-mcp-scripts-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let mut s = server();
|
||||
s.backend().confine(Confinement::new(&[], std::slice::from_ref(&base)).unwrap());
|
||||
let refused = |s: &mut McpServer, name: &str, args: Value| {
|
||||
let (c, err) = call(s, name, args.clone());
|
||||
assert!(err, "{name} {args} should be refused: {c:?}");
|
||||
c[0]["text"].as_str().unwrap_or_default().to_string()
|
||||
};
|
||||
// Every way a script's code starts.
|
||||
for (command, params) in [
|
||||
("window.scriptPanel", json!({"name": "Layer Tools.jsx"})),
|
||||
("window.restoreScriptPanels", json!({})),
|
||||
("script.runTasks", json!({})),
|
||||
("scriptui.click", json!({"window": 1, "widget": 2})),
|
||||
("scriptui.set", json!({"window": 1, "widget": 2, "value": 1})),
|
||||
("scriptui.mouse", json!({"window": 1, "widget": 2, "type": "click"})),
|
||||
("scriptui.close", json!({"window": 1})),
|
||||
] {
|
||||
let msg = refused(&mut s, "execute_command", json!({"command": command, "params": params}));
|
||||
assert!(msg.contains("not available while file access is confined"), "`{command}`: {msg}");
|
||||
}
|
||||
// Including as a batch step.
|
||||
let msg = refused(&mut s, "batch", json!({"steps": [{"command": "window.scriptPanel", "params": {"name": "x.jsx"}}]}));
|
||||
assert!(msg.contains("not available while file access is confined"), "{msg}");
|
||||
// What scripts may do stays as the user left it, by either spelling of the setting.
|
||||
for params in [
|
||||
json!({"key": "scripting.allowScriptsWriteFiles", "value": true}),
|
||||
json!({"key": "scripting.allow_scripts_write_files", "value": true}),
|
||||
json!({"values": {"scripting.warnExecutingFiles": false}}),
|
||||
json!({"values": {"general.showToolTips": false, "scripting.enableJsDebugger": true}}),
|
||||
// `Prefs::set` makes a JSON pointer of the key, so this spelling resolves too…
|
||||
json!({"key": "scripting/allowScriptsWriteFiles", "value": true}),
|
||||
// …and the page on its own replaces every setting on it.
|
||||
json!({"key": "scripting", "value": {"allowScriptsWriteFiles": true}}),
|
||||
json!({"values": {"scripting": {"warnExecutingFiles": false}}}),
|
||||
] {
|
||||
let msg = refused(&mut s, "execute_command", json!({"command": "prefs.set", "params": params}));
|
||||
assert!(msg.contains("what a script may do"), "{msg}");
|
||||
}
|
||||
let got = call_json(&mut s, "execute_command", json!({"command": "prefs.get", "params": {"key": "scripting.allowScriptsWriteFiles"}}));
|
||||
assert_eq!(got, json!(false), "{got}");
|
||||
// Other settings, and looking at the script windows, still work.
|
||||
call_json(&mut s, "execute_command", json!({"command": "prefs.set", "params": {"key": "general.showToolTips", "value": false}}));
|
||||
call_json(&mut s, "execute_command", json!({"command": "scriptui.list"}));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
@@ -48,8 +48,10 @@ reconstruction, so prediction never drifts.
|
||||
reconstruction loop; full coefficient coding (all_zero, tx type, eob_pt, eob_extra,
|
||||
coeff_base_eob, coeff_base, coeff_br, dc_sign, Golomb) with the exact contexts.
|
||||
- **Rate control**: `ConstantQ(base_q_idx)`; `Bitrate { kbps }` adapts `base_q_idx` per frame
|
||||
towards the average target (key frames get a lower quantizer and a larger share; the first
|
||||
key frame is searched in a few passes).
|
||||
towards the average target (key frames get a lower quantizer and a larger share of the budget of
|
||||
their group of `keyint` frames, the inter frames the rest; the first key frame is searched in a
|
||||
few passes, a later key frame that misses its budget by more than 19 % is coded again; the
|
||||
deviation from the budget is paid back over 16 frames).
|
||||
|
||||
## Limits
|
||||
|
||||
|
||||
+147
-24
@@ -24,6 +24,7 @@ use bitw::{BitWriter, obu};
|
||||
use decide::{Decider, FrameGeom, RefFrame};
|
||||
use loopfilter::{LfInfo, loop_filter};
|
||||
use pred::Plane;
|
||||
use tables::AC_QLOOKUP;
|
||||
use writer::TileWriter;
|
||||
|
||||
/// Rate control.
|
||||
@@ -136,10 +137,118 @@ struct RateState {
|
||||
frame_bits: f64,
|
||||
/// Quantizer index for inter frames (continuous).
|
||||
q: f64,
|
||||
/// Key frame quantizer index minus the inter frame one.
|
||||
key_off: f64,
|
||||
/// Quantizer index steps per halving of the frame size, measured from consecutive inter
|
||||
/// frames (the size/quantizer curve is much steeper than the default on noisy content).
|
||||
slope: f64,
|
||||
/// Quantizer index and size (in bits) of the previous inter frame.
|
||||
prev_inter: Option<(f64, f64)>,
|
||||
/// Accumulated (spent - budget) bits.
|
||||
debt: f64,
|
||||
}
|
||||
|
||||
/// Quantizer index steps per halving of the frame size before any measurement.
|
||||
const RC_SLOPE: f64 = 28.0;
|
||||
/// Fraction of the quantizer step the size error asks for that is applied per frame.
|
||||
const RC_GAIN: f64 = 0.8;
|
||||
/// Frames over which an accumulated deviation from the budget is paid back.
|
||||
const RC_PAYBACK: f64 = 16.0;
|
||||
|
||||
impl RateState {
|
||||
/// The nominal bit budget and the starting quantizer index of a key or inter frame. A key
|
||||
/// frame gets a larger share, and the inter frames of the group get the rest, so that a whole
|
||||
/// group of `keyint` frames adds up to `keyint * frame_bits`.
|
||||
fn frame_budget(&self, keyint: u32, key: bool) -> (f64, f64) {
|
||||
let keyint = keyint.max(1) as f64;
|
||||
let boost = (1.0 + (keyint.clamp(1.0, 60.0) - 1.0).sqrt() * 0.75).min(0.5 * keyint + 0.5);
|
||||
if key {
|
||||
(self.frame_bits * boost, (self.q + self.key_off).clamp(1.0, 255.0))
|
||||
} else if keyint > 1.0 {
|
||||
(self.frame_bits * (keyint - boost) / (keyint - 1.0), self.q)
|
||||
} else {
|
||||
(self.frame_bits, self.q)
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapts the quantizers to the size of the frame just coded at `q_used` against `target`.
|
||||
fn update(&mut self, key: bool, first: bool, q_used: f64, bits: f64, target: f64) {
|
||||
let err = (bits / target).log2().clamp(-1.5, 1.5);
|
||||
if first && key {
|
||||
// four passes ran: the key frame quantizer is known, the inter one follows from the offset
|
||||
self.q = (q_used - self.key_off).clamp(1.0, 255.0);
|
||||
} else if key {
|
||||
self.key_off = q_used - self.q + RC_GAIN * self.slope * err;
|
||||
} else {
|
||||
if let Some((pq, pbits)) = self.prev_inter {
|
||||
let (dq, dr) = (q_used - pq, (pbits / bits).log2());
|
||||
if dq.abs() >= 3.0 && dr.abs() >= 0.15 && dq * dr > 0.0 {
|
||||
self.slope = (0.6 * self.slope + 0.4 * (dq / dr)).clamp(3.0, 45.0);
|
||||
}
|
||||
}
|
||||
self.prev_inter = Some((q_used, bits));
|
||||
self.q = (q_used + RC_GAIN * self.slope * err).clamp(1.0, 255.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most passes on the first frame, and how far from the target (octaves, 3 %) its size may be
|
||||
/// before it is accepted.
|
||||
const RC_FIRST_PASSES: usize = 4;
|
||||
const RC_FIRST_TOLERANCE: f64 = 0.04;
|
||||
/// Quantizer index steps per halving of the size, for the first correction of the first frame
|
||||
/// (the starting quantizer comes from the bits per sample; the first pass shows how far off it
|
||||
/// is, and from there on the passes are placed by `next_probe_q`).
|
||||
const RC_FIRST_SLOPE: f64 = 30.0;
|
||||
/// The same for a key frame after the first: coded again when its size is more than 0.25 octaves
|
||||
/// (19 %) off the target.
|
||||
const RC_KEY_PASSES: usize = 3;
|
||||
const RC_KEY_TOLERANCE: f64 = 0.25;
|
||||
/// Octaves of frame size per octave of quantizer step assumed for the first correction of a frame.
|
||||
const RC_PROBE_SLOPE: f64 = 1.0;
|
||||
/// Largest change of the quantizer step (octaves) between two passes on one frame.
|
||||
const RC_PROBE_MAX_STEP: f64 = 2.0;
|
||||
|
||||
/// The quantizer index whose step size is closest to `2^x`.
|
||||
fn q_for_step(bit_depth: u8, x: f64) -> u8 {
|
||||
let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize];
|
||||
let mut best = 1;
|
||||
for q in 1..=255usize {
|
||||
if ((table[q] as f64).log2() - x).abs() < ((table[best] as f64).log2() - x).abs() {
|
||||
best = q;
|
||||
}
|
||||
}
|
||||
best as u8
|
||||
}
|
||||
|
||||
/// The next quantizer to try for a frame, from the `(quantizer, log2(size / target))`
|
||||
/// pairs coded so far. The size follows the quantizer step (not the index, which is linear in the
|
||||
/// step only below about 60) in a near log-log line: false position between the closest pair of
|
||||
/// passes that enclose the target, otherwise a step from the latest one with the slope of the
|
||||
/// last two. `None` when no untried quantizer lies between the enclosing passes.
|
||||
fn next_probe_q(bit_depth: u8, tried: &[(u8, f64)]) -> Option<u8> {
|
||||
let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize];
|
||||
let x = |q: u8| (table[q as usize] as f64).log2();
|
||||
let over = tried.iter().filter(|t| t.1 > 0.0).max_by_key(|t| t.0);
|
||||
let under = tried.iter().filter(|t| t.1 <= 0.0).min_by_key(|t| t.0);
|
||||
if let (Some(&(q_lo, e_lo)), Some(&(q_hi, e_hi))) = (over, under) {
|
||||
if q_hi - q_lo < 2 {
|
||||
return None;
|
||||
}
|
||||
let next = x(q_lo) + (x(q_hi) - x(q_lo)) * e_lo / (e_lo - e_hi);
|
||||
return Some(q_for_step(bit_depth, next).clamp(q_lo + 1, q_hi - 1));
|
||||
}
|
||||
let &(q, err) = tried.last()?;
|
||||
let mut slope = RC_PROBE_SLOPE;
|
||||
if let [.., (q0, e0), (q1, e1)] = tried
|
||||
&& (x(*q1) - x(*q0)).abs() > 0.05
|
||||
&& (e0 - e1) / (x(*q1) - x(*q0)) > 0.0
|
||||
{
|
||||
slope = ((e0 - e1) / (x(*q1) - x(*q0))).clamp(0.25, 3.0);
|
||||
}
|
||||
Some(q_for_step(bit_depth, x(q) + (err / slope).clamp(-RC_PROBE_MAX_STEP, RC_PROBE_MAX_STEP)))
|
||||
}
|
||||
|
||||
/// AV1 encoder. Frames come out in input order (no hidden/reordered frames).
|
||||
pub struct Encoder {
|
||||
cfg: EncoderConfig,
|
||||
@@ -185,7 +294,7 @@ impl Encoder {
|
||||
// initial guess from the bits per sample
|
||||
let bpp = frame_bits / (cfg.width as f64 * cfg.height as f64);
|
||||
let q = (110.0 - 40.0 * (bpp / 0.1).log2()).clamp(20.0, 230.0);
|
||||
Some(RateState { frame_bits, q, debt: 0.0 })
|
||||
Some(RateState { frame_bits, q, key_off: -24.0, slope: RC_SLOPE, prev_inter: None, debt: 0.0 })
|
||||
}
|
||||
};
|
||||
let mut enc = Encoder { cfg, geom, level, seq_obu: Vec::new(), frame_num: 0, refr: None, rc };
|
||||
@@ -471,38 +580,52 @@ impl Encoder {
|
||||
|
||||
fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool, rc: &mut RateState) -> (Vec<u8>, RefFrame) {
|
||||
let first = self.frame_num == 0;
|
||||
let (frame_bits, debt, q_inter) = (rc.frame_bits, rc.debt, rc.q);
|
||||
// key frames get a lower quantizer and a larger share of the budget
|
||||
let key_boost = 1.0 + (self.cfg.keyint.clamp(1, 60) as f64 - 1.0).sqrt() * 0.75;
|
||||
let budget = if key { frame_bits * key_boost } else { frame_bits };
|
||||
// pay back a fraction of the accumulated deviation
|
||||
let target = (budget - debt * 0.25).max(budget * 0.25);
|
||||
let mut q = if key { q_inter - 24.0 } else { q_inter };
|
||||
let mut best: Option<(Vec<u8>, RefFrame, f64)> = None;
|
||||
let passes = if first { 4 } else { 1 };
|
||||
for pass in 0..passes {
|
||||
let (budget, q_start) = rc.frame_budget(self.cfg.keyint, key);
|
||||
// pay back the accumulated deviation over the next RC_PAYBACK frames
|
||||
let target = (budget - rc.debt / RC_PAYBACK).clamp(budget * 0.35, budget * 2.0);
|
||||
let mut q = q_start;
|
||||
let mut best: Option<(Vec<u8>, RefFrame, f64, f64)> = None;
|
||||
// The first frame is coded up to four times to find its quantizer (it fixes the
|
||||
// quantizers of everything after it), a later key frame up to three times (it is a large
|
||||
// part of the budget of its group). A frame is coded again only while its size is far from
|
||||
// the target and an untried quantizer is left to try; other frames are coded once.
|
||||
let (max_passes, tolerance) = if first {
|
||||
(RC_FIRST_PASSES, RC_FIRST_TOLERANCE)
|
||||
} else if key {
|
||||
(RC_KEY_PASSES, RC_KEY_TOLERANCE)
|
||||
} else {
|
||||
(1, 0.0)
|
||||
};
|
||||
let mut tried: Vec<(u8, f64)> = Vec::new();
|
||||
for _ in 0..max_passes {
|
||||
let qi = q.round().clamp(1.0, 255.0) as u8;
|
||||
if tried.iter().any(|t| t.0 == qi) {
|
||||
break;
|
||||
}
|
||||
let (obu, rf) = self.encode_frame(src, key, qi);
|
||||
let bits = obu.len() as f64 * 8.0;
|
||||
let err = (bits / target).log2();
|
||||
let better = best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs());
|
||||
if better {
|
||||
best = Some((obu, rf, bits));
|
||||
tried.push((qi, err));
|
||||
if best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs()) {
|
||||
best = Some((obu, rf, bits, qi as f64));
|
||||
}
|
||||
if pass + 1 < passes {
|
||||
q = (q + 30.0 * err).clamp(1.0, 255.0);
|
||||
if err.abs() <= tolerance {
|
||||
break;
|
||||
}
|
||||
let next = if first && tried.len() == 1 {
|
||||
Some((q + RC_FIRST_SLOPE * err).clamp(1.0, 255.0))
|
||||
} else {
|
||||
next_probe_q(self.cfg.bit_depth, &tried).map(f64::from)
|
||||
};
|
||||
match next {
|
||||
Some(next) => q = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
// At least one pass ran, so `best` is set.
|
||||
let Some((obu, rf, bits)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) };
|
||||
let Some((obu, rf, bits, q_used)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) };
|
||||
rc.debt += bits - budget;
|
||||
// adapt the inter quantizer: about 30 index steps per doubling of the rate
|
||||
let err = (bits / target).log2().clamp(-1.0, 1.0);
|
||||
if first && key {
|
||||
rc.q = (q + 24.0).clamp(1.0, 255.0);
|
||||
} else {
|
||||
rc.q = (rc.q + 12.0 * err).clamp(1.0, 255.0);
|
||||
}
|
||||
rc.update(key, first, q_used, bits, target);
|
||||
(obu, rf)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ fn sin128(angle: i32) -> i64 {
|
||||
cos128(angle - 64)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn brev(num_bits: u32, x: usize) -> usize {
|
||||
let mut t = 0;
|
||||
for i in 0..num_bits {
|
||||
@@ -63,8 +64,11 @@ fn hd(t: &mut [i32], a: usize, b: usize, flip: bool, r: u32) {
|
||||
t[b] = (x - y).clamp(lo, hi);
|
||||
}
|
||||
|
||||
/// Inverse DCT of length 2^n, n = 2..=5 (7.13.2.3), in place.
|
||||
fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
|
||||
/// Inverse DCT of length 2^N, N = 2..=5 (7.13.2.3), in place. `N` is a const generic so that the
|
||||
/// bit-reversal permutations and loop bounds fold at compile time.
|
||||
#[inline(always)]
|
||||
fn inverse_dct_n<const N: u32>(t: &mut [i32], r: u32) {
|
||||
let n = N;
|
||||
let len = 1usize << n;
|
||||
let mut copy = [0i32; 32];
|
||||
copy[..len].copy_from_slice(&t[..len]);
|
||||
@@ -76,6 +80,7 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
|
||||
}
|
||||
|
||||
/// Rotate the high-frequency branches before the four-point core.
|
||||
#[inline(always)]
|
||||
fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) {
|
||||
if n >= 5 {
|
||||
for i in 0..8 {
|
||||
@@ -112,6 +117,7 @@ fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) {
|
||||
}
|
||||
|
||||
/// Evaluate the four-point core and merge progressively larger branches.
|
||||
#[inline(always)]
|
||||
fn inverse_dct_final_stages(t: &mut [i32], n: u32, r: u32) {
|
||||
for i in 0..2 {
|
||||
bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0);
|
||||
@@ -190,6 +196,7 @@ const SINPI_2_9: i64 = 2482;
|
||||
const SINPI_3_9: i64 = 3344;
|
||||
const SINPI_4_9: i64 = 3803;
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_adst4(t: &mut [i32]) {
|
||||
let (t0, t1, t2, t3) = (t[0] as i64, t[1] as i64, t[2] as i64, t[3] as i64);
|
||||
let mut s = [SINPI_1_9 * t0, SINPI_2_9 * t0, SINPI_3_9 * t1, SINPI_4_9 * t2, SINPI_1_9 * t2, SINPI_2_9 * t3, SINPI_4_9 * t3];
|
||||
@@ -207,7 +214,9 @@ fn inverse_adst4(t: &mut [i32]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn adst_in_permute(t: &mut [i32], n: u32) {
|
||||
#[inline(always)]
|
||||
fn adst_in_permute<const N: u32>(t: &mut [i32]) {
|
||||
let n = N;
|
||||
let n0 = 1usize << n;
|
||||
let mut copy = [0i32; 16];
|
||||
copy[..n0].copy_from_slice(&t[..n0]);
|
||||
@@ -217,7 +226,9 @@ fn adst_in_permute(t: &mut [i32], n: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
fn adst_out_permute(t: &mut [i32], n: u32) {
|
||||
#[inline(always)]
|
||||
fn adst_out_permute<const N: u32>(t: &mut [i32]) {
|
||||
let n = N;
|
||||
let n0 = 1usize << n;
|
||||
let mut copy = [0i32; 16];
|
||||
copy[..n0].copy_from_slice(&t[..n0]);
|
||||
@@ -231,8 +242,9 @@ fn adst_out_permute(t: &mut [i32], n: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_adst8(t: &mut [i32], r: u32) {
|
||||
adst_in_permute(t, 3);
|
||||
adst_in_permute::<3>(t);
|
||||
for i in 0..4 {
|
||||
bf(t, 2 * i, 2 * i + 1, 60 - 16 * i as i32, true);
|
||||
}
|
||||
@@ -250,11 +262,12 @@ fn inverse_adst8(t: &mut [i32], r: u32) {
|
||||
for i in 0..2 {
|
||||
bf(t, 2 + 4 * i, 3 + 4 * i, 32, true);
|
||||
}
|
||||
adst_out_permute(t, 3);
|
||||
adst_out_permute::<3>(t);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_adst16(t: &mut [i32], r: u32) {
|
||||
adst_in_permute(t, 4);
|
||||
adst_in_permute::<4>(t);
|
||||
for i in 0..8 {
|
||||
bf(t, 2 * i, 2 * i + 1, 62 - 8 * i as i32, true);
|
||||
}
|
||||
@@ -283,7 +296,7 @@ fn inverse_adst16(t: &mut [i32], r: u32) {
|
||||
for i in 0..4 {
|
||||
bf(t, 2 + 4 * i, 3 + 4 * i, 32, true);
|
||||
}
|
||||
adst_out_permute(t, 4);
|
||||
adst_out_permute::<4>(t);
|
||||
}
|
||||
|
||||
/// 1-D transform kind.
|
||||
@@ -308,6 +321,17 @@ pub(crate) fn kinds(tx_type: usize) -> (Kind, Kind) {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
|
||||
match n {
|
||||
2 => inverse_dct_n::<2>(t, r),
|
||||
3 => inverse_dct_n::<3>(t, r),
|
||||
4 => inverse_dct_n::<4>(t, r),
|
||||
_ => inverse_dct_n::<5>(t, r),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) {
|
||||
match kind {
|
||||
Kind::Dct => inverse_dct(t, n, r),
|
||||
@@ -322,7 +346,17 @@ fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) {
|
||||
/// 2-D inverse transform (7.13.3) of a square `n x n` block (n = 4..=32) of dequantised
|
||||
/// coefficients (row stride `n`) into `residual` (row stride `n`).
|
||||
pub(crate) fn inverse_2d(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) {
|
||||
let log2 = TX_WIDTH_LOG2[tx_sz] as u32;
|
||||
match TX_WIDTH_LOG2[tx_sz] {
|
||||
2 => inverse_2d_n::<2>(dequant, residual, tx_sz, tx_type, bit_depth),
|
||||
3 => inverse_2d_n::<3>(dequant, residual, tx_sz, tx_type, bit_depth),
|
||||
4 => inverse_2d_n::<4>(dequant, residual, tx_sz, tx_type, bit_depth),
|
||||
_ => inverse_2d_n::<5>(dequant, residual, tx_sz, tx_type, bit_depth),
|
||||
}
|
||||
}
|
||||
|
||||
/// `inverse_2d` for a block of width `1 << LOG2`.
|
||||
fn inverse_2d_n<const LOG2: u32>(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) {
|
||||
let log2 = LOG2;
|
||||
let n = 1usize << log2;
|
||||
let row_shift = TRANSFORM_ROW_SHIFT[tx_sz] as u32;
|
||||
let row_clamp = bit_depth + 8;
|
||||
@@ -429,6 +463,51 @@ fn basis(kind: Kind, n: usize) -> &'static [f32] {
|
||||
/// Forward 2-D transform of the `n x n` residual (row stride `n`) into orthonormal-domain
|
||||
/// coefficients scaled by 8 (so that `level = out / q` with the spec's dequantiser).
|
||||
pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) {
|
||||
match n {
|
||||
4 => forward_2d_n::<4, 16>(residual, out, tx_type),
|
||||
8 => forward_2d_n::<8, 64>(residual, out, tx_type),
|
||||
16 => forward_2d_n::<16, 256>(residual, out, tx_type),
|
||||
_ => forward_2d_dyn(residual, out, n, tx_type),
|
||||
}
|
||||
}
|
||||
|
||||
/// `forward_2d` for a fixed size, so `tmp` is exactly `NN = N * N` floats (no 4 KiB zeroing).
|
||||
#[inline(always)]
|
||||
fn forward_2d_n<const N: usize, const NN: usize>(residual: &[i32], out: &mut [f32], tx_type: usize) {
|
||||
let n = N;
|
||||
let (ck, rk) = kinds(tx_type);
|
||||
let bc = &basis(ck, n)[..NN];
|
||||
let brt = &basis_t(rk, n)[..NN];
|
||||
let residual = &residual[..NN];
|
||||
let out = &mut out[..NN];
|
||||
let mut tmp = [0f32; NN];
|
||||
for i in 0..n {
|
||||
let t = &mut tmp[i * n..i * n + n];
|
||||
for j in 0..n {
|
||||
let r = residual[i * n + j];
|
||||
if r == 0 {
|
||||
continue;
|
||||
}
|
||||
let r = r as f32;
|
||||
for (o, &b) in t.iter_mut().zip(&brt[j * n..j * n + n]) {
|
||||
*o += r * b;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.iter_mut().for_each(|v| *v = 0.0);
|
||||
for k in 0..n {
|
||||
let o = &mut out[k * n..k * n + n];
|
||||
for i in 0..n {
|
||||
let b = 8.0 * bc[k * n + i];
|
||||
for (v, &t) in o.iter_mut().zip(&tmp[i * n..i * n + n]) {
|
||||
*v += b * t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 32x32 (and any other size): a const-generic 32 version measured slower.
|
||||
fn forward_2d_dyn(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) {
|
||||
let (ck, rk) = kinds(tx_type);
|
||||
let bc = basis(ck, n);
|
||||
let brt = basis_t(rk, n);
|
||||
|
||||
@@ -80,6 +80,23 @@ fn bitrate_mode_tracks_target() {
|
||||
assert!(kbps > 400.0 / 2.0 && kbps < 400.0 * 2.0, "{kbps} kbit/s");
|
||||
}
|
||||
|
||||
/// Over several key frame intervals the average rate stays within 5 % of the target, for a
|
||||
/// range of targets (the key frame budget is taken out of the inter frames of its group).
|
||||
#[test]
|
||||
fn bitrate_mode_is_accurate_over_many_groups() {
|
||||
let n = 180;
|
||||
for (kbps, keyint, bd) in [(40, 30, 8), (100, 30, 8), (200, 60, 8), (150, 20, 10)] {
|
||||
let pics: Vec<Pic> = (0..n).map(|t| synth(192, 128, bd, t)).collect();
|
||||
let mut c = cfg(192, 128, bd as u8, 0, keyint);
|
||||
c.rate = RateControl::Bitrate { kbps };
|
||||
let mut enc = effectcraft_av1enc::Encoder::new(c).unwrap();
|
||||
let bits: usize = pics.iter().map(|p| enc.encode(&p.frame()).data.len() * 8).sum();
|
||||
let got = bits as f64 / (n as f64 / 30.0) / 1000.0;
|
||||
eprintln!("{bd} bit, keyint {keyint}: {got:.1} kbit/s for {kbps}");
|
||||
assert!((got / kbps as f64 - 1.0).abs() < 0.05, "{got:.1} kbit/s for a {kbps} kbit/s target ({bd} bit, keyint {keyint})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers() {
|
||||
let enc = effectcraft_av1enc::Encoder::new(cfg(1920, 1080, 10, 100, 60)).unwrap();
|
||||
|
||||
@@ -19,7 +19,7 @@ use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::{Image, Px};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::generate::value_noise;
|
||||
use crate::generate::value_noise_linear_z;
|
||||
use crate::util::{SRC_NAMES, Src, fit_layer, layer_cover_pad, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
@@ -37,13 +37,15 @@ fn pct(max: f64) -> ParamUi {
|
||||
|
||||
// ---------------------------------------------------------------- Turbulent Displace
|
||||
|
||||
/// Fractional-octave fBm of value noise in 0..1. Evolution (`z`) is linear: with the eased z of
|
||||
/// plain value noise a few degrees of Evolution barely moved the pattern (#943).
|
||||
fn fbm(u: f64, v: f64, z: f64, seed: u32, octaves: f64, falloff: f32) -> f32 {
|
||||
let n = octaves.ceil().max(1.0) as usize;
|
||||
let frac = (octaves - octaves.floor()) as f32;
|
||||
let (mut sum, mut norm, mut amp, mut f) = (0.0f32, 0.0f32, 1.0f32, 1.0f32);
|
||||
for o in 0..n {
|
||||
let w = if o + 1 == n && frac > 0.0 { frac } else { 1.0 };
|
||||
sum += value_noise(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w;
|
||||
sum += value_noise_linear_z(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w;
|
||||
norm += amp * w;
|
||||
amp *= falloff;
|
||||
f *= 2.0;
|
||||
@@ -1115,6 +1117,45 @@ mod tests {
|
||||
assert_eq!(TURBULENT_PINNING[11], "Pin Left Locked");
|
||||
}
|
||||
|
||||
/// #943: every non-equivalent Evolution value renders a visibly different frame. The eased
|
||||
/// evolution axis left 0 and 37 degrees (or 360 and 400) within a hundredth of each other.
|
||||
#[test]
|
||||
fn turbulent_displace_evolution_changes_the_output() {
|
||||
// A smooth ramp (red = x, green = y) with Pin All Locked, so the frame stays opaque and
|
||||
// the difference is purely how far the pattern moved.
|
||||
let mut ramp = Image::new(64, 64);
|
||||
for y in 0..64u32 {
|
||||
for x in 0..64u32 {
|
||||
ramp.set(x, y, [x as f32 / 63.0, y as f32 / 63.0, 0.25, 1.0]);
|
||||
}
|
||||
}
|
||||
let render = |evo: f64, extra: &[(&str, Value)]| {
|
||||
let base = [("evolution", num(evo)), ("size", num(10.0)), ("pinning", Value::Enum(8))];
|
||||
run("ec.distort.turbulentdisplace", &[base.as_slice(), extra].concat(), ramp.clone()).img
|
||||
};
|
||||
let evolutions = [0.0, 37.0, 90.5, 180.0, 360.0, 400.25, 725.0, 1000.5];
|
||||
let frames: Vec<Image> = evolutions.iter().map(|&e| render(e, &[])).collect();
|
||||
for (i, a) in frames.iter().enumerate() {
|
||||
for (j, b) in frames.iter().enumerate().skip(i + 1) {
|
||||
let d = max_diff(a, b);
|
||||
assert!(d > 0.01, "evolution {} and {} differ by only {d}", evolutions[i], evolutions[j]);
|
||||
}
|
||||
}
|
||||
// Constant rate: 20 degrees next to a whole revolution (from either side) is a visible
|
||||
// step, not the rounding-error size the eased axis gave (under 0.001).
|
||||
assert!(max_diff(&frames[0], &render(20.0, &[])) > 0.02);
|
||||
assert!(max_diff(&render(340.0, &[]), &frames[4]) > 0.02);
|
||||
// 0 and 360 degrees are different frames without Cycle Evolution, and the same frame with
|
||||
// Cycle (in Revolutions) = 1, at any starting angle.
|
||||
assert!(max_diff(&frames[0], &frames[4]) > 0.01);
|
||||
let cycle = [("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(1.0))];
|
||||
assert!(max_diff(&render(0.0, &cycle), &render(360.0, &cycle)) < 1e-4);
|
||||
assert!(max_diff(&render(37.0, &cycle), &render(397.0, &cycle)) < 1e-4);
|
||||
assert!(max_diff(&render(0.0, &cycle), &render(37.0, &cycle)) > 0.01);
|
||||
// Random Seed still gives a different pattern at the same Evolution.
|
||||
assert!(max_diff(&frames[1], &render(37.0, &[("evolutionOptions/randomSeed", num(5.0))])) > 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magnify_blending_none_and_scaling() {
|
||||
let img = test_img(32, 32);
|
||||
|
||||
@@ -292,10 +292,22 @@ pub(crate) fn fade(t: f32) -> f32 {
|
||||
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
||||
}
|
||||
|
||||
/// 3D value noise in 0..1 (z = evolution).
|
||||
/// 3D value noise in 0..1 (z = evolution). The quintic fade applies on all three axes, so z
|
||||
/// eases in and out of every integer.
|
||||
pub fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 {
|
||||
value_noise_z(x, y, z, seed, true)
|
||||
}
|
||||
|
||||
/// [`value_noise`] with the evolution axis z interpolated linearly (x and y keep the quintic
|
||||
/// fade). The fade's zero slope at every integer z made a small Evolution change almost invisible
|
||||
/// next to a whole revolution (#943, #1816); linear z moves the pattern at a constant rate.
|
||||
pub fn value_noise_linear_z(x: f32, y: f32, z: f32, seed: u32) -> f32 {
|
||||
value_noise_z(x, y, z, seed, false)
|
||||
}
|
||||
|
||||
fn value_noise_z(x: f32, y: f32, z: f32, seed: u32, ease_z: bool) -> f32 {
|
||||
let (x0, y0, z0) = (x.floor(), y.floor(), z.floor());
|
||||
let (fx, fy, fz) = (fade(x - x0), fade(y - y0), fade(z - z0));
|
||||
let (fx, fy, fz) = (fade(x - x0), fade(y - y0), if ease_z { fade(z - z0) } else { z - z0 });
|
||||
let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32);
|
||||
let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
|
||||
let c = |dx: i32, dy: i32, dz: i32| lattice(ix + dx, iy + dy, iz + dz, seed);
|
||||
@@ -414,6 +426,27 @@ mod generator_tests {
|
||||
Image::filled(32, 32, [1.0, 0.0, 0.0, 1.0])
|
||||
}
|
||||
|
||||
/// #943: the evolution axis is linear, so z moves the noise at a constant rate, while the
|
||||
/// classic `value_noise` keeps its eased z (other effects and their pinned goldens use it).
|
||||
#[test]
|
||||
fn linear_z_value_noise_moves_at_a_constant_rate() {
|
||||
for (x, y, seed) in [(0.3f32, 0.7f32, 5u32), (2.4, -1.6, 11), (-3.1, 4.9, 0)] {
|
||||
let a = value_noise_linear_z(x, y, 0.0, seed);
|
||||
let b = value_noise_linear_z(x, y, 1.0, seed);
|
||||
for z in [0.1f32, 0.25, 0.5, 0.75, 0.9] {
|
||||
let want = a + (b - a) * z;
|
||||
let got = value_noise_linear_z(x, y, z, seed);
|
||||
assert!((got - want).abs() < 1e-5, "({x}, {y}) z {z}: {got} vs {want}");
|
||||
}
|
||||
// Same lattice values at whole z, and x / y are untouched.
|
||||
assert_eq!(value_noise_linear_z(x, y, 2.0, seed), value_noise(x, y, 2.0, seed));
|
||||
}
|
||||
// The eased version still has the old shape: at z = 0.25 it lags the linear one.
|
||||
let (a, b) = (value_noise(0.3, 0.7, 0.0, 5), value_noise(0.3, 0.7, 1.0, 5));
|
||||
let eased = value_noise(0.3, 0.7, 0.25, 5);
|
||||
assert!((eased - (a + (b - a) * 0.25)).abs() > 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_ramp_stays_inside_the_layer_alpha() {
|
||||
// #1063: a shape's transparent pixels stay transparent; the ramp fills only the shape.
|
||||
|
||||
+24
-28
@@ -264,25 +264,30 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize
|
||||
}
|
||||
let low = Plane::luma(img);
|
||||
let step = (size / 2).max(1);
|
||||
let mut cands: Vec<(f32, usize, usize)> = Vec::new();
|
||||
for y in (0..=h - size).step_by(step) {
|
||||
for x in (0..=w - size).step_by(step) {
|
||||
// Variance of the picture inside the box (the flattest boxes hold the least detail).
|
||||
let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true);
|
||||
for yy in y..y + size {
|
||||
for xx in x..x + size {
|
||||
let v = low.data[yy * w + xx];
|
||||
s += v;
|
||||
sq += v * v;
|
||||
n += 1.0;
|
||||
opaque &= img.data[yy * w + xx][3] > 0.5;
|
||||
// Candidate rows in parallel; the sort below is a total order (variance, y, x), so the
|
||||
// result does not depend on the collection order.
|
||||
let rows: Vec<usize> = (0..=h - size).step_by(step).collect();
|
||||
let mut cands: Vec<(f32, usize, usize)> = rows
|
||||
.par_iter()
|
||||
.flat_map_iter(|&y| {
|
||||
let data = low.data.as_slice();
|
||||
let alpha = img.data.as_slice();
|
||||
(0..=w - size).step_by(step).filter_map(move |x| {
|
||||
// Variance of the picture inside the box (the flattest boxes hold the least detail).
|
||||
let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true);
|
||||
for yy in y..y + size {
|
||||
for xx in x..x + size {
|
||||
let v = data[yy * w + xx];
|
||||
s += v;
|
||||
sq += v * v;
|
||||
n += 1.0;
|
||||
opaque &= alpha[yy * w + xx][3] > 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
if opaque {
|
||||
cands.push((sq / n - (s / n).powi(2), x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
opaque.then(|| (sq / n - (s / n).powi(2), x, y))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
cands.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)).then(a.1.cmp(&b.1)));
|
||||
cands.into_iter().take(count).map(|(_, x, y)| (x, y, size)).collect()
|
||||
}
|
||||
@@ -479,16 +484,7 @@ pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(u
|
||||
|
||||
/// The grain level (0.5..2) measured in the sample boxes of `img`.
|
||||
fn grain_level(img: &Image, boxes: &[(usize, usize, usize)]) -> f32 {
|
||||
let mut m = vec![false; img.data.len()];
|
||||
let w = img.width as usize;
|
||||
for &(x, y, s) in boxes {
|
||||
for yy in y..y + s {
|
||||
for xx in x..x + s {
|
||||
m[yy * w + xx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let st = crate::noise2::grain_stats_masked(img, Some(&m));
|
||||
let st = crate::noise2::grain_stats_boxes(img, boxes);
|
||||
let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0;
|
||||
if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) }
|
||||
}
|
||||
|
||||
+165
-22
@@ -75,6 +75,102 @@ pub(crate) fn grain_stats_masked(img: &Image, mask: Option<&[bool]>) -> GrainSta
|
||||
GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 }
|
||||
}
|
||||
|
||||
/// Grain statistics over sample `boxes` (in image pixels), measured from small crops around each
|
||||
/// box instead of the whole image. Empty `boxes` measures every opaque pixel, like
|
||||
/// [`grain_stats_masked`] without a mask. Overlapping boxes are deduplicated, so the sums match
|
||||
/// the masked version.
|
||||
pub(crate) fn grain_stats_boxes(img: &Image, boxes: &[(usize, usize, usize)]) -> GrainStats {
|
||||
if boxes.is_empty() {
|
||||
return grain_stats_masked(img, None);
|
||||
}
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if w < 3 || h < 3 {
|
||||
return GrainStats::default();
|
||||
}
|
||||
// The Gaussian's three box passes at sigma 1.5 have radius 1 each, so 3 px of halo make the
|
||||
// residual exact inside a box; one more column covers the right neighbour used by the lag-1
|
||||
// correlation.
|
||||
const HALO: usize = 4;
|
||||
let mut mask = vec![false; w * h];
|
||||
for &(x, y, s) in boxes {
|
||||
for yy in y..y.saturating_add(s).min(h) {
|
||||
for xx in x..x.saturating_add(s).min(w) {
|
||||
mask[yy * w + xx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut counted = vec![false; w * h];
|
||||
let mut n = [0.0f64; 3];
|
||||
let mut sum = [0.0f64; 3];
|
||||
let mut sq = [0.0f64; 3];
|
||||
let mut rho_num = 0.0f64;
|
||||
let mut rho_den = 0.0f64;
|
||||
let mut crop: Vec<([f32; 3], f32)> = Vec::new();
|
||||
let mut planes: Vec<Plane> = Vec::new();
|
||||
for &(bx, by, bs) in boxes {
|
||||
let x0 = bx.saturating_sub(HALO);
|
||||
let y0 = by.saturating_sub(HALO);
|
||||
let x1 = bx.saturating_add(bs).saturating_add(HALO).min(w);
|
||||
let y1 = by.saturating_add(bs).saturating_add(HALO).min(h);
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
continue;
|
||||
}
|
||||
let (cw, ch) = (x1 - x0, y1 - y0);
|
||||
crop.clear();
|
||||
crop.extend((y0..y1).flat_map(|y| (x0..x1).map(move |x| unpremul(img.data[y * w + x]))));
|
||||
planes.clear();
|
||||
for k in 0..3 {
|
||||
let pl = Plane { w: cw, h: ch, data: crop.iter().map(|s| s.0[k]).collect() };
|
||||
planes.push(gauss_plane(&pl, 1.5, 1.5));
|
||||
}
|
||||
for ly in 0..bs {
|
||||
let y = by + ly;
|
||||
if y >= h {
|
||||
break;
|
||||
}
|
||||
for lx in 0..bs {
|
||||
let x = bx + lx;
|
||||
if x >= w {
|
||||
break;
|
||||
}
|
||||
let g = y * w + x;
|
||||
if !mask[g] || counted[g] {
|
||||
continue;
|
||||
}
|
||||
counted[g] = true;
|
||||
let l = (y - y0) * cw + (x - x0);
|
||||
let (_, a) = crop[l];
|
||||
if a <= 0.5 {
|
||||
continue;
|
||||
}
|
||||
for k in 0..3 {
|
||||
let hp = crop[l].0[k] - planes[k].data[l];
|
||||
sum[k] += hp as f64;
|
||||
sq[k] += (hp as f64) * (hp as f64);
|
||||
n[k] += 1.0;
|
||||
}
|
||||
if x + 1 < w && mask[g + 1] && crop[l + 1].1 > 0.5 {
|
||||
for k in 0..3 {
|
||||
let hp = crop[l].0[k] - planes[k].data[l];
|
||||
let hn = crop[l + 1].0[k] - planes[k].data[l + 1];
|
||||
rho_num += (hp * hn) as f64;
|
||||
rho_den += (hp * hp) as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut std = [0.0f32; 3];
|
||||
for k in 0..3 {
|
||||
if n[k] > 1.0 {
|
||||
let mean = sum[k] / n[k];
|
||||
std[k] = ((sq[k] / n[k] - mean * mean).max(0.0)).sqrt() as f32;
|
||||
}
|
||||
}
|
||||
let rho = if rho_den > 1e-12 { (rho_num / rho_den).clamp(0.0, 0.95) } else { 0.0 };
|
||||
GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 }
|
||||
}
|
||||
|
||||
/// Match Grain's Viewing Mode options.
|
||||
const MATCH_GRAIN_VIEWS: [&str; 5] = ["Preview", "Noise Samples", "Compensation Samples", "Blending Matte", "Final Output"];
|
||||
|
||||
@@ -86,34 +182,24 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let view = ctx.params.e("viewingMode");
|
||||
let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false);
|
||||
let src_boxes = sampling_boxes(ctx, &source, b.scale);
|
||||
let own_boxes = sampling_boxes(ctx, &b.img, b.scale);
|
||||
let box_col = ctx.params.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]);
|
||||
if view == 1 || view == 2 {
|
||||
// Noise Samples (on the source) / Compensation Samples (on this layer).
|
||||
let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), own_boxes) };
|
||||
let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), sampling_boxes(ctx, &b.img, b.scale)) };
|
||||
draw_boxes(&mut img, &boxes, box_col);
|
||||
b.img = img;
|
||||
return b;
|
||||
}
|
||||
let mask_of = |img: &Image, boxes: &[(usize, usize, usize)]| -> Option<Vec<bool>> {
|
||||
if boxes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let w = img.width as usize;
|
||||
let mut m = vec![false; img.data.len()];
|
||||
for &(x, y, s) in boxes {
|
||||
for yy in y..y + s {
|
||||
for xx in x..x + s {
|
||||
m[yy * w + xx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(m)
|
||||
};
|
||||
let src_stats = grain_stats_masked(&source, mask_of(&source, &src_boxes).as_deref());
|
||||
let src_stats = grain_stats_boxes(&source, &src_boxes);
|
||||
let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32;
|
||||
let own = if comp > 0.0 { grain_stats_masked(&b.img, mask_of(&b.img, &own_boxes).as_deref()) } else { GrainStats::default() };
|
||||
let orig = b.img.clone();
|
||||
// The own-layer boxes only matter for the compensation term; skip them when it is off.
|
||||
let own = if comp > 0.0 {
|
||||
let own_boxes = sampling_boxes(ctx, &b.img, b.scale);
|
||||
grain_stats_boxes(&b.img, &own_boxes)
|
||||
} else {
|
||||
GrainStats::default()
|
||||
};
|
||||
let orig = (view == 0).then(|| b.img.clone());
|
||||
let look = GrainLook::from_params(ctx);
|
||||
let amp: [f32; 3] = [0, 1, 2].map(|k| {
|
||||
let s = src_stats.std[k];
|
||||
@@ -147,8 +233,8 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
}
|
||||
}
|
||||
});
|
||||
if view == 0 {
|
||||
crate::noise::preview_compose(ctx, &mut b, &orig);
|
||||
if let Some(orig) = &orig {
|
||||
crate::noise::preview_compose(ctx, &mut b, orig);
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -306,6 +392,63 @@ mod tests {
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxed_grain_stats_match_the_masked_ones() {
|
||||
// Noisy image with alpha holes; the boxes cover overlapping, edge-adjacent and
|
||||
// bottom-right corner cases (clamped crops).
|
||||
let mut img = noisy(64, 48, 0.2, 9);
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
if (x / 7 + y / 5) % 6 == 0 {
|
||||
let mut p = img.get(x as i64, y as i64);
|
||||
p[3] = 0.0;
|
||||
img.set(x, y, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
let boxes = [(3usize, 4usize, 9usize), (8, 6, 9), (40, 20, 12), (39, 21, 12), (55, 39, 9)];
|
||||
let w = img.width as usize;
|
||||
let mut mask = vec![false; img.data.len()];
|
||||
for &(x, y, s) in &boxes {
|
||||
for yy in y..y + s {
|
||||
for xx in x..x + s {
|
||||
mask[yy * w + xx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let masked = grain_stats_masked(&img, Some(&mask));
|
||||
let boxed = grain_stats_boxes(&img, &boxes);
|
||||
for k in 0..3 {
|
||||
assert!((masked.std[k] - boxed.std[k]).abs() < 1e-6, "{k}: {masked:?} vs {boxed:?}");
|
||||
}
|
||||
assert!((masked.size - boxed.size).abs() <= 1e-7, "size: {masked:?} vs {boxed:?}");
|
||||
// A denser grid where every box overlaps its neighbours (mask dedup across boxes).
|
||||
let img2 = noisy(96, 64, 0.15, 21);
|
||||
let mut boxes2 = Vec::new();
|
||||
for gy in 0..5 {
|
||||
for gx in 0..6 {
|
||||
boxes2.push((gx * 11 + (gy % 3) * 4, gy * 10 + gx % 2, 12));
|
||||
}
|
||||
}
|
||||
let w2 = img2.width as usize;
|
||||
let mut mask2 = vec![false; img2.data.len()];
|
||||
for &(x, y, s) in &boxes2 {
|
||||
for yy in y..y + s {
|
||||
for xx in x..x + s {
|
||||
mask2[yy * w2 + xx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let masked2 = grain_stats_masked(&img2, Some(&mask2));
|
||||
let boxed2 = grain_stats_boxes(&img2, &boxes2);
|
||||
for k in 0..3 {
|
||||
assert!((masked2.std[k] - boxed2.std[k]).abs() < 1e-6, "{k}: {masked2:?} vs {boxed2:?}");
|
||||
}
|
||||
assert!((masked2.size - boxed2.size).abs() <= 1e-7, "size: {masked2:?} vs {boxed2:?}");
|
||||
// No boxes measures the whole image, like no mask.
|
||||
assert_eq!(grain_stats_boxes(&img, &[]), grain_stats(&img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_grain_transfers_grain_strength() {
|
||||
let flat = Image::filled(48, 48, [0.5, 0.5, 0.5, 1.0]);
|
||||
|
||||
@@ -6,6 +6,10 @@ use effectcraft_color::{luminance, rgb_to_hsl};
|
||||
use effectcraft_raster::{Image, Px};
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// Below this many pixels, `Plane::transpose` and `Plane::map_rows` run on the calling thread:
|
||||
/// the rayon dispatch costs more than the work on small crops (the Match Grain sample boxes).
|
||||
const PAR_MIN: usize = 4096;
|
||||
|
||||
/// A single-channel `f32` image (row-major).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Plane {
|
||||
@@ -62,6 +66,15 @@ impl Plane {
|
||||
pub fn transpose(&self) -> Plane {
|
||||
let (w, h) = (self.w, self.h);
|
||||
let mut out = Plane::new(h, w);
|
||||
if w.saturating_mul(h) < PAR_MIN {
|
||||
// Small planes: the rayon dispatch costs more than the copy itself.
|
||||
for (x, col) in out.data.chunks_mut(h.max(1)).enumerate() {
|
||||
for y in 0..h {
|
||||
col[y] = self.data[y * w + x];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| {
|
||||
for y in 0..h {
|
||||
col[y] = self.data[y * w + x];
|
||||
@@ -75,6 +88,11 @@ impl Plane {
|
||||
if self.w == 0 {
|
||||
return out;
|
||||
}
|
||||
if self.w.saturating_mul(self.h) < PAR_MIN {
|
||||
// Small planes: the rayon dispatch costs more than the rows themselves.
|
||||
out.data.chunks_mut(self.w).zip(self.data.chunks(self.w)).for_each(|(o, s)| f(s, o));
|
||||
return out;
|
||||
}
|
||||
out.data.par_chunks_mut(self.w).zip(self.data.par_chunks(self.w)).for_each(|(o, s)| f(s, o));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ fn copy(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
clip.push(KeyClip { layer: lid, path, keys });
|
||||
}
|
||||
let n: usize = clip.iter().map(|c| c.keys.len()).sum();
|
||||
s.state.keyframe_text = crate::keyframe_text::text(&s.project, comp, &s.state.selected_keys, &[]);
|
||||
s.state.key_clipboard = clip;
|
||||
s.state.clip_is_keys = true;
|
||||
Ok(json!(n))
|
||||
|
||||
@@ -263,6 +263,7 @@ fn clear_clipboards(s: &mut Session) {
|
||||
s.state.contents_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.value_clipboard.clear();
|
||||
s.state.keyframe_text = None;
|
||||
s.state.clip_is_keys = false;
|
||||
}
|
||||
|
||||
@@ -329,10 +330,12 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
}
|
||||
let mut keys = s.state.selected_keys.clone();
|
||||
let mut values = vec![];
|
||||
let mut statics = vec![];
|
||||
for (lid, uid) in &leaves {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
let Some(pr) = l.props.find(*uid) else { continue };
|
||||
if pr.keys.is_empty() {
|
||||
statics.push((*lid, *uid));
|
||||
if let Some(path) = l.props.match_path_of(*uid)
|
||||
&& !values.iter().any(|(p, _)| *p == path)
|
||||
{
|
||||
@@ -342,6 +345,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
|
||||
}
|
||||
}
|
||||
let text = crate::keyframe_text::text(&s.project, comp, &keys, &statics);
|
||||
clear_clipboards(s);
|
||||
let mut n = 0;
|
||||
if !keys.is_empty() {
|
||||
@@ -350,6 +354,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
s.state.selected_keys = selected;
|
||||
n = r?.as_u64().unwrap_or(0);
|
||||
}
|
||||
s.state.keyframe_text = text;
|
||||
let v = values.len();
|
||||
s.state.value_clipboard = values;
|
||||
// Keys alone answer as `keys.copy` does (their count).
|
||||
|
||||
@@ -804,6 +804,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.state.clipboard.clear();
|
||||
s.state.key_clipboard.clear();
|
||||
s.state.keyframe_text = None;
|
||||
s.state.contents_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.clip_is_keys = false;
|
||||
|
||||
@@ -102,11 +102,39 @@ fn quote(s: &str) -> String {
|
||||
|
||||
/// The AE expression reference to property `uid` of `target` as seen from a property of `from`.
|
||||
pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool) -> Option<String> {
|
||||
reference_with(comp, from, target, uid, compact, false)
|
||||
}
|
||||
|
||||
/// [`reference`], with the properties inside an effect selected by their index (`by_index`, an
|
||||
/// Alt pick whip in After Effects): `effect("Color Control")(1)` rather than `("Color")`. The
|
||||
/// effect keeps its name. An index is the property's place among its group's children, as an
|
||||
/// expression's `(1)` reads it: it follows a renamed parameter, not one moved to another place.
|
||||
pub(crate) fn reference_with(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool, by_index: bool) -> Option<String> {
|
||||
let chain = target.props.node_chain(uid)?;
|
||||
// Each node's one-based index among its group's children, and whether it lies inside an
|
||||
// effect (below the effect itself): those are selected by index with `by_index`.
|
||||
let mut parent = &target.props;
|
||||
let mut depth_in_effects: Option<usize> = None;
|
||||
let mut by_number: Vec<Option<usize>> = vec![];
|
||||
for n in &chain {
|
||||
let at = parent.children.iter().position(|c| c.uid() == n.uid()).map(|i| i + 1);
|
||||
by_number.push(at.filter(|_| by_index && depth_in_effects.is_some_and(|d| d >= 1)));
|
||||
depth_in_effects = match depth_in_effects {
|
||||
Some(d) => Some(d + 1),
|
||||
None => (n.match_id() == "effects").then_some(0),
|
||||
};
|
||||
if let Node::Group(g) = n {
|
||||
parent = g;
|
||||
}
|
||||
}
|
||||
let selector = |i: usize, label: &str| match by_number.get(i).copied().flatten() {
|
||||
Some(at) => format!("({at})"),
|
||||
None => format!("({})", quote(label)),
|
||||
};
|
||||
let mut parts: Vec<String> = vec![];
|
||||
let mut pending: Option<&str> = None;
|
||||
let mut in_effect = false;
|
||||
for n in chain {
|
||||
for (i, n) in chain.iter().copied().enumerate() {
|
||||
if let Some(f) = pending.take() {
|
||||
parts.push(format!("{f}({})", quote(n.name())));
|
||||
in_effect |= f == "effect";
|
||||
@@ -119,7 +147,7 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, com
|
||||
(Node::Group(_), "transform") if !in_effect => parts.push("transform".into()),
|
||||
(Node::Group(_), "text") if !in_effect => parts.push("text".into()),
|
||||
(Node::Prop(_), "sourceText") => parts.push("sourceText".into()),
|
||||
_ if in_effect => parts.push(format!("({})", quote(n.name()))),
|
||||
_ if in_effect => parts.push(selector(i, n.name())),
|
||||
_ => parts.push(camel(n.name())),
|
||||
}
|
||||
}
|
||||
@@ -127,10 +155,9 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, com
|
||||
let _ = comp;
|
||||
if !compact {
|
||||
// Match names: language-independent, like After Effects with Compact English off.
|
||||
let chain = target.props.node_chain(uid)?;
|
||||
let mut out = if from.id == target.id { "thisLayer".to_string() } else { format!("thisComp.layer({})", quote(&target.name)) };
|
||||
for n in chain {
|
||||
out.push_str(&format!("({})", quote(n.match_id())));
|
||||
for (i, n) in chain.iter().copied().enumerate() {
|
||||
out.push_str(&selector(i, n.match_id()));
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
@@ -161,7 +188,9 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let from = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let target = comp.layer(tl).ok_or(EngineError::NoComp)?;
|
||||
let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
|
||||
let by_index = b_p(p, "byIndex").unwrap_or(false);
|
||||
let r = reference_with(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact, by_index)
|
||||
.ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
|
||||
let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?);
|
||||
let (dm, dt) = (dims(from, me), dims(target, them));
|
||||
// Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects.
|
||||
@@ -206,7 +235,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pick Whip (Link Property)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)",
|
||||
"{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end], byIndex?: bool} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end; `byIndex`: an effect's properties by their index, `effect(\"Color Control\")(1)`, as an Alt pick whip does)",
|
||||
has_layers,
|
||||
pick_whip
|
||||
),
|
||||
|
||||
@@ -289,6 +289,7 @@ fn extract_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
clip.push(crate::KeyClip { layer: lid, path, keys });
|
||||
}
|
||||
s.state.key_clipboard = clip;
|
||||
s.state.keyframe_text = None;
|
||||
s.state.clip_is_keys = true;
|
||||
// The same data as text (tab-separated, one row per frame) for other applications.
|
||||
let mut text = String::from("EffectCraft Face Measurements\n\nFrame\tTime");
|
||||
|
||||
@@ -203,7 +203,7 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
|
||||
}
|
||||
|
||||
/// The `format` parameter's values.
|
||||
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff";
|
||||
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff";
|
||||
|
||||
/// `channels` (refusing RGBA where the format has no alpha) and `color`/`alphaMode`.
|
||||
fn apply_channels_and_alpha(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> {
|
||||
@@ -1017,7 +1017,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add to Render Queue",
|
||||
["Composition"],
|
||||
["Cmd+M", "Cmd+Shift+/"],
|
||||
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
|
||||
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
|
||||
can_add,
|
||||
add
|
||||
),
|
||||
@@ -1037,7 +1037,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Output Module Settings...",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
|
||||
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
|
||||
has_items,
|
||||
set_output_module
|
||||
),
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts
|
||||
//! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI
|
||||
//! panels appear at the bottom of the Window menu and open as dockable panels
|
||||
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects.
|
||||
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of
|
||||
//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`.
|
||||
//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
|
||||
//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
|
||||
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
|
||||
@@ -22,6 +23,11 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui};
|
||||
/// The settings-store folders of installed scripts.
|
||||
pub const SCRIPTS_DIR: &str = "Scripts";
|
||||
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
|
||||
/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts.
|
||||
const PANELS_FOLDER: &str = "ScriptUI Panels";
|
||||
/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in
|
||||
/// After Effects): never listed as File ▸ Scripts submenus.
|
||||
const RESERVED_FOLDERS: [&str; 3] = [PANELS_FOLDER, "Startup", "Shutdown"];
|
||||
/// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch.
|
||||
pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json";
|
||||
/// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects
|
||||
@@ -111,7 +117,7 @@ pub const BUNDLED: &[(&str, bool, &str, &str)] = &[
|
||||
/// A script File ▸ Scripts (or the Window menu, for panels) offers.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct ScriptEntry {
|
||||
/// File name (`Rename Layers.jsx`): what the menus show and commands take.
|
||||
/// File name (`Rename Layers.jsx`), or `Folder/File.jsx` in a subfolder: what commands take.
|
||||
pub name: String,
|
||||
/// In the ScriptUI Panels folder: opens as a dockable panel.
|
||||
pub panel: bool,
|
||||
@@ -119,16 +125,23 @@ pub struct ScriptEntry {
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
/// Whether `name` ends in `.jsx` or `.js` (any letter case).
|
||||
fn is_script(name: &str) -> bool {
|
||||
let l = name.to_ascii_lowercase();
|
||||
l.ends_with(".jsx") || l.ends_with(".js")
|
||||
}
|
||||
|
||||
/// Whether `name` can name an installed script: a bare file name, never a path (names are joined
|
||||
/// onto the Scripts folders of the settings store, which takes them as relative paths), and on
|
||||
/// Windows none of the names [`windows_unsafe_name`] lists.
|
||||
/// Whether `name` can name an installed script: a file name, or `Folder/File` for one in a
|
||||
/// subfolder of a Scripts folder (one level, and not one of [`RESERVED_FOLDERS`]: `ScriptUI
|
||||
/// Panels/…`, `Startup/…`, `Shutdown/…`). Each part is a bare name, never a path (names are joined onto the Scripts folders of
|
||||
/// the settings store, which takes them as relative paths), and on Windows none of the names
|
||||
/// [`windows_unsafe_name`] lists.
|
||||
fn is_script_name(name: &str) -> bool {
|
||||
is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name))
|
||||
let part = |n: &str| is_bare_name(n) && !(cfg!(windows) && windows_unsafe_name(n));
|
||||
match name.split_once('/') {
|
||||
Some((folder, file)) => part(folder) && part(file) && !RESERVED_FOLDERS.iter().any(|r| folder.eq_ignore_ascii_case(r)),
|
||||
None => part(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
|
||||
@@ -155,8 +168,13 @@ pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
|
||||
let mut v: Vec<ScriptEntry> = vec![];
|
||||
if let Some(cfg) = &s.config {
|
||||
for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
|
||||
for n in cfg.list(dir).into_iter().filter(|n| is_script(n) && is_script_name(n)) {
|
||||
v.push(ScriptEntry { name: n, panel, source: "installed" });
|
||||
// The folder itself, then each subfolder, whose scripts are named `Folder/File.jsx`.
|
||||
let subfolders = cfg.list_dirs(dir).into_iter().map(|f| (format!("{dir}/{f}"), format!("{f}/")));
|
||||
for (folder, prefix) in std::iter::once((dir.to_string(), String::new())).chain(subfolders) {
|
||||
let names = cfg.list(&folder).into_iter().map(|n| format!("{prefix}{n}"));
|
||||
for n in names.filter(|n| is_script(n) && is_script_name(n)) {
|
||||
v.push(ScriptEntry { name: n, panel, source: "installed" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,12 +234,19 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!(scripts(s)))
|
||||
}
|
||||
|
||||
/// `file.uninstallScript {name}`: delete an installed script or panel at the top of the Scripts
|
||||
/// folders.
|
||||
fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "file.uninstallScript";
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?;
|
||||
if !is_script(name) || !is_script_name(name) {
|
||||
return Err(bad(c, format!("`{name}` is not a script file name")));
|
||||
}
|
||||
// A subfolder can be a link to files outside the settings folder (an extension's own
|
||||
// folder), so only what Install puts at the top is removed, never a file through a folder.
|
||||
if name.contains('/') {
|
||||
return Err(bad(c, format!("`{name}` is in a subfolder: delete it there yourself (only scripts at the top of the Scripts folders are uninstalled)")));
|
||||
}
|
||||
let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?;
|
||||
let mut removed = false;
|
||||
for dir in [SCRIPTS_DIR, PANELS_DIR] {
|
||||
@@ -316,13 +341,15 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "window.scriptPanel";
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string();
|
||||
let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
|
||||
let file = name.rsplit_once('/').map_or(name.as_str(), |(_, f)| f);
|
||||
let title = file.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
|
||||
if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) {
|
||||
let id = w.id;
|
||||
s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) });
|
||||
return Ok(json!({"window": id, "open": true}));
|
||||
}
|
||||
let (_, code) = script_code(s, &name, Some(true)).or_else(|| script_code(s, &name, None)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
|
||||
// Only panels: a script opened as one would be remembered, then forgotten at the next launch.
|
||||
let (_, code) = script_code(s, &name, Some(true)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
|
||||
let out = super::file_more::run_js_named(s, &panel_wrapper(&title, &code), &name)?;
|
||||
let id = s.script_ui.windows.iter().rev().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name).map(|w| w.id);
|
||||
if let Some(id) = id {
|
||||
@@ -338,6 +365,7 @@ fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"window": id, "result": out}))
|
||||
}
|
||||
|
||||
/// The commands of File ▸ Scripts, ScriptUI panels and windows, and script settings.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -358,14 +386,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
|s, p| install(s, p, true)
|
||||
),
|
||||
cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall),
|
||||
crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list),
|
||||
cmd!(
|
||||
"file.uninstallScript",
|
||||
"Uninstall Script",
|
||||
[],
|
||||
None,
|
||||
"{name (a script at the top of a Scripts folder, not `Folder/File.jsx`)}",
|
||||
always,
|
||||
uninstall
|
||||
),
|
||||
crate::query!("file.scripts.list", "List Scripts", "{} → [{name (`Folder/File.jsx` in a subfolder), panel, source: installed|sample|extension}]", list),
|
||||
cmd!(
|
||||
"window.scriptPanel",
|
||||
"ScriptUI Panel",
|
||||
[],
|
||||
None,
|
||||
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel",
|
||||
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`, or `Folder/Panel.jsx` in a subfolder)} → opens it as a dockable panel",
|
||||
always,
|
||||
open_panel
|
||||
),
|
||||
@@ -504,13 +540,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundled and ordinary script file names are accepted; paths are not.
|
||||
#[test]
|
||||
fn script_names_are_bare_file_names() {
|
||||
let more = ["..jsx", "a..b.js", "Äpfel ✓.jsx", "console.jsx", "Concert.jsx", "NULL.jsx", "COM10.jsx", "LPT.jsx"];
|
||||
for name in BUNDLED.iter().map(|b| b.0).chain(more) {
|
||||
assert!(is_script(name) && is_script_name(name), "{name}");
|
||||
}
|
||||
for name in PATHS {
|
||||
// `a/b.jsx` is a script in a folder (see below).
|
||||
for name in PATHS.into_iter().filter(|n| *n != "a/b.jsx") {
|
||||
assert!(!is_script_name(name), "{name:?}");
|
||||
}
|
||||
for name in WINDOWS_ONLY {
|
||||
@@ -528,6 +566,141 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Folder/File.jsx` is accepted; deeper, upward, absolute and `\` names are not.
|
||||
#[test]
|
||||
fn script_names_may_have_one_folder() {
|
||||
for name in ["Motion Kit/Animate.jsx", "a/b.jsx", "Äpfel/✓.js", "..jsx/x.jsx"] {
|
||||
assert!(is_script_name(name), "{name:?}");
|
||||
}
|
||||
// Deeper, up, absolute, an empty part, a `\` path, and the panels' own folder in `Scripts`.
|
||||
let refused = ["a/b/c.jsx", "../x.jsx", "a/../x.jsx", "./x.jsx", "a/..", "/x.jsx", "a//x.jsx", "a/", "a\\b.jsx", "a/b\\c.jsx", "a\0/x.jsx"];
|
||||
for name in refused.into_iter().chain(["ScriptUI Panels/x.jsx", "scriptui panels/x.jsx"]) {
|
||||
assert!(!is_script_name(name), "{name:?}");
|
||||
}
|
||||
for name in ["In:Out/x.jsx", "con/x.jsx", "x/COM1.jsx", "x./y.jsx"] {
|
||||
assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `code` to `rel` in the settings folder of [`session_in_tmp`]'s `root`.
|
||||
fn put(root: &std::path::Path, rel: &str, code: &str) {
|
||||
let p = root.join("settings").join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, code).unwrap();
|
||||
}
|
||||
|
||||
/// Every script command takes `Folder/File.jsx` names and refuses names outside one folder level.
|
||||
#[test]
|
||||
fn scripts_in_a_subfolder_are_listed_run_and_opened_as_folder_and_file() {
|
||||
let (mut s, root) = session_in_tmp("folders");
|
||||
put(&root, "Scripts/Tools/Hello.jsx", "hello");
|
||||
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "animate");
|
||||
put(&root, "Scripts/ScriptUI Panels/Top.jsx", "top");
|
||||
// Two folders deep: neither listed nor run.
|
||||
put(&root, "Scripts/a/b/c.jsx", "too deep");
|
||||
put(&root, "Scripts/ScriptUI Panels/Motion Kit/sub/Deep.jsx", "too deep");
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
let installed: Vec<(&str, bool)> =
|
||||
list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").map(|e| (e["name"].as_str().unwrap(), e["panel"] == true)).collect();
|
||||
assert_eq!(installed, [("Tools/Hello.jsx", false), ("Motion Kit/Animate.jsx", true), ("Top.jsx", true)]);
|
||||
assert_eq!(s.execute("file.runScript", json!({"name": "Tools/Hello.jsx"})).unwrap()["ran"], "hello");
|
||||
// A panel in a folder is titled after its file.
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap();
|
||||
let ran = r["result"]["ran"].as_str().unwrap_or_default();
|
||||
assert!(ran.contains("animate") && ran.contains(r#"__uiDockPanel("Animate")"#), "{r}");
|
||||
assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
|
||||
// A script that is not a panel doesn't open as one.
|
||||
for name in ["Tools/Hello.jsx", "Rename Layers.jsx"] {
|
||||
assert!(bad_params(&s.execute("window.scriptPanel", json!({"name": name}))), "{name}");
|
||||
}
|
||||
for name in [
|
||||
"../x.jsx",
|
||||
"a/b/c.jsx",
|
||||
"a\\b.jsx",
|
||||
"Motion Kit/sub/Deep.jsx",
|
||||
"ScriptUI Panels/Top.jsx",
|
||||
"Startup/Run.jsx",
|
||||
"shutdown/Run.jsx",
|
||||
"Tools/../../../outside.jsx",
|
||||
] {
|
||||
assert_eq!(script_code(&s, name, None), None, "{name:?}");
|
||||
for c in ["file.runScript", "window.scriptPanel", "file.uninstallScript"] {
|
||||
let r = s.execute(c, json!({"name": name}));
|
||||
assert!(bad_params(&r), "{c} `{name}`: {r:?}");
|
||||
}
|
||||
}
|
||||
assert!(["Scripts/a/b/c.jsx", "Scripts/ScriptUI Panels/Top.jsx"].iter().all(|p| root.join("settings").join(p).is_file()));
|
||||
assert!(root.join("outside.jsx").is_file());
|
||||
// Uninstall removes only scripts at the top; one in a folder is deleted there by hand.
|
||||
for name in ["Tools/Hello.jsx", "Motion Kit/Animate.jsx"] {
|
||||
assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": name}))), "{name}");
|
||||
}
|
||||
assert!(["Scripts/Tools/Hello.jsx", "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx"].iter().all(|p| root.join("settings").join(p).is_file()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A subfolder can be a link to an extension's own folder, outside the settings folder: its
|
||||
/// scripts are listed and run from there, but uninstall never deletes through it.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn uninstall_never_deletes_through_a_linked_subfolder() {
|
||||
let (mut s, root) = session_in_tmp("linked");
|
||||
std::fs::create_dir_all(root.join("extension")).unwrap();
|
||||
std::fs::write(root.join("extension").join("Animate.jsx"), "animate").unwrap();
|
||||
std::os::unix::fs::symlink(root.join("extension"), root.join("settings").join(PANELS_DIR).join("Motion Kit")).unwrap();
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}");
|
||||
assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
|
||||
let r = s.execute("file.uninstallScript", json!({"name": "Motion Kit/Animate.jsx"}));
|
||||
assert!(bad_params(&r), "{r:?}");
|
||||
assert!(root.join("extension").join("Animate.jsx").is_file(), "the extension's own file was deleted");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Window ▸ Motion Kit ▸ Animate.jsx: a subfolder is a submenu named after it, where the folder
|
||||
/// sorts; the entries outside folders stay as they were.
|
||||
#[test]
|
||||
fn a_subfolder_is_a_submenu() {
|
||||
use crate::menus::{MenuNode, dynamic, dynamic_nodes};
|
||||
let (s, root) = session_in_tmp("submenus");
|
||||
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "");
|
||||
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Rig.jsx", "");
|
||||
put(&root, "Scripts/ScriptUI Panels/Top.jsx", "");
|
||||
put(&root, "Scripts/Tools/Hello.jsx", "");
|
||||
let cx = Default::default();
|
||||
let item =
|
||||
|label: &str, command: &str, name: &str| json!({"type": "item", "label": label, "command": command, "params": {"name": name}, "shortcut": null});
|
||||
let menus = [
|
||||
(
|
||||
"scriptPanels",
|
||||
json!({"type": "submenu", "label": "Motion Kit", "children": [
|
||||
item("Animate.jsx", "window.scriptPanel", "Motion Kit/Animate.jsx"),
|
||||
item("Rig.jsx", "window.scriptPanel", "Motion Kit/Rig.jsx"),
|
||||
]}),
|
||||
),
|
||||
("scripts", json!({"type": "submenu", "label": "Tools", "children": [item("Hello.jsx", "file.runScript", "Tools/Hello.jsx")]})),
|
||||
];
|
||||
for (menu, folder) in menus {
|
||||
let (nodes, _) = dynamic_nodes(&s, menu, &cx);
|
||||
let (entries, _) = dynamic(&s, menu, &cx);
|
||||
let items: Vec<_> = nodes.iter().filter_map(|n| if let MenuNode::Item(e) = n { Some(e) } else { None }).collect();
|
||||
assert_eq!(items, entries.iter().filter(|e| !e.label.contains('/')).collect::<Vec<_>>(), "{menu}");
|
||||
let folders: Vec<Value> = nodes.iter().filter(|n| matches!(n, MenuNode::Submenu { .. })).map(|n| serde_json::to_value(n).unwrap()).collect();
|
||||
assert_eq!(folders, [folder], "{menu}");
|
||||
}
|
||||
let (panels, _) = dynamic_nodes(&s, "scriptPanels", &cx);
|
||||
let labels: Vec<&str> = panels
|
||||
.iter()
|
||||
.filter_map(|n| match n {
|
||||
MenuNode::Item(e) => Some(e.label.as_str()),
|
||||
MenuNode::Submenu { label, .. } => Some(label),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(labels, ["Ease Presets.jsx", "Layer Tools.jsx", "Motion Kit", "Top.jsx"]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_takes_a_script_name_not_a_path() {
|
||||
let (mut s, root) = session_in_tmp("uninstall");
|
||||
|
||||
@@ -86,6 +86,10 @@ pub struct EditorState {
|
||||
/// Paste sets the same properties of the selected layers.
|
||||
#[serde(skip)]
|
||||
pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>,
|
||||
/// The copied keyframes and values as After Effects keyframe text (one layer's Transform
|
||||
/// properties), for the system clipboard.
|
||||
#[serde(skip)]
|
||||
pub keyframe_text: Option<String>,
|
||||
/// Copied effect instances, with comp-time key offsets from the earliest copied key.
|
||||
/// Paste adds them to the selected layers at the destination comp's CTI.
|
||||
#[serde(skip)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub(crate) mod keyframe_text;
|
||||
pub(crate) mod keyframe_text_effects;
|
||||
pub mod psd_import;
|
||||
pub mod vector;
|
||||
pub mod xml_project;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Copied keyframes as After Effects keyframe text, for the system clipboard (#1908). Other
|
||||
//! applications read this tab-separated format (AE2Blend brings it into Blender). Like After
|
||||
//! Effects it covers one layer: its copied properties, keyframed ones as one row per key (frame
|
||||
//! number in comp time, then the value) and static ones as a single row with no frame. Easing
|
||||
//! and tangents aren't part of the format; EffectCraft's own paste keeps them.
|
||||
|
||||
use effectcraft_project::{AutoOrient, Comp, GroupKind, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, Project, PropGroup, Property, Uid};
|
||||
|
||||
use crate::{KeyRef, keyframe_text_effects};
|
||||
|
||||
/// Transform properties with the unit their columns are labelled with and, for vectors, the
|
||||
/// third component a 2D layer shows. X / Y / Z Position (Separate Dimensions) have no unit.
|
||||
const TRANSFORM: &[(&str, &str, Option<f64>)] = &[
|
||||
("poi", "pixels", Some(0.0)),
|
||||
("anchor", "pixels", Some(0.0)),
|
||||
("position", "pixels", Some(0.0)),
|
||||
("positionX", "", None),
|
||||
("positionY", "", None),
|
||||
("positionZ", "", None),
|
||||
("scale", "percent", Some(100.0)),
|
||||
("orientation", "degrees", None),
|
||||
("rotationX", "degrees", None),
|
||||
("rotationY", "degrees", None),
|
||||
("rotation", "degrees", None),
|
||||
("opacity", "percent", None),
|
||||
];
|
||||
|
||||
/// How a copied property is written: its block's header line, column labels and values.
|
||||
struct Channel {
|
||||
header: String,
|
||||
labels: Vec<String>,
|
||||
values: Values,
|
||||
}
|
||||
|
||||
/// How a property's value becomes the numbers in a row.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Values {
|
||||
Plain,
|
||||
/// A 2D layer's Transform vector: the third component is the one the layer shows (it may
|
||||
/// keep another from pasted 3D keys).
|
||||
Flat(f64),
|
||||
/// RGBA 0–1 as alpha, red, green, blue 0–255.
|
||||
Argb,
|
||||
/// A popup's 0-based choice as After Effects' 1-based one.
|
||||
OneBased,
|
||||
Scale(f64),
|
||||
}
|
||||
|
||||
impl Values {
|
||||
fn row(self, mut v: Vec<f64>) -> Vec<f64> {
|
||||
match (self, v.as_slice()) {
|
||||
(Values::Flat(z), _) => {
|
||||
if let Some(c) = v.get_mut(2) {
|
||||
*c = z;
|
||||
}
|
||||
v
|
||||
}
|
||||
(Values::Argb, [r, g, b, a]) => [a, r, g, b].map(|c| c * 255.0).to_vec(),
|
||||
(Values::OneBased, _) => v.into_iter().map(|c| c + 1.0).collect(),
|
||||
(Values::Scale(s), _) => v.into_iter().map(|c| c * s).collect(),
|
||||
_ => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text for copied keys (layer times) and unanimated properties. `None` unless they all come
|
||||
/// from one layer and at least one property can be written.
|
||||
pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(LayerId, Uid)]) -> Option<String> {
|
||||
let lid = keys.first().map(|k| k.layer).or_else(|| statics.first().map(|s| s.0))?;
|
||||
if keys.iter().any(|k| k.layer != lid) || statics.iter().any(|s| s.0 != lid) {
|
||||
return None;
|
||||
}
|
||||
let layer = comp.layer(lid)?;
|
||||
let fps = comp.frame_rate.as_f64();
|
||||
let mut blocks = String::new();
|
||||
layer.props.walk("", &mut |_, pr| {
|
||||
let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect();
|
||||
times.sort();
|
||||
times.dedup();
|
||||
let rows: Vec<(String, Vec<f64>)> = if !times.is_empty() {
|
||||
let frame = |t| num(layer.comp_time(t).seconds() * fps);
|
||||
times.into_iter().filter_map(|t| Some((frame(t), pr.keys.iter().find(|k| k.time == t)?.value.components()))).collect()
|
||||
} else if statics.iter().any(|s| s.1 == pr.uid) {
|
||||
vec![(String::new(), pr.value.components())]
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let Some(ch) = channel(layer, pr).filter(|ch| !ch.labels.is_empty()) else { return };
|
||||
if rows.iter().any(|(_, v)| v.is_empty()) {
|
||||
return;
|
||||
}
|
||||
blocks.push_str(&format!("{}\n\tFrame\t{}\t\n", ch.header, ch.labels.join("\t")));
|
||||
for (frame, values) in rows {
|
||||
let values: Vec<String> = ch.values.row(values).into_iter().map(num).collect();
|
||||
blocks.push_str(&format!("\t{frame}\t{}\t\n", values.join("\t")));
|
||||
}
|
||||
blocks.push('\n');
|
||||
});
|
||||
if blocks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// After Effects writes a text layer's source as 1 × 1. Other layers without a source of
|
||||
// their own (shapes, cameras) take the comp's size, and all of them its pixel aspect.
|
||||
let (w, h) = match (&layer.source, effectcraft_render::eval::source_size(project, layer)) {
|
||||
(LayerSource::Text, _) => (1, 1),
|
||||
(_, (0, 0)) => (comp.width, comp.height),
|
||||
(_, size) => size,
|
||||
};
|
||||
let par = match layer.source {
|
||||
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match project.item(item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => f.pixel_aspect,
|
||||
Some(ItemKind::Solid(s)) => s.pixel_aspect,
|
||||
Some(ItemKind::Comp(c)) => c.pixel_aspect,
|
||||
_ => comp.pixel_aspect,
|
||||
},
|
||||
_ => comp.pixel_aspect,
|
||||
};
|
||||
// The layout of current After Effects: a `Layer` line before the layer's properties (parsers
|
||||
// such as ae-keyframes reject properties without one), and a tab after every column.
|
||||
Some(format!(
|
||||
"Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\nLayer\n{blocks}\nEnd of Keyframe Data\n",
|
||||
num(fps),
|
||||
num(par),
|
||||
num(comp.pixel_aspect)
|
||||
))
|
||||
}
|
||||
|
||||
/// How the layer's property `pr` is written, by where it sits in the layer: `None` for
|
||||
/// properties the text doesn't carry.
|
||||
fn channel(layer: &Layer, pr: &Property) -> Option<Channel> {
|
||||
let chain = layer.props.node_chain(pr.uid)?;
|
||||
match chain.first().and_then(|n| n.as_group()).map(|g| g.match_id.as_str()) {
|
||||
None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }),
|
||||
Some("transform") => transform(layer, pr),
|
||||
Some("effects") => effect(&chain, pr),
|
||||
Some("masks" | "text" | "contents" | "cameraOptions" | "lightOptions" | "materialOptions") => nested(&chain, pr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed groups by match id, with the kind After Effects names them by: it writes them by kind
|
||||
/// and place (`Mask #2`, `Group #1`), not by name.
|
||||
const INSTANCES: &[(&str, &str)] = &[
|
||||
("mask", "Mask"),
|
||||
("animator", "Animator"),
|
||||
("rangeSelector", "Range Selector"),
|
||||
("wigglySelector", "Wiggly Selector"),
|
||||
("expressionSelector", "Expression Selector"),
|
||||
("group", "Group"),
|
||||
("rect", "Rectangle Path"),
|
||||
("ellipse", "Ellipse Path"),
|
||||
("star", "Polystar Path"),
|
||||
("path", "Path"),
|
||||
("fill", "Fill"),
|
||||
("stroke", "Stroke"),
|
||||
("gfill", "Gradient Fill"),
|
||||
("gstroke", "Gradient Stroke"),
|
||||
("trim", "Trim Paths"),
|
||||
("repeater", "Repeater"),
|
||||
("round", "Round Corners"),
|
||||
("offset", "Offset Paths"),
|
||||
("pucker", "Pucker & Bloat"),
|
||||
("twist", "Twist"),
|
||||
("zigzag", "Zig Zag"),
|
||||
("wiggle", "Wiggle Paths"),
|
||||
("merge", "Merge Paths"),
|
||||
];
|
||||
|
||||
/// A property under Masks, Text, Contents or Camera / Light / Material Options: the group's
|
||||
/// name, then each group down to the property. Instances go by kind and place, and a text
|
||||
/// selector's own properties carry their place too (`Start #1`).
|
||||
fn nested(chain: &[&Node], pr: &Property) -> Option<Channel> {
|
||||
let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect();
|
||||
let parent = groups.last()?;
|
||||
let (labels, values) = columns(&parent.match_id, &pr.match_id, pr, false)?;
|
||||
let mut header = groups.first()?.name.clone();
|
||||
for pair in groups.windows(2) {
|
||||
let [outer, g] = pair else { continue };
|
||||
match INSTANCES.iter().find(|(id, _)| *id == g.match_id).filter(|_| g.is_instance()) {
|
||||
Some((_, kind)) => header.push_str(&format!("\t{kind} #{}", place(outer, g.uid)?)),
|
||||
None => header.push_str(&format!("\t{}", g.name)),
|
||||
}
|
||||
}
|
||||
if parent.match_id.ends_with("Selector") {
|
||||
header.push_str(&format!("\t{} #{}", pr.name, place(parent, pr.uid)?));
|
||||
} else {
|
||||
header.push_str(&format!("\t{}", pr.name));
|
||||
}
|
||||
Some(Channel { header, labels, values })
|
||||
}
|
||||
|
||||
/// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer,
|
||||
/// then the property's name and After Effects index. Properties in twirl-down groups are
|
||||
/// written without their group, as After Effects does.
|
||||
fn effect(chain: &[&Node], pr: &Property) -> Option<Channel> {
|
||||
let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None };
|
||||
let GroupKind::Effect { effect } = &fx.kind else { return None };
|
||||
let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name);
|
||||
let path: String = chain.iter().skip(2).filter_map(|n| n.as_group()).map(|g| format!("{}/", g.match_id)).chain([pr.match_id.clone()]).collect();
|
||||
let (labels, values) = columns(effect, &path, pr, true)?;
|
||||
let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(effect, fx, pr.uid)?);
|
||||
Some(Channel { header, labels, values })
|
||||
}
|
||||
|
||||
/// After Effects' index of an effect property: the effect's hidden input layer is #1, and a
|
||||
/// twirl-down group takes an index where it opens and another where it closes. The hidden
|
||||
/// source choice EffectCraft keeps beside a layer parameter has none. Where After Effects
|
||||
/// numbers a parameter otherwise ([`keyframe_text_effects::INDICES`]), counting goes on from it.
|
||||
fn effect_index(effect: &str, fx: &PropGroup, uid: Uid) -> Option<usize> {
|
||||
fn count(effect: &str, g: &PropGroup, prefix: &str, uid: Uid, n: &mut usize) -> bool {
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Prop(p) if is_layer_source(g, p) => {}
|
||||
Node::Prop(p) => {
|
||||
let path = format!("{prefix}{}", p.match_id);
|
||||
*n = keyframe_text_effects::INDICES.iter().find(|(e, q, _)| *e == effect && *q == path).map_or(*n + 1, |a| a.2);
|
||||
if p.uid == uid {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Node::Group(sub) => {
|
||||
*n += 1;
|
||||
if count(effect, sub, &format!("{prefix}{}/", sub.match_id), uid, n) {
|
||||
return true;
|
||||
}
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
let mut n = 1;
|
||||
count(effect, fx, "", uid, &mut n).then_some(n)
|
||||
}
|
||||
|
||||
fn is_layer_source(g: &PropGroup, p: &Property) -> bool {
|
||||
p.ui == ParamUi::Hidden && g.props().any(|l| l.ui == ParamUi::Layer && effectcraft_effects::layer_source_id(&l.match_id) == p.match_id)
|
||||
}
|
||||
|
||||
/// 1-based position of a child in its group.
|
||||
fn place(group: &PropGroup, uid: Uid) -> Option<usize> {
|
||||
group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1)
|
||||
}
|
||||
|
||||
/// How After Effects writes a property differently from what its editor suggests ([`by_ui`]).
|
||||
#[derive(Clone, Copy)]
|
||||
enum Exception {
|
||||
/// Not written.
|
||||
Skip,
|
||||
Unit(&'static str),
|
||||
Values(Values),
|
||||
}
|
||||
|
||||
/// Properties After Effects writes differently from what their editor suggests, by the effect
|
||||
/// or group they're directly in and their match id.
|
||||
const EXCEPTIONS: &[(&str, &str, Exception)] = &[
|
||||
// Path Options' Path picks a mask, which After Effects doesn't write.
|
||||
("pathOptions", "path", Exception::Skip),
|
||||
("mask", "feather", Exception::Unit("pixels")),
|
||||
("mask", "expansion", Exception::Unit("pixels")),
|
||||
("cameraOptions", "zoom", Exception::Unit("pixels")),
|
||||
("cameraOptions", "focusDistance", Exception::Unit("pixels")),
|
||||
("cameraOptions", "aperture", Exception::Unit("pixels")),
|
||||
("cameraOptions", "highlightGain", Exception::Unit("")),
|
||||
("cameraOptions", "highlightSaturation", Exception::Unit("")),
|
||||
// After Effects keeps the threshold as 0–1.
|
||||
("cameraOptions", "highlightThreshold", Exception::Values(Values::Scale(1.0 / 255.0))),
|
||||
("lightOptions", "shadowDiffusion", Exception::Unit("pixels")),
|
||||
// Off, On, Only: written from 0.
|
||||
("materialOptions", "castsShadows", Exception::Values(Values::Plain)),
|
||||
("materialOptions", "acceptsShadows", Exception::Values(Values::Plain)),
|
||||
];
|
||||
|
||||
/// Column labels and values for a property in `scope`, the effect (by id) or group (by match
|
||||
/// id) it's directly in, at `path` there (an effect property's groups, then its match id): [`by_ui`]
|
||||
/// unless [`EXCEPTIONS`] or, in effects, [`keyframe_text_effects::UNITS`] say otherwise.
|
||||
fn columns(scope: &str, path: &str, pr: &Property, in_effect: bool) -> Option<(Vec<String>, Values)> {
|
||||
let (by_editor, values) = by_ui(pr, in_effect)?;
|
||||
let exception = EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == path).map(|e| e.2);
|
||||
let effect_unit = || keyframe_text_effects::UNITS.iter().find(|(e, p, _)| in_effect && *e == scope && *p == path).map(|u| Exception::Unit(u.2));
|
||||
Some(match exception.or_else(effect_unit) {
|
||||
None => (by_editor, values),
|
||||
Some(Exception::Skip) => return None,
|
||||
Some(Exception::Unit(unit)) => (labels(unit, pr.value.dims()), values),
|
||||
Some(Exception::Values(values)) => (by_editor, values),
|
||||
})
|
||||
}
|
||||
|
||||
/// Column labels and values for a property, by how it's edited. After Effects labels most 0–100
|
||||
/// sliders (opacities) as percent, and ±100 ones too outside effects (text selector amounts),
|
||||
/// and other sliders, checkboxes and popups with no unit. [`keyframe_text_effects::UNITS`] lists
|
||||
/// the effect parameters After Effects labels otherwise.
|
||||
fn by_ui(pr: &Property, in_effect: bool) -> Option<(Vec<String>, Values)> {
|
||||
let dims = pr.value.dims();
|
||||
Some(match &pr.ui {
|
||||
ParamUi::Color => (["alpha ", "red ", "green ", "blue "].map(String::from).to_vec(), Values::Argb),
|
||||
ParamUi::Popup { .. } => (labels("", dims), Values::OneBased),
|
||||
ParamUi::Slider { min, max, .. } if *max == 100.0 && (*min == 0.0 || (*min == -100.0 && !in_effect)) => (labels("percent", dims), Values::Plain),
|
||||
ParamUi::Percent => (labels("percent", dims), Values::Plain),
|
||||
ParamUi::Angle => (labels("degrees", dims), Values::Plain),
|
||||
ParamUi::Point | ParamUi::Point3 => (labels("pixels", dims), Values::Plain),
|
||||
ParamUi::Number | ParamUi::Slider { .. } | ParamUi::Pixels | ParamUi::Checkbox => (labels("", dims), Values::Plain),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn transform(layer: &Layer, pr: &Property) -> Option<Channel> {
|
||||
let (id, unit, z) = TRANSFORM.iter().find(|(id, ..)| *id == pr.match_id)?;
|
||||
if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) {
|
||||
return None;
|
||||
}
|
||||
// After Effects labels only the first of Orientation's three columns, and names Z Rotation
|
||||
// plain Rotation (as AE2Blend expects).
|
||||
let labels = if *id == "orientation" { vec![format!("X {unit}")] } else { labels(unit, pr.value.dims()) };
|
||||
let values = match z {
|
||||
Some(z) if !layer.is_3d() => Values::Flat(*z),
|
||||
_ => Values::Plain,
|
||||
};
|
||||
Some(Channel { header: format!("Transform\t{}", pr.name), labels, values })
|
||||
}
|
||||
|
||||
/// Column labels: the unit, or one per axis (`X pixels`, `Y pixels`; `X ` with no unit, as After
|
||||
/// Effects writes a shape's Size).
|
||||
fn labels(unit: &str, dims: usize) -> Vec<String> {
|
||||
match dims {
|
||||
0 => vec![],
|
||||
1 => vec![unit.to_string()],
|
||||
_ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the layer uses this Transform property: one-node cameras (and lights not aimed at
|
||||
/// it) have no Point of Interest, and Separate Dimensions replaces Position with X / Y / Z
|
||||
/// Position.
|
||||
fn active(layer: &Layer, id: &str) -> bool {
|
||||
match id {
|
||||
"poi" => !(layer.is_camera() || layer.is_light()) || layer.auto_orient == AutoOrient::TowardsPointOfInterest,
|
||||
"position" => layer.transform().is_none_or(|tr| tr.get("positionX").is_none()),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number with at most three decimals and no trailing zeros (`23.976`, `960`, `-0.5`).
|
||||
fn num(v: f64) -> String {
|
||||
let s = format!("{:.3}", if v.is_finite() { v } else { 0.0 });
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! After Effects' index and unit for effect parameters where they differ from what
|
||||
//! [`crate::keyframe_text`]'s rules give, as After Effects 2019 writes them in its keyframe text.
|
||||
//! After Effects keeps an effect's parameter indices for good (projects store them), so they
|
||||
//! hold for later versions. Parameters After Effects lacks, or names differently and keeps
|
||||
//! elsewhere, aren't listed.
|
||||
|
||||
/// Effect id, parameter path in the effect (its groups' match ids, then its own) and After
|
||||
/// Effects' index for it; the parameters after it count on from there. After Effects numbers
|
||||
/// legacy parameters it hides, so its indices skip ahead.
|
||||
pub(crate) const INDICES: &[(&str, &str, usize)] = &[
|
||||
("ec.3d.channelextract", "clampOutput", 6),
|
||||
("ec.3d.channelextract", "antialias", 5),
|
||||
("ec.3d.extractor", "blackPoint", 4),
|
||||
("ec.audio.parametriceq", "band1Enable", 3),
|
||||
("ec.blur.radial", "amount", 3),
|
||||
("ec.color.cccolorneutralizer", "midtonesUnbalance", 8),
|
||||
("ec.color.cccolorneutralizer", "highlightsUnbalance", 14),
|
||||
("ec.color.cccolorneutralizer", "pinning", 20),
|
||||
("ec.color.cckernel", "k1", 3),
|
||||
("ec.color.cckernel", "k4", 8),
|
||||
("ec.color.cckernel", "k7", 13),
|
||||
("ec.color.cckernel", "scale", 17),
|
||||
("ec.color.cckernel", "absolute", 18),
|
||||
("ec.color.colorstabilizer", "stabilize", 2),
|
||||
("ec.color.huesaturation", "hue", 4),
|
||||
("ec.color.huesaturation", "colorize", 7),
|
||||
("ec.color.levels", "inBlack", 4),
|
||||
("ec.color.levelsic", "rgb/rgbInBlack", 5),
|
||||
("ec.color.lumetri", "highDynamicRange", 3),
|
||||
("ec.color.lumetri", "basicCorrection/inputLut", 8),
|
||||
("ec.color.lumetri", "basicCorrection/whiteBalance/temperature", 12),
|
||||
("ec.color.lumetri", "basicCorrection/tone/hdrSpecular", 22),
|
||||
("ec.color.lumetri", "basicCorrection/saturation", 26),
|
||||
("ec.color.lumetri", "creative/look", 32),
|
||||
("ec.color.lumetri", "creative/lookIntensity", 33),
|
||||
("ec.color.lumetri", "creative/adjustments/tintBalance", 40),
|
||||
("ec.color.lumetri", "hslSecondary/key/showMask", 83),
|
||||
("ec.color.lumetri", "hslSecondary/refine/denoise", 90),
|
||||
("ec.color.lumetri", "hslSecondary/correction/temperature", 96),
|
||||
("ec.color.lumetri", "vignette/vignetteAmount", 105),
|
||||
("ec.color.selectivecolor", "details/reds/redsCyan", 10),
|
||||
("ec.color.videolimiter", "compressionBeforeClipping", 3),
|
||||
("ec.distort.ccflomotion", "knot1", 3),
|
||||
("ec.distort.ccpowerpin", "expandTop", 9),
|
||||
("ec.distort.upscale", "scale", 4),
|
||||
("ec.distort.puppet", "refinement", 3),
|
||||
("ec.control.stereo3d", "configuration", 3),
|
||||
("ec.control.stereo3d", "convergenceOf", 8),
|
||||
("ec.generate.advancedlightning", "turbulence", 17),
|
||||
("ec.generate.advancedlightning", "alphaObstacle", 16),
|
||||
("ec.generate.advancedlightning", "compositeOnOriginal", 21),
|
||||
("ec.generate.ccgluegun", "style", 9),
|
||||
("ec.generate.cclightburst", "setColor", 7),
|
||||
("ec.generate.cclightrays", "colorFromSource", 8),
|
||||
("ec.generate.cclightrays", "color", 10),
|
||||
("ec.generate.circle", "feather/feather", 7),
|
||||
("ec.generate.paintbucket", "color", 9),
|
||||
("ec.vr.blur", "frameLayout", 3),
|
||||
("ec.vr.blur", "blurriness", 6),
|
||||
("ec.vr.chromaticaberrations", "frameLayout", 3),
|
||||
("ec.vr.chromaticaberrations", "aberrationRed", 7),
|
||||
("ec.vr.colorgradients", "frameLayout", 3),
|
||||
("ec.vr.colorgradients", "point1", 10),
|
||||
("ec.vr.colorgradients", "point2", 12),
|
||||
("ec.vr.colorgradients", "point3", 14),
|
||||
("ec.vr.colorgradients", "point4", 16),
|
||||
("ec.vr.colorgradients", "point5", 18),
|
||||
("ec.vr.colorgradients", "blend", 7),
|
||||
("ec.vr.colorgradients", "opacity", 27),
|
||||
("ec.vr.denoise", "frameLayout", 3),
|
||||
("ec.vr.denoise", "noiseType", 6),
|
||||
("ec.vr.digitalglitch", "frameLayout", 3),
|
||||
("ec.vr.digitalglitch", "masterAmplitude", 9),
|
||||
("ec.vr.digitalglitch", "distortionRate", 16),
|
||||
("ec.vr.digitalglitch", "distortionComplexity", 15),
|
||||
("ec.vr.digitalglitch", "distortionEvolution", 17),
|
||||
("ec.vr.digitalglitch", "colorDistortion", 11),
|
||||
("ec.vr.digitalglitch", "randomSeed", 39),
|
||||
("ec.vr.fractalnoise", "frameLayout", 3),
|
||||
("ec.vr.fractalnoise", "fractalType", 6),
|
||||
("ec.vr.fractalnoise", "randomSeed", 25),
|
||||
("ec.vr.glow", "frameLayout", 3),
|
||||
("ec.vr.glow", "luminanceThreshold", 6),
|
||||
("ec.vr.planetosphere", "frameLayout", 3),
|
||||
("ec.vr.planetosphere", "scale", 6),
|
||||
("ec.vr.planetosphere", "feather", 8),
|
||||
("ec.vr.planetosphere", "tilt", 15),
|
||||
("ec.vr.rotatesphere", "frameLayout", 3),
|
||||
("ec.vr.rotatesphere", "invertRotation", 9),
|
||||
("ec.vr.rotatesphere", "tilt", 6),
|
||||
("ec.vr.sharpen", "frameLayout", 3),
|
||||
("ec.vr.sharpen", "sharpenAmount", 6),
|
||||
("ec.key.colordifference", "view", 3),
|
||||
("ec.key.colorrange", "fuzziness", 3),
|
||||
("ec.key.extract", "channel", 3),
|
||||
("ec.key.linearcolor", "view", 3),
|
||||
("ec.matte.refinehard", "feather", 2),
|
||||
("ec.matte.refinehard", "reduceChatter", 5),
|
||||
("ec.matte.refinehard", "decontaminationAmount", 14),
|
||||
("ec.matte.refinesoft", "reduceChatter", 10),
|
||||
("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", 3),
|
||||
("ec.matte.rotobrush", "rotoBrushMatte/feather", 13),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/smooth", 22),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/reduceChatter", 27),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/useMotionBlur", 29),
|
||||
("ec.noise.noisealpha", "noiseOptions/cycleNoise", 8),
|
||||
("ec.obsolete.basictext", "position", 2),
|
||||
("ec.key.spill", "suppression", 3),
|
||||
("ec.perspective.cameratracker", "shotType", 4),
|
||||
("ec.perspective.cameratracker", "advanced/solveMethod", 12),
|
||||
("ec.perspective.cameratracker", "advanced/detailedAnalysis", 17),
|
||||
("ec.perspective.cameratracker", "advanced/autoDeletePoints", 18),
|
||||
("ec.perspective.cccylinder", "rotationX", 9),
|
||||
("ec.perspective.cccylinder", "render", 13),
|
||||
("ec.perspective.cccylinder", "lightIntensity", 15),
|
||||
("ec.perspective.cccylinder", "ambient", 21),
|
||||
("ec.sim.ccdrizzle", "light/lightIntensity", 10),
|
||||
("ec.sim.ccdrizzle", "light/lightHeight", 13),
|
||||
("ec.sim.ccdrizzle", "light/lightDirection", 15),
|
||||
("ec.sim.ccmrmercury", "blobBirthSize", 15),
|
||||
("ec.sim.ccmrmercury", "light/lightIntensity", 19),
|
||||
("ec.sim.ccmrmercury", "light/lightHeight", 22),
|
||||
("ec.sim.ccmrmercury", "light/lightDirection", 24),
|
||||
("ec.sim.ccparticlesystems2", "particle/maxOpacity", 24),
|
||||
("ec.sim.ccparticlesystems2", "particle/birthColor", 27),
|
||||
("ec.sim.ccrainfall", "transferMode", 16),
|
||||
("ec.sim.ccrainfall", "extras/randomSeed", 23),
|
||||
("ec.sim.ccsnowfall", "color", 19),
|
||||
("ec.sim.ccsnowfall", "transferMode", 26),
|
||||
("ec.sim.ccsnowfall", "extras/randomSeed", 32),
|
||||
("ec.stylize.ccblockload", "startCleared", 4),
|
||||
("ec.stylize.cchextile", "rotate", 6),
|
||||
("ec.stylize.glow", "colorLooping", 9),
|
||||
("ec.text.timecode", "timeUnits", 5),
|
||||
("ec.text.timecode", "textPosition", 9),
|
||||
("ec.utility.facemeasurements", "headOrientationX", 9),
|
||||
("ec.utility.facemeasurements", "rightEyebrowRaise", 20),
|
||||
("ec.utility.facemeasurements", "mouthOffsetX", 26),
|
||||
("ec.utility.facetrackpoints", "leftEyebrowInner", 10),
|
||||
("ec.utility.facetrackpoints", "leftEyebrowMiddle", 12),
|
||||
("ec.utility.facetrackpoints", "leftEyebrowOuter", 14),
|
||||
("ec.utility.facetrackpoints", "rightEyebrowInner", 24),
|
||||
("ec.utility.facetrackpoints", "rightEyebrowMiddle", 26),
|
||||
("ec.utility.facetrackpoints", "rightEyebrowOuter", 28),
|
||||
("ec.utility.facetrackpoints", "leftEyeInner", 15),
|
||||
("ec.utility.facetrackpoints", "leftEyeOuter", 21),
|
||||
("ec.utility.facetrackpoints", "leftPupil", 20),
|
||||
("ec.utility.facetrackpoints", "rightEyeInner", 29),
|
||||
("ec.utility.facetrackpoints", "rightEyeOuter", 35),
|
||||
("ec.utility.facetrackpoints", "rightPupil", 34),
|
||||
("ec.utility.facetrackpoints", "noseTip", 41),
|
||||
("ec.utility.facetrackpoints", "leftNostril", 44),
|
||||
("ec.utility.facetrackpoints", "rightNostril", 46),
|
||||
("ec.utility.facetrackpoints", "mouthLeft", 49),
|
||||
("ec.utility.facetrackpoints", "mouthRight", 55),
|
||||
("ec.utility.facetrackpoints", "mouthTop", 52),
|
||||
("ec.utility.facetrackpoints", "mouthBottom", 58),
|
||||
("ec.utility.facetrackpoints", "chin", 84),
|
||||
];
|
||||
|
||||
/// Effect id, parameter path and the unit After Effects labels the parameter's columns with.
|
||||
pub(crate) const UNITS: &[(&str, &str, &str)] = &[
|
||||
("ec.3d.depthoffield", "focalBias", ""),
|
||||
("ec.3d.idmatte", "feather", ""),
|
||||
("ec.audio.flangechorus", "voiceSeparationTime", ""),
|
||||
("ec.audio.flangechorus", "voicePhaseChange", ""),
|
||||
("ec.audio.modulator", "modulationRate", ""),
|
||||
("ec.audio.parametriceq", "band1BoostCut", "percent"),
|
||||
("ec.audio.parametriceq", "band2BoostCut", "percent"),
|
||||
("ec.audio.parametriceq", "band3BoostCut", "percent"),
|
||||
("ec.audio.stereomixer", "leftLevel", "percent"),
|
||||
("ec.audio.stereomixer", "rightLevel", "percent"),
|
||||
("ec.audio.stereomixer", "leftPan", "percent"),
|
||||
("ec.audio.stereomixer", "rightPan", "percent"),
|
||||
("ec.blur.bilateral", "threshold", ""),
|
||||
("ec.blur.ccradial", "amount", "pixels"),
|
||||
("ec.blur.ccradialfast", "amount", ""),
|
||||
("ec.blur.camerashakedeblur", "strength", "percent"),
|
||||
("ec.blur.radial", "amount", ""),
|
||||
("ec.blur.smart", "radius", ""),
|
||||
("ec.blur.smart", "threshold", ""),
|
||||
("ec.color.autocolor", "blackClip", "percent"),
|
||||
("ec.color.autocolor", "whiteClip", "percent"),
|
||||
("ec.color.autocontrast", "blackClip", "percent"),
|
||||
("ec.color.autocontrast", "whiteClip", "percent"),
|
||||
("ec.color.autolevels", "blackClip", "percent"),
|
||||
("ec.color.autolevels", "whiteClip", "percent"),
|
||||
("ec.color.brightnesscontrast", "contrast", "pixels"),
|
||||
("ec.color.cccolorneutralizer", "pinning", ""),
|
||||
("ec.color.cccolorneutralizer", "blendWOriginal", ""),
|
||||
("ec.color.changecolor", "lightnessTransform", "pixels"),
|
||||
("ec.color.changecolor", "saturationTransform", "pixels"),
|
||||
("ec.color.colorbalance", "shadowRed", "pixels"),
|
||||
("ec.color.colorbalance", "shadowGreen", "pixels"),
|
||||
("ec.color.colorbalance", "shadowBlue", "pixels"),
|
||||
("ec.color.colorbalance", "midRed", "pixels"),
|
||||
("ec.color.colorbalance", "midGreen", "pixels"),
|
||||
("ec.color.colorbalance", "midBlue", "pixels"),
|
||||
("ec.color.colorbalance", "hiRed", "pixels"),
|
||||
("ec.color.colorbalance", "hiGreen", "pixels"),
|
||||
("ec.color.colorbalance", "hiBlue", "pixels"),
|
||||
("ec.color.colorbalancehls", "lightness", "pixels"),
|
||||
("ec.color.colorbalancehls", "saturation", "pixels"),
|
||||
("ec.color.colorstabilizer", "sampleSize", "percent"),
|
||||
("ec.color.gammapedestalgain", "blackStretch", "percent"),
|
||||
("ec.color.huesaturation", "saturation", "pixels"),
|
||||
("ec.color.huesaturation", "lightness", "pixels"),
|
||||
("ec.color.huesaturation", "colorizeSaturation", ""),
|
||||
("ec.color.huesaturation", "colorizeLightness", "pixels"),
|
||||
("ec.color.lumetri", "creative/adjustments/fadedFilm", ""),
|
||||
("ec.color.lumetri", "hslSecondary/refine/denoise", ""),
|
||||
("ec.color.lumetri", "hslSecondary/refine/blur", ""),
|
||||
("ec.color.lumetri", "vignette/vignetteMidpoint", ""),
|
||||
("ec.color.lumetri", "vignette/vignetteFeather", ""),
|
||||
("ec.color.psarbitrarymap", "phase", "degrees"),
|
||||
("ec.color.selectivecolor", "details/reds/redsCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/reds/redsMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/reds/redsYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/reds/redsBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksBlack", "percent"),
|
||||
("ec.color.shadowhighlight", "shadowAmount", ""),
|
||||
("ec.color.shadowhighlight", "highlightAmount", ""),
|
||||
("ec.color.shadowhighlight", "moreOptions/shadowTonalWidth", ""),
|
||||
("ec.color.shadowhighlight", "moreOptions/highlightTonalWidth", ""),
|
||||
("ec.color.shadowhighlight", "moreOptions/blackClip", "percent"),
|
||||
("ec.color.shadowhighlight", "moreOptions/whiteClip", "percent"),
|
||||
("ec.distort.bulge", "height", "pixels"),
|
||||
("ec.distort.bulge", "taperRadius", ""),
|
||||
("ec.distort.ccbendit", "bend", "pixels"),
|
||||
("ec.distort.ccbender", "amount", "pixels"),
|
||||
("ec.distort.ccgriddler", "tileSize", "percent"),
|
||||
("ec.distort.cctiler", "scale", "percent"),
|
||||
("ec.distort.upscale", "scale", "percent"),
|
||||
("ec.distort.magnify", "magnification", "pixels"),
|
||||
("ec.distort.magnify", "size", "percent"),
|
||||
("ec.distort.puppet", "refinement", ""),
|
||||
("ec.distort.ripple", "radius", ""),
|
||||
("ec.distort.ripple", "speed", "percent"),
|
||||
("ec.distort.transform", "opacity", ""),
|
||||
("ec.distort.turbulentdisplace", "complexity", "percent"),
|
||||
("ec.distort.twirl", "radius", ""),
|
||||
("ec.distort.wavewarp", "width", "percent"),
|
||||
("ec.distort.wavewarp", "speed", "pixels"),
|
||||
("ec.generate.fourcolor", "blend", "percent"),
|
||||
("ec.generate.advancedlightning", "coreSettings/coreRadius", ""),
|
||||
("ec.generate.advancedlightning", "glowSettings/glowRadius", "percent"),
|
||||
("ec.generate.advancedlightning", "alphaObstacle", "pixels"),
|
||||
("ec.generate.ccgluegun", "reflection", ""),
|
||||
("ec.generate.cclightburst", "rayLength", ""),
|
||||
("ec.generate.cclightsweep", "edgeThickness", ""),
|
||||
("ec.generate.ccthreads", "coverage", ""),
|
||||
("ec.generate.ccthreads", "shadowing", ""),
|
||||
("ec.generate.ccthreads", "texture", ""),
|
||||
("ec.generate.checkerboard", "width", "percent"),
|
||||
("ec.generate.checkerboard", "height", "percent"),
|
||||
("ec.generate.grid", "width", "percent"),
|
||||
("ec.generate.grid", "height", "percent"),
|
||||
("ec.generate.lensflare", "flareBrightness", "percent"),
|
||||
("ec.vr.chromaticaberrations", "falloff", ""),
|
||||
("ec.vr.denoise", "noiseLevel", ""),
|
||||
("ec.vr.digitalglitch", "colorDistortion", ""),
|
||||
("ec.vr.fractalnoise", "subSettings/subInfluence", ""),
|
||||
("ec.vr.glow", "luminanceThreshold", ""),
|
||||
("ec.vr.planetosphere", "feather", ""),
|
||||
("ec.vr.sharpen", "sharpenAmount", ""),
|
||||
("ec.key.advancedspill", "suppression", "percent"),
|
||||
("ec.key.advancedspill", "ultraSettings/tolerance", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/desaturate", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/spillRange", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/spillColorCorrection", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/lumaCorrection", ""),
|
||||
("ec.key.ccsimplewireremoval", "thickness", ""),
|
||||
("ec.key.keycleaner", "additionalEdgeRadius", ""),
|
||||
("ec.key.keycleaner", "alphaContrast", "percent"),
|
||||
("ec.matte.refinehard", "feather", ""),
|
||||
("ec.matte.refinehard", "increaseDecontaminationRadius", ""),
|
||||
("ec.matte.refinesoft", "smooth", ""),
|
||||
("ec.matte.refinesoft", "feather", ""),
|
||||
("ec.matte.refinesoft", "shiftEdge", "percent"),
|
||||
("ec.matte.refinesoft", "decontamination/increaseDecontaminationRadius", ""),
|
||||
("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", ""),
|
||||
("ec.matte.rotobrush", "rotoBrushMatte/feather", ""),
|
||||
("ec.matte.rotobrush", "rotoBrushMatte/shiftEdge", "percent"),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/smooth", ""),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/feather", ""),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/shiftEdge", "percent"),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/decontamination/increaseDecontaminationRadius", ""),
|
||||
("ec.matte.simplechoker", "chokeMatte", "pixels"),
|
||||
("ec.noise.fractal", "transform/scale", "percent"),
|
||||
("ec.noise.fractal", "transform/scaleWidth", "percent"),
|
||||
("ec.noise.fractal", "transform/scaleHeight", "percent"),
|
||||
("ec.noise.fractal", "complexity", "percent"),
|
||||
("ec.noise.fractal", "subSettings/subInfluence", ""),
|
||||
("ec.noise.turbulent", "transform/scale", "percent"),
|
||||
("ec.noise.turbulent", "transform/scaleWidth", "percent"),
|
||||
("ec.noise.turbulent", "transform/scaleHeight", "percent"),
|
||||
("ec.noise.turbulent", "complexity", "percent"),
|
||||
("ec.noise.turbulent", "subSettings/subInfluence", ""),
|
||||
("ec.key.colorkey", "edgeFeather", ""),
|
||||
("ec.obsolete.lightning", "branchAngle", ""),
|
||||
("ec.key.luma", "edgeFeather", ""),
|
||||
("ec.blur.reduceflicker", "softness", ""),
|
||||
("ec.perspective.cameratracker", "trackPointSize", "percent"),
|
||||
("ec.perspective.cameratracker", "targetSize", "percent"),
|
||||
("ec.perspective.cccylinder", "lightHeight", "pixels"),
|
||||
("ec.sim.ccbubbles", "bubbleSize", "percent"),
|
||||
("ec.sim.ccdrizzle", "displacement", ""),
|
||||
("ec.sim.ccdrizzle", "light/lightHeight", "pixels"),
|
||||
("ec.sim.ccmrmercury", "longevity", ""),
|
||||
("ec.sim.ccmrmercury", "blobInfluence", "percent"),
|
||||
("ec.sim.ccmrmercury", "light/lightHeight", "pixels"),
|
||||
("ec.sim.ccmrmercury", "shading/metal", ""),
|
||||
("ec.sim.ccparticlesystems2", "birthRate", ""),
|
||||
("ec.sim.ccparticlesystems2", "longevity", ""),
|
||||
("ec.sim.ccparticlesystems2", "physics/gravity", "pixels"),
|
||||
("ec.sim.ccrainfall", "windVariation", ""),
|
||||
("ec.sim.ccrainfall", "opacity", ""),
|
||||
("ec.sim.ccsnowfall", "sizeVariation", ""),
|
||||
("ec.sim.ccsnowfall", "speedVariation", ""),
|
||||
("ec.sim.ccsnowfall", "windVariation", ""),
|
||||
("ec.sim.ccsnowfall", "wiggle/wiggleAmount", ""),
|
||||
("ec.sim.ccsnowfall", "wiggle/wiggleAmountVariation", ""),
|
||||
("ec.sim.ccsnowfall", "wiggle/wiggleFrequencyVariation", ""),
|
||||
("ec.sim.ccsnowfall", "opacity", ""),
|
||||
("ec.sim.ccstarburst", "speed", "percent"),
|
||||
("ec.sim.ccstarburst", "size", "pixels"),
|
||||
("ec.stylize.ccblockload", "completion", ""),
|
||||
("ec.stylize.ccburnfilm", "burn", ""),
|
||||
("ec.stylize.cchextile", "smearing", ""),
|
||||
("ec.stylize.cckaleida", "size", "percent"),
|
||||
("ec.stylize.ccvignette", "pinHighlights", ""),
|
||||
("ec.stylize.cartoon", "detailThreshold", ""),
|
||||
("ec.stylize.cartoon", "fill/shadingSmoothness", ""),
|
||||
("ec.stylize.cartoon", "edge/edgeSoftness", ""),
|
||||
("ec.stylize.cartoon", "advanced/edgeBlackLevel", ""),
|
||||
("ec.stylize.glow", "colorLoops", "percent"),
|
||||
("ec.stylize.roughenedges", "stretch", "pixels"),
|
||||
("ec.time.pixelmotionblur", "vectorDetail", ""),
|
||||
("ec.transition.blockdissolve", "blockWidth", "percent"),
|
||||
("ec.transition.blockdissolve", "blockHeight", "percent"),
|
||||
("ec.transition.ccgridwipe", "border", "pixels"),
|
||||
("ec.transition.ccgridwipe", "tiles", "percent"),
|
||||
("ec.transition.cclinesweep", "completion", ""),
|
||||
("ec.transition.ccscalewipe", "stretch", "pixels"),
|
||||
("ec.transition.venetian", "width", "percent"),
|
||||
("ec.transition.venetian", "feather", ""),
|
||||
("ec.utility.cineon", "highlightRolloff", ""),
|
||||
("ec.utility.facemeasurements", "headOrientationX", "degrees"),
|
||||
("ec.utility.facemeasurements", "headOrientationY", "degrees"),
|
||||
("ec.utility.facemeasurements", "headOrientationZ", "degrees"),
|
||||
("ec.utility.facemeasurements", "leftEyebrowRaise", "percent"),
|
||||
("ec.utility.facemeasurements", "rightEyebrowRaise", "percent"),
|
||||
("ec.utility.facemeasurements", "mouthOffsetX", "percent"),
|
||||
("ec.utility.facemeasurements", "mouthOffsetY", "percent"),
|
||||
];
|
||||
@@ -70,6 +70,7 @@ pub use home::templates;
|
||||
pub use interchange::psd_import;
|
||||
pub use interchange::vector;
|
||||
pub use interchange::xml_project;
|
||||
use interchange::{keyframe_text, keyframe_text_effects};
|
||||
pub use render_queue::{ExportJob, ExportResult, Exporter, JobState};
|
||||
pub use settings::autosave;
|
||||
pub use settings::config;
|
||||
|
||||
@@ -82,7 +82,8 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
|
||||
/// Recent Script Files), `history` (undo steps, newest
|
||||
/// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and
|
||||
/// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and
|
||||
/// `scriptPanels` (ScriptUI panels).
|
||||
/// `scriptPanels` (ScriptUI panels; a script in a subfolder is labelled `Folder/File.jsx` here,
|
||||
/// and [`dynamic_nodes`] puts it in the folder's submenu).
|
||||
pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
|
||||
use serde_json::json;
|
||||
let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
|
||||
@@ -167,6 +168,31 @@ pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<
|
||||
}
|
||||
}
|
||||
|
||||
/// [`dynamic`] as menu nodes, which is what frontends draw: scripts and panels in a subfolder
|
||||
/// (`Motion Kit/Animate.jsx`) go into a submenu named after the folder (`Motion Kit ▸
|
||||
/// Animate.jsx`), placed where the folder sorts among the other entries.
|
||||
pub fn dynamic_nodes(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuNode>, Option<&'static str>) {
|
||||
let (entries, empty) = dynamic(s, name, cx);
|
||||
let scripts = matches!(name, "scripts" | "scriptPanels");
|
||||
let mut out: Vec<MenuNode> = vec![];
|
||||
for mut e in entries {
|
||||
let Some((folder, file)) = e.label.split_once('/').filter(|_| scripts).map(|(d, f)| (d.to_string(), f.to_string())) else {
|
||||
out.push(MenuNode::Item(e));
|
||||
continue;
|
||||
};
|
||||
e.label = file;
|
||||
let open = out.iter_mut().find_map(|n| match n {
|
||||
MenuNode::Submenu { label, children } if *label == folder => Some(children),
|
||||
_ => None,
|
||||
});
|
||||
match open {
|
||||
Some(children) => children.push(MenuNode::Item(e)),
|
||||
None => out.push(MenuNode::Submenu { label: folder, children: vec![MenuNode::Item(e)] }),
|
||||
}
|
||||
}
|
||||
(out, empty)
|
||||
}
|
||||
|
||||
/// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or
|
||||
/// workspace, like After Effects).
|
||||
pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String {
|
||||
|
||||
@@ -208,6 +208,16 @@ pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 {
|
||||
let bpp = if om.channels == effectcraft_project::render_queue::Channels::Rgba { 4 } else { 3 };
|
||||
((w as u64 * bpp).next_multiple_of(2) * h as u64) as f64
|
||||
}
|
||||
// An 18-byte header and 24-bit BGR, 32-bit BGRA or 8-bit grey (Alpha) pixels, uncompressed.
|
||||
OutputFormat::TargaSequence => {
|
||||
use effectcraft_project::render_queue::Channels;
|
||||
let bpp = match om.channels {
|
||||
Channels::Rgb => 3,
|
||||
Channels::Rgba => 4,
|
||||
Channels::Alpha => 1,
|
||||
};
|
||||
(18 + w as u64 * h as u64 * bpp) as f64
|
||||
}
|
||||
OutputFormat::WebM => mbps(8.0) * px / fps,
|
||||
OutputFormat::Gif => w as f64 * h as f64 * 0.5,
|
||||
OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps,
|
||||
|
||||
@@ -116,6 +116,25 @@ fn persists_in_project_file() {
|
||||
assert!(effectcraft_project::Project::from_json(&old).unwrap().render_queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targa_sequence_output_module() {
|
||||
let (mut s, _) = rq_session();
|
||||
let a = s.execute("renderQueue.add", json!({"format": "tga", "output": "/out/[compName]_[#####].tga", "channels": "rgba"})).unwrap();
|
||||
let om = &s.project.render_queue[0].output;
|
||||
assert_eq!((format!("{:?}", om.format), om.channels.label()), ("TargaSequence".to_string(), "RGB + Alpha"));
|
||||
assert!(a["outputPath"].as_str().unwrap().ends_with("_[#####].tga"), "{a}");
|
||||
// Uncompressed: an 18-byte header and 4 bytes a pixel with alpha, 3 without.
|
||||
assert_eq!(crate::render_queue::bytes_per_frame(om, 7, 4, 10.0), 18.0 + 7.0 * 4.0 * 4.0);
|
||||
s.execute("renderQueue.setOutputModule", json!({"index": 1, "channels": "rgb"})).unwrap();
|
||||
assert_eq!(crate::render_queue::bytes_per_frame(&s.project.render_queue[0].output, 7, 4, 10.0), 18.0 + 7.0 * 4.0 * 3.0);
|
||||
let f = s.execute("renderQueue.formats", json!({})).unwrap();
|
||||
let tga = f["formats"].as_array().unwrap().iter().find(|f| f["id"] == "TargaSequence").cloned();
|
||||
assert_eq!(
|
||||
tga,
|
||||
Some(json!({"id": "TargaSequence", "label": "Targa Sequence", "extension": "tga", "sequence": true, "alpha": true, "audio": false, "available": true}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_toast_names_the_first_reason() {
|
||||
struct Failing;
|
||||
@@ -186,7 +205,7 @@ fn without_exporter() {
|
||||
s.execute("renderQueue.add", json!({})).unwrap();
|
||||
assert!(!s.is_enabled("renderQueue.render"));
|
||||
let f = s.execute("renderQueue.formats", json!({})).unwrap();
|
||||
assert_eq!(f["formats"].as_array().unwrap().len(), 13);
|
||||
assert_eq!(f["formats"].as_array().unwrap().len(), 14);
|
||||
assert_eq!(f["formats"][0]["available"], false);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ pub trait ConfigStore: Send + Sync {
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
/// Write entry `name`, replacing any earlier text.
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
|
||||
/// Remove entry `name`; a missing entry is no error.
|
||||
fn remove(&self, name: &str) -> std::io::Result<()>;
|
||||
/// Names of the entries directly in `dir` (a `/`-separated name prefix such as `Scripts`),
|
||||
/// sorted. Stores that can't list return nothing.
|
||||
@@ -32,6 +34,11 @@ pub trait ConfigStore: Send + Sync {
|
||||
let _ = dir;
|
||||
vec![]
|
||||
}
|
||||
/// Names of the folders directly in `dir`, sorted, like [`ConfigStore::list`].
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let _ = dir;
|
||||
vec![]
|
||||
}
|
||||
/// The folder next to the configuration (default Auto-Save folder for untitled projects),
|
||||
/// in the namespace of [`ConfigStore::files`]; `None` when there is none.
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
@@ -107,6 +114,15 @@ impl ConfigStore for MemoryConfig {
|
||||
let Ok(m) = self.files.lock() else { return vec![] };
|
||||
m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect()
|
||||
}
|
||||
/// The first name part of the keys under `dir/`.
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let pre = format!("{}/", dir.trim_end_matches('/'));
|
||||
let Ok(m) = self.files.lock() else { return vec![] };
|
||||
let mut v: Vec<String> = m.keys().filter_map(|k| k.strip_prefix(&pre)?.split_once('/')).map(|(d, _)| d.to_string()).filter(|d| !d.is_empty()).collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Files in one directory, written atomically.
|
||||
@@ -153,6 +169,13 @@ impl ConfigStore for DirConfig {
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
/// The folders in `dir`, following links like [`ConfigStore::list`] does for files.
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(self.dir.join(dir)) else { return vec![] };
|
||||
let mut v: Vec<String> = rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
fn remove(&self, name: &str) -> std::io::Result<()> {
|
||||
match std::fs::remove_file(self.dir.join(name)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
|
||||
|
||||
@@ -17,6 +17,7 @@ mod history;
|
||||
mod import_project;
|
||||
mod key_metadata_order;
|
||||
mod key_time;
|
||||
mod keyframe_text;
|
||||
mod keylight;
|
||||
mod limits;
|
||||
mod lottie;
|
||||
|
||||
@@ -136,3 +136,62 @@ fn footage_changes_change_the_salt() {
|
||||
assert_ne!(k3, k2);
|
||||
assert_ne!(set_proxy(&mut s, false), k3);
|
||||
}
|
||||
|
||||
/// Marker edits keep the disk key (the sound of a playing preview must not wait for frames to
|
||||
/// render again, #1083); an expression that reads markers makes them count.
|
||||
#[test]
|
||||
fn markers_keep_the_content_key_unless_an_expression_reads_them() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap();
|
||||
s.execute("markers.set", json!({"layer": l, "new": true, "time": 0.25})).unwrap();
|
||||
assert_eq!(key(&s), k0, "markers");
|
||||
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap();
|
||||
let k1 = key(&s);
|
||||
assert_ne!(k1, k0);
|
||||
s.execute("comp.addMarker", json!({"time": 1.0})).unwrap();
|
||||
assert_ne!(key(&s), k1, "a marker read by an expression");
|
||||
}
|
||||
|
||||
/// A comp marker alone (no layer marker beside it) keeps the key too.
|
||||
#[test]
|
||||
fn a_lone_comp_marker_keeps_the_content_key() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap();
|
||||
assert_eq!(key(&s), k0);
|
||||
}
|
||||
|
||||
/// A protected marker with a duration retimes a stretched precomp layer (Responsive Design), so
|
||||
/// changing it changes the key of the comp that nests it; a plain marker beside it does not.
|
||||
#[test]
|
||||
fn protected_markers_with_a_duration_change_the_content_key() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 10})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||||
s.execute("layer.timeStretch", json!({"percent": 200})).unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, outer);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"comp": inner.0, "time": 2.0, "comment": "beat"})).unwrap();
|
||||
assert_eq!(key(&s), k0, "a plain marker");
|
||||
s.execute("comp.addMarker", json!({"comp": inner.0, "time": 1.0})).unwrap();
|
||||
let index = s.project.comp(inner).unwrap().markers.iter().position(|m| m.time.seconds() == 1.0).unwrap();
|
||||
s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 1.0, "protected": true})).unwrap();
|
||||
let k1 = key(&s);
|
||||
assert_ne!(k1, k0, "a protected marker with a duration");
|
||||
s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 2.0})).unwrap();
|
||||
assert_ne!(key(&s), k1, "its duration");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Copied keyframes as After Effects keyframe text for other applications (#1908).
|
||||
|
||||
use effectcraft_project::{LayerId, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{KeyRef, Session};
|
||||
|
||||
/// A 1920×1080 23.976 fps comp with a 200×100 solid.
|
||||
fn session() -> (Session, LayerId) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 1920, "height": 1080, "frameRate": 23.976, "duration": 10})).unwrap();
|
||||
let id = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, LayerId(id))
|
||||
}
|
||||
|
||||
fn frame(s: &Session, n: i64) -> Tick {
|
||||
Tick(s.active_comp().unwrap().frame_rate.frame_duration().0 * n)
|
||||
}
|
||||
|
||||
fn uid(s: &Session, lid: LayerId, path: &str) -> Uid {
|
||||
s.active_comp().unwrap().layer(lid).unwrap().props.prop(path).unwrap().uid
|
||||
}
|
||||
|
||||
fn edit_layer(s: &mut Session, lid: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
f(std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(lid).unwrap());
|
||||
}
|
||||
|
||||
fn add_key(s: &mut Session, lid: LayerId, path: &str, time: Tick, value: serde_json::Value) {
|
||||
s.execute("prop.addKey", json!({"layer": lid.0, "path": path, "time": time.seconds(), "value": value})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_keys_are_after_effects_text_in_comp_frames() {
|
||||
let (mut s, l) = session();
|
||||
// The layer starts 10 frames in: its keys at layer frames 2, 5 and 9 are comp frames 12, 15 and 19.
|
||||
let start = frame(&s, 10);
|
||||
edit_layer(&mut s, l, |l| l.start_time = start);
|
||||
for (n, v) in [(2, [960.0, 540.0]), (5, [1000.5, 520.25]), (9, [1100.0, 500.0])] {
|
||||
let t = frame(&s, n);
|
||||
add_key(&mut s, l, "transform/position", t, json!(v));
|
||||
}
|
||||
let t = frame(&s, 5);
|
||||
add_key(&mut s, l, "transform/rotation", t, json!(-45));
|
||||
let (position, rotation) = (uid(&s, l, "transform/position"), uid(&s, l, "transform/rotation"));
|
||||
// A subset: two of the three Position keys and the Rotation key.
|
||||
s.state.selected_keys = [(position, 2), (position, 9), (rotation, 5)].map(|(prop, n)| KeyRef { layer: l, prop, time: frame(&s, n) }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
s.state.keyframe_text.as_deref(),
|
||||
Some(
|
||||
"Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\
|
||||
Layer\n\
|
||||
Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t12\t960\t540\t0\t\n\t19\t1100\t500\t0\t\n\n\
|
||||
Transform\tRotation\n\tFrame\tdegrees\t\n\t15\t-45\t\n\n\
|
||||
\n\
|
||||
End of Keyframe Data\n"
|
||||
)
|
||||
);
|
||||
// The internal clipboard still has the keys themselves, relative to the earliest.
|
||||
let times: Vec<Vec<Tick>> = s.state.key_clipboard.iter().map(|c| c.keys.iter().map(|k| k.time).collect()).collect();
|
||||
assert_eq!(times, [vec![Tick::ZERO, frame(&s, 7)], vec![frame(&s, 3)]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stretched_layer_writes_the_frames_its_keys_show_at() {
|
||||
let (mut s, l) = session();
|
||||
edit_layer(&mut s, l, |l| l.stretch = 200.0);
|
||||
let t = frame(&s, 6);
|
||||
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "transform/opacity", t, json!(100));
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_static_values_have_no_frame() {
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t\t100\t200\t0\t\n\n"), "{text}");
|
||||
assert_eq!(s.state.value_clipboard.len(), 1, "the internal clipboard keeps the value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_transform_group_lists_what_the_layer_shows() {
|
||||
let (mut s, l) = session();
|
||||
let t = frame(&s, 24);
|
||||
add_key(&mut s, l, "transform/scale", Tick::ZERO, json!([50, 50, 100]));
|
||||
add_key(&mut s, l, "transform/scale", t, json!([150, 150, 100]));
|
||||
let transform = s.active_comp().unwrap().layer(l).unwrap().props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
|
||||
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation");
|
||||
assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t50\t100\t\n\t24\t150\t150\t100\t\n"), "{text}");
|
||||
// 3D: Orientation and X / Y Rotation. Z Rotation stays Rotation, and Orientation's three
|
||||
// values sit under one label, as After Effects writes them.
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
|
||||
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Rotation", "Opacity"]);
|
||||
assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\t\n\t\t0\t0\t0\t\n"), "{text}");
|
||||
}
|
||||
|
||||
fn copy_transform(s: &mut Session, lid: LayerId) -> String {
|
||||
let transform = s.active_comp().unwrap().layer(lid).unwrap().props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": lid.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.state.keyframe_text.clone().unwrap()
|
||||
}
|
||||
|
||||
fn names(text: &str) -> Vec<&str> {
|
||||
text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_the_layer_does_not_use_are_left_out() {
|
||||
// Separate Dimensions: the old Position no longer moves the layer.
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
|
||||
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionX", "value": 321})).unwrap();
|
||||
let text = copy_transform(&mut s, l);
|
||||
assert!(!names(&text).contains(&"Position"), "{text}");
|
||||
// A one-node camera has no Point of Interest; a two-node one does.
|
||||
let cam = LayerId(s.execute("layer.newCamera", json!({"type": "oneNode"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, cam);
|
||||
assert!(!names(&text).contains(&"Point of Interest"), "{text}");
|
||||
s.execute("layer.cameraSettings", json!({"layer": cam.0, "type": "twoNode"})).unwrap();
|
||||
let text = copy_transform(&mut s, cam);
|
||||
assert!(names(&text).contains(&"Point of Interest"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separated_position_writes_x_y_and_z_position() {
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "transform/positionX", Tick::ZERO, json!(100));
|
||||
add_key(&mut s, l, "transform/positionX", t, json!(321));
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionY", "value": 200})).unwrap();
|
||||
let text = copy_transform(&mut s, l);
|
||||
// After Effects labels their single column with no unit.
|
||||
assert!(text.contains("Transform\tX Position\n\tFrame\t\t\n\t0\t100\t\n\t12\t321\t\n\nTransform\tY Position\n\tFrame\t\t\n\t\t200\t\n\n"), "{text}");
|
||||
assert!(!names(&text).contains(&"Z Position"), "a 2D layer has no Z Position: {text}");
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
let text = copy_transform(&mut s, l);
|
||||
assert!(text.contains("Transform\tZ Position\n\tFrame\t\t\n\t\t0\t\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_remap_writes_source_seconds_at_comp_frames() {
|
||||
let (mut s, l) = session();
|
||||
let r = s.execute("layer.precompose", json!({"layers": [l.0], "name": "Inner"})).unwrap();
|
||||
let l = LayerId(r["layer"].as_u64().unwrap());
|
||||
s.execute("layer.select", json!({"layers": [l.0]})).unwrap();
|
||||
s.execute("layer.enableTimeRemap", json!({})).unwrap();
|
||||
let start = frame(&s, 10);
|
||||
edit_layer(&mut s, l, |l| l.start_time = start);
|
||||
let t = frame(&s, 6);
|
||||
add_key(&mut s, l, "timeRemap", t, json!(2.5));
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "timeRemap"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
// As After Effects writes it: the key at layer frame 6 of a layer starting at frame 10 is at 16.
|
||||
assert!(text.contains("Layer\nTime Remap\n\tFrame\tseconds\t\n\t10\t0\t\n\t16\t2.5\t\n"), "{text}");
|
||||
}
|
||||
|
||||
/// Selects the layer's properties at `paths` and copies them.
|
||||
fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String {
|
||||
for (i, path) in paths.iter().enumerate() {
|
||||
s.execute("prop.select", json!({"layer": lid.0, "path": path, "add": i > 0, "selectKeys": false})).unwrap();
|
||||
}
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.state.keyframe_text.clone().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same effects and values (2019 has no Dropdown
|
||||
// Menu Control: that block follows its other popups, written 1-based with no unit).
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Fill", "Checkbox Control", "Dropdown Menu Control", "Slider Control", "Point Control"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "effects/#1/color", Tick::ZERO, json!([1, 0.5, 0.25, 1]));
|
||||
add_key(&mut s, l, "effects/#1/color", t, json!([0, 0, 1, 1]));
|
||||
add_key(&mut s, l, "effects/#2/checkbox", Tick::ZERO, json!(true));
|
||||
add_key(&mut s, l, "effects/#2/checkbox", t, json!(false));
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#3/menu", "value": 1})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#4/slider", "value": 20.5})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#5/point", "value": [10, 20]})).unwrap();
|
||||
// After Effects writes a renamed effect under the effect's own name.
|
||||
let slider = s.active_comp().unwrap().layer(l).unwrap().props.group("effects/#4").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": slider, "name": "My Slider"})).unwrap();
|
||||
let text = copy_props(&mut s, l, &["effects/#1/color", "effects/#2/checkbox", "effects/#3/menu", "effects/#4/slider", "effects/#5/point"]);
|
||||
assert!(
|
||||
text.contains(
|
||||
"Layer\n\
|
||||
Effects\tFill #1\tColor #4\n\tFrame\talpha \tred \tgreen \tblue \t\n\t0\t255\t255\t127.5\t63.75\t\n\t12\t255\t0\t0\t255\t\n\n\
|
||||
Effects\tCheckbox Control #2\tCheckbox #2\n\tFrame\t\t\n\t0\t1\t\n\t12\t0\t\n\n\
|
||||
Effects\tDropdown Menu Control #3\tMenu #2\n\tFrame\t\t\n\t\t2\t\n\n\
|
||||
Effects\tSlider Control #4\tSlider #2\n\tFrame\t\t\n\t\t20.5\t\n\n\
|
||||
Effects\tPoint Control #5\tPoint #2\n\tFrame\tX pixels\tY pixels\t\n\t\t10\t20\t\n\n"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_properties_have_after_effects_indices() {
|
||||
// After Effects 2019 numbered Fractal Noise's Rotation (in the Transform group) #9,
|
||||
// Complexity (after it) #17, Evolution #25, and Opacity (after Evolution Options) #31: a
|
||||
// group takes an index where it opens and closes, and its properties are written without
|
||||
// it. Set Matte's Use For Matte is #3: the layer parameter before it has no extra index for
|
||||
// its source choice.
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Fractal Noise", "Set Matte"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&[
|
||||
"effects/#1/transform/rotation",
|
||||
"effects/#1/complexity",
|
||||
"effects/#1/evolution",
|
||||
"effects/#1/opacity",
|
||||
"effects/#1/blendingMode",
|
||||
"effects/#2/takeMatteFrom",
|
||||
],
|
||||
);
|
||||
for header in [
|
||||
"Effects\tFractal Noise #1\tRotation #9\n",
|
||||
"Effects\tFractal Noise #1\tComplexity #17\n",
|
||||
"Effects\tFractal Noise #1\tEvolution #25\n",
|
||||
"Effects\tFractal Noise #1\tOpacity #31\n",
|
||||
"Effects\tFractal Noise #1\tBlending Mode #32\n",
|
||||
"Effects\tSet Matte #2\tUse For Matte #3\n",
|
||||
] {
|
||||
assert!(text.contains(header), "{header}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_parameters_after_effects_numbers_or_labels_otherwise_follow_it() {
|
||||
// After Effects 2019: Radial Blur counts a hidden parameter before Amount, which has no
|
||||
// unit; 3D Channel Extract lists Anti-alias before Clamp Output; Selective Color's per-color
|
||||
// sliders start at #10 and are percent.
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Radial Blur", "3D Channel Extract", "Selective Color"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&["effects/#1/amount", "effects/#1/center", "effects/#2/antialias", "effects/#2/invertDepthMap", "effects/#3/details/reds/redsCyan"],
|
||||
);
|
||||
for block in [
|
||||
"Effects\tRadial Blur #1\tAmount #3\n\tFrame\t\t\n",
|
||||
"Effects\tRadial Blur #1\tCenter #4\n\tFrame\tX pixels\tY pixels\t\n",
|
||||
"Effects\t3D Channel Extract #2\tAnti-alias #5\n\tFrame\t\t\n",
|
||||
"Effects\t3D Channel Extract #2\tInvert Depth Map #7\n\tFrame\t\t\n",
|
||||
"Effects\tSelective Color #3\tReds Cyan #10\n\tFrame\tpercent\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same masks and values: masks go by place, not
|
||||
// name, and Mask Path is left out.
|
||||
let (mut s, l) = session();
|
||||
for _ in 0..2 {
|
||||
s.execute("mask.new", json!({"layer": l.0, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap();
|
||||
}
|
||||
let t = frame(&s, 12);
|
||||
for (prop, a, b) in [("feather", json!([5, 5]), json!([10, 20])), ("opacity", json!(50), json!(75)), ("expansion", json!(3), json!(-4))] {
|
||||
add_key(&mut s, l, &format!("masks/#1/{prop}"), Tick::ZERO, a);
|
||||
add_key(&mut s, l, &format!("masks/#1/{prop}"), t, b);
|
||||
}
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "masks/#2/feather", "value": [7, 8]})).unwrap();
|
||||
let second = s.active_comp().unwrap().layer(l).unwrap().props.group("masks/#2").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Renamed Mask"})).unwrap();
|
||||
let text = copy_props(&mut s, l, &["masks/#1/path", "masks/#1/feather", "masks/#1/opacity", "masks/#1/expansion", "masks/#2/feather"]);
|
||||
assert!(
|
||||
text.contains(
|
||||
"Layer\n\
|
||||
Masks\tMask #1\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t0\t5\t5\t\n\t12\t10\t20\t\n\n\
|
||||
Masks\tMask #1\tMask Opacity\n\tFrame\tpercent\t\n\t0\t50\t\n\t12\t75\t\n\n\
|
||||
Masks\tMask #1\tMask Expansion\n\tFrame\tpixels\t\n\t0\t3\t\n\t12\t-4\t\n\n\
|
||||
Masks\tMask #2\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t\t7\t8\t\n\n\
|
||||
\nEnd of Keyframe Data\n"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same text properties and values.
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 23.976})).unwrap();
|
||||
let l = LayerId(s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.addTextAnimator", json!({"properties": ["position", "fillColor"]})).unwrap();
|
||||
s.execute("layer.addTextAnimator", json!({"properties": ["opacity"]})).unwrap();
|
||||
let second = s.active_comp().unwrap().layer(l).unwrap().props.group("text/animators/#2").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Fade"})).unwrap();
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "text/sourceText", Tick::ZERO, json!("Hello"));
|
||||
add_key(&mut s, l, "text/pathOptions/firstMargin", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "text/pathOptions/firstMargin", t, json!(50));
|
||||
add_key(&mut s, l, "text/animators/#1/selectors/#1/start", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "text/animators/#1/selectors/#1/start", t, json!(100));
|
||||
add_key(&mut s, l, "text/animators/#1/properties/position", Tick::ZERO, json!([0, 0, 0]));
|
||||
add_key(&mut s, l, "text/animators/#1/properties/position", t, json!([10, -20, 0]));
|
||||
for (path, v) in [
|
||||
("text/animators/#1/selectors/#1/end", json!(80)),
|
||||
("text/animators/#1/selectors/#1/offset", json!(-10)),
|
||||
("text/animators/#1/properties/fillColor", json!([1, 0, 0, 1])),
|
||||
("text/moreOptions/groupingAlignment", json!([10, -20])),
|
||||
] {
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": path, "value": v})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&[
|
||||
"text/sourceText",
|
||||
"text/pathOptions/firstMargin",
|
||||
"text/animators/#1/selectors/#1/start",
|
||||
"text/animators/#1/selectors/#1/end",
|
||||
"text/animators/#1/selectors/#1/offset",
|
||||
"text/animators/#1/selectors/#1/advanced/amount",
|
||||
"text/animators/#1/properties/position",
|
||||
"text/animators/#1/properties/fillColor",
|
||||
"text/animators/#2/properties/opacity",
|
||||
"text/moreOptions/groupingAlignment",
|
||||
],
|
||||
);
|
||||
for block in [
|
||||
"Text\tPath Options\tFirst Margin\n\tFrame\t\t\n\t0\t0\t\n\t12\t50\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tStart #1\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tEnd #2\n\tFrame\tpercent\t\n\t\t80\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tOffset #3\n\tFrame\tpercent\t\n\t\t-10\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tProperties\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t0\t0\t0\t\n\t12\t10\t-20\t0\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tProperties\tFill Color\n\tFrame\talpha \tred \tgreen \tblue \t\n\t\t255\t255\t0\t0\t\n\n",
|
||||
"Text\tAnimators\tAnimator #2\tProperties\tOpacity\n\tFrame\tpercent\t\n\t\t100\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tAdvanced\tAmount\n\tFrame\tpercent\t\n\t\t100\t\n\n",
|
||||
"Text\tMore Options\tGrouping Alignment\n\tFrame\tX percent\tY percent\t\n\t\t10\t-20\t\n\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}\nin\n{text}");
|
||||
}
|
||||
assert!(!text.contains("Source Text"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_2d_layer_writes_the_z_it_shows() {
|
||||
// Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it.
|
||||
let (mut s, l) = session();
|
||||
let src = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
edit_layer(&mut s, src, |l| l.switches.three_d = true);
|
||||
add_key(&mut s, src, "transform/position", Tick::ZERO, json!([100, 200, 300]));
|
||||
add_key(&mut s, src, "transform/scale", Tick::ZERO, json!([50, 60, 70]));
|
||||
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: src, prop: uid(&s, src, p), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("keys.paste", json!({"layers": [l.0], "time": 0})).unwrap();
|
||||
let pasted = s.active_comp().unwrap().layer(l).unwrap().props.prop("transform/position").unwrap().keys[0].value.components();
|
||||
assert_eq!(pasted, [100.0, 200.0, 300.0], "the 2D layer keeps the pasted Z");
|
||||
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: l, prop: uid(&s, l, p), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t100\t200\t0\t\n"), "{text}");
|
||||
assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t60\t100\t\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_has_the_source_pixel_aspect() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 24, "pixelAspect": 1.2})).unwrap();
|
||||
let solid = LayerId(s.execute("layer.newSolid", json!({"width": 200, "height": 100, "pixelAspect": 1.5})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, solid);
|
||||
assert!(text.contains("\tSource Pixel Aspect Ratio\t1.5\n\tComp Pixel Aspect Ratio\t1.2\n"), "{text}");
|
||||
// A text layer: 1 × 1, as After Effects 2019 wrote it, with the comp's pixel aspect.
|
||||
let t = LayerId(s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, t);
|
||||
assert!(text.contains("\tSource Width\t1\n\tSource Height\t1\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}");
|
||||
// A shape layer has no source of its own: the comp's size.
|
||||
let shape = LayerId(s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, shape);
|
||||
assert!(text.contains("\tSource Width\t1920\n\tSource Height\t1080\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_two_layers_have_no_text() {
|
||||
let (mut s, a) = session();
|
||||
let b = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
for l in [a, b] {
|
||||
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(50));
|
||||
}
|
||||
s.state.selected_keys = [a, b].map(|l| KeyRef { layer: l, prop: uid(&s, l, "transform/opacity"), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert_eq!(s.state.keyframe_text, None, "After Effects copies one layer's keys as text");
|
||||
assert_eq!(s.state.key_clipboard.len(), 2, "EffectCraft's own clipboard has both");
|
||||
// A copied layer leaves no text behind.
|
||||
s.state.selected_keys = vec![KeyRef { layer: a, prop: uid(&s, a, "transform/opacity"), time: Tick::ZERO }];
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert!(s.state.keyframe_text.is_some());
|
||||
s.state.selected_keys.clear();
|
||||
s.execute("edit.copy", json!({"layers": [a.0]})).unwrap();
|
||||
assert_eq!(s.state.keyframe_text, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_contents_are_written_as_after_effects_does() {
|
||||
// Headers and labels After Effects 2019 wrote for the same shape items: items go by kind and
|
||||
// place in their group, and a Size has no unit (`X `).
|
||||
let (mut s, _) = session();
|
||||
let sh = LayerId(s.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap());
|
||||
for (kind, group) in [("group", None), ("rectangle", Some("contents/#1")), ("fill", Some("contents/#1")), ("star", None), ("repeater", None)] {
|
||||
let mut p = json!({"layer": sh.0, "kind": kind});
|
||||
if let Some(g) = group {
|
||||
p["group"] = json!(g);
|
||||
}
|
||||
s.execute("layer.addShapeItem", p).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
sh,
|
||||
&[
|
||||
"contents/#1/contents/#1/size",
|
||||
"contents/#1/contents/#2/color",
|
||||
"contents/#1/transform/position",
|
||||
"contents/#2/points",
|
||||
"contents/#3/transform/startOpacity",
|
||||
],
|
||||
);
|
||||
for block in [
|
||||
"Contents\tGroup #1\tContents\tRectangle Path #1\tSize\n\tFrame\tX \tY \t\n",
|
||||
"Contents\tGroup #1\tContents\tFill #2\tColor\n\tFrame\talpha \tred \tgreen \tblue \t\n",
|
||||
"Contents\tGroup #1\tTransform\tPosition\n\tFrame\tX pixels\tY pixels\t\n",
|
||||
"Contents\tPolystar Path #2\tPoints\n\tFrame\t\t\n",
|
||||
"Contents\tRepeater #3\tTransform\tStart Opacity\n\tFrame\tpercent\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_light_and_material_options_are_written_as_after_effects_does() {
|
||||
// Blocks After Effects 2019 wrote for these options, defaults included: Zoom and Shadow
|
||||
// Diffusion in pixels, Highlight Threshold on 0–1, and the material's shadow choices from 0.
|
||||
let (mut s, l) = session();
|
||||
let cam = LayerId(s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_props(&mut s, cam, &["cameraOptions/zoom", "cameraOptions/highlightThreshold"]);
|
||||
assert!(text.contains("Camera Options\tZoom\n\tFrame\tpixels\t\n"), "{text}");
|
||||
assert!(text.contains("Camera Options\tHighlight Threshold\n\tFrame\t\t\n\t\t1\t\n"), "{text}");
|
||||
let light = LayerId(s.execute("layer.newLight", json!({"type": "spot"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_props(&mut s, light, &["lightOptions/intensity", "lightOptions/shadowDiffusion"]);
|
||||
assert!(text.contains("Light Options\tIntensity\n\tFrame\tpercent\t\n\t\t100\t\n"), "{text}");
|
||||
assert!(text.contains("Light Options\tShadow Diffusion\n\tFrame\tpixels\t\n\t\t0\t\n"), "{text}");
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
let text = copy_props(&mut s, l, &["materialOptions/castsShadows", "materialOptions/acceptsShadows", "materialOptions/diffuse"]);
|
||||
for block in [
|
||||
"Material Options\tCasts Shadows\n\tFrame\t\t\n\t\t0\t\n",
|
||||
"Material Options\tAccepts Shadows\n\tFrame\t\t\n\t\t1\t\n",
|
||||
"Material Options\tDiffuse\n\tFrame\tpercent\t\n\t\t50\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,33 @@ fn alpha_at(s: &Session, f: u32) -> Vec<f32> {
|
||||
img.data.iter().map(|p| p[3]).collect()
|
||||
}
|
||||
|
||||
/// Green, then Alt (red) over part of the object, then green there again: the last stroke wins,
|
||||
/// in the render and through Undo / Redo (#1646).
|
||||
#[test]
|
||||
fn roto_brush_strokes_correct_earlier_ones() {
|
||||
let (mut s, clip, _) = painted(center, "disk-1646.mov");
|
||||
let c = center(0);
|
||||
let spot = [c[0], c[1] + 25.0];
|
||||
let i = spot[1] as usize * W as usize + spot[0] as usize;
|
||||
let paint = |s: &mut Session, kind: &str| {
|
||||
s.execute_checked(
|
||||
"roto.stroke",
|
||||
json!({"layer": clip.0, "kind": kind, "points": line([spot[0] - 6.0, spot[1]], [spot[0] + 6.0, spot[1]]), "radius": 7}),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
assert!(alpha_at(&s, 0)[i] > 0.5, "the spot starts inside the matte");
|
||||
paint(&mut s, "bg");
|
||||
assert!(alpha_at(&s, 0)[i] < 0.5, "Alt removes it");
|
||||
paint(&mut s, "fg");
|
||||
assert!(alpha_at(&s, 0)[i] > 0.5, "green over the Alt stroke restores it");
|
||||
assert!(iou_at(&mut s, clip, 0, c) > 0.95);
|
||||
assert!(s.undo());
|
||||
assert!(alpha_at(&s, 0)[i] < 0.5, "undo brings the removal back");
|
||||
assert!(s.redo());
|
||||
assert!(alpha_at(&s, 0)[i] > 0.5, "redo restores it again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roto_brush_segments_and_propagates() {
|
||||
let (mut s, clip, uid) = painted(center, "disk-a.mov");
|
||||
|
||||
@@ -519,9 +519,10 @@ fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() {
|
||||
};
|
||||
let radius = |v: [f64; 2]| v[0].hypot(v[1]);
|
||||
|
||||
// A 120 x 80 rectangle is its four corners, joined by straight edges.
|
||||
// A 120 x 80 rectangle is its four corners, joined by straight edges, from the top-right
|
||||
// corner clockwise as After Effects draws it.
|
||||
let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]}));
|
||||
assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]);
|
||||
assert_eq!(rect.vertices, vec![[60.0, -40.0], [60.0, 40.0], [-60.0, 40.0], [-60.0, -40.0]]);
|
||||
assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}");
|
||||
// Undo brings the parametric shape back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
@@ -765,6 +765,59 @@ fn pick_whip_generates_ae_reference_expressions() {
|
||||
assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err());
|
||||
}
|
||||
|
||||
/// #1072: an Alt pick whip (`byIndex`) selects an effect's properties by their index,
|
||||
/// `effect("Color Control")(1)`, its place among its group's children (nested groups too). The
|
||||
/// effect keeps its name; other properties, dimensions, insertion into an expression and match
|
||||
/// names (Compact English off) work as before. (`effectcraft-host`'s `pick_whip` tests check the
|
||||
/// references read the right values.)
|
||||
#[test]
|
||||
fn pick_whip_by_index_selects_effect_properties_by_their_index() {
|
||||
let (mut s, l) = setup();
|
||||
let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for fx in ["Color Control", "Fractal Noise", "Point Control"] {
|
||||
s.execute("effect.apply", json!({"layer": o, "effect": fx})).unwrap();
|
||||
}
|
||||
// Fractal Noise ▸ Transform ▸ Scale, by its places in the property tree.
|
||||
let fx = s.active_comp().unwrap().layer(effectcraft_project::LayerId(o)).unwrap().props.sub("effects").unwrap().groups().nth(1).unwrap().clone();
|
||||
let group = fx.children.iter().position(|c| c.match_id() == "transform").unwrap() + 1;
|
||||
let scale = fx.sub("transform").unwrap().children.iter().position(|c| c.match_id() == "scale").unwrap() + 1;
|
||||
let whip = |s: &mut Session, path: &str, target: Value, extra: Value| {
|
||||
let mut p = json!({"layer": l, "path": path, "target": target, "byIndex": true});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
s.execute("prop.pickWhip", p).unwrap()
|
||||
};
|
||||
let other = "thisComp.layer(\"Other\")";
|
||||
let undo = s.history.undo.len();
|
||||
assert_eq!(
|
||||
whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#1/color"}), json!({})),
|
||||
json!(format!("{other}.effect(\"Color Control\")(1)[0]")),
|
||||
"a colour into one value: its first"
|
||||
);
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
let nested = format!("{other}.effect(\"Fractal Noise\")({group})({scale})");
|
||||
assert_eq!(whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({})), json!(nested));
|
||||
assert_eq!(
|
||||
whip(&mut s, "transform/opacity", json!({"layer": o, "path": "effects/#3/point", "dimension": 1}), json!({})),
|
||||
json!(format!("{other}.effect(\"Point Control\")(1)[1]"))
|
||||
);
|
||||
assert_eq!(
|
||||
whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({"expression": "1 + ", "range": [4, 4]})),
|
||||
json!(format!("1 + {nested}")),
|
||||
"inserted at the cursor"
|
||||
);
|
||||
assert_eq!(
|
||||
whip(&mut s, "transform/opacity", json!({"layer": o, "path": "transform/opacity"}), json!({})),
|
||||
json!(format!("{other}.transform.opacity")),
|
||||
"only an effect's properties go by index"
|
||||
);
|
||||
// Match names (Compact English off): the effect by its match name, its properties by index.
|
||||
s.execute("prefs.set", json!({"key": "general.expressionPickWhipCompact", "value": false})).unwrap();
|
||||
let e = whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({}));
|
||||
assert!(e.as_str().unwrap().ends_with(&format!("({group})({scale})")), "{e}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/rotation").expr.unwrap().text, format!("1 + {nested}"), "one undo per pick");
|
||||
}
|
||||
|
||||
/// #407: Reset without a value gives a property its own default (a Transform property's, an
|
||||
/// effect parameter's) besides removing its keyframes and expression.
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "effectcraft-export"
|
||||
description = "Render Queue jobs and output modules: H.264 MP4 (+AAC), ProRes and uncompressed MOV (+PCM) via FilmCraft, HEVC / AV1 MP4 (+AAC) and WebM (VP9 or AV1 + Opus) via EffectCraft encoders, PNG/JPEG/TIFF/EXR sequences, animated GIF, WAV/AIFF"
|
||||
description = "Render Queue jobs and output modules: H.264 MP4 (+AAC), ProRes and uncompressed MOV (+PCM) via FilmCraft, HEVC / AV1 MP4 (+AAC) and WebM (VP9 or AV1 + Opus) via EffectCraft encoders, PNG/JPEG/TIFF/EXR/Targa sequences, animated GIF, WAV/AIFF"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -22,7 +22,7 @@ effectcraft-opusenc = { workspace = true }
|
||||
effectcraft-vp9enc = { workspace = true }
|
||||
effectcraft-hevcenc = { workspace = true }
|
||||
effectcraft-av1enc = { workspace = true }
|
||||
image = { workspace = true, features = ["png", "jpeg", "tiff", "exr"] }
|
||||
image = { workspace = true, features = ["png", "jpeg", "tiff", "exr", "tga"] }
|
||||
gif = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::io::Write;
|
||||
use effectcraft_project::Comp;
|
||||
use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use filmcraft_isobmff::{Brand, ColorInfo, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::{Cx, ExportError, Report, Result, State, batch_size, wants_audio};
|
||||
@@ -19,6 +19,17 @@ fn mux_err(e: impl std::fmt::Display) -> ExportError {
|
||||
ExportError::Io(e.to_string())
|
||||
}
|
||||
|
||||
/// Tag a video [`SampleEntry`] with the BT.709 limited-range `colr` (nclx) box every video
|
||||
/// encoder here outputs (`rgba_to_yuv420`, `rgba_to_yuv_10` and `rgba_to_yuv420_bits` all use
|
||||
/// the 0.2126/0.7152/0.0722 luma and a 16–235 luma range). Without it a re-import falls back to
|
||||
/// the container's resolution-based `default_matrix` — BT.601 below 1024×577 — and shifts the
|
||||
/// colours (pure red came back as [232,0,1]).
|
||||
pub(crate) fn tag_bt709_limited(entry: &mut SampleEntry) {
|
||||
if let Some(v) = entry.video.as_mut() {
|
||||
v.color = Some(ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false });
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Packet {
|
||||
pub(crate) data: Vec<u8>,
|
||||
pub(crate) key: bool,
|
||||
@@ -110,7 +121,9 @@ impl VideoEncoder for H264 {
|
||||
let (sps, pps) = self.enc.sps_pps();
|
||||
filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4)
|
||||
});
|
||||
SampleEntry::avc(cfg, self.w as u16, self.h as u16)
|
||||
let mut entry = SampleEntry::avc(cfg, self.w as u16, self.h as u16);
|
||||
tag_bt709_limited(&mut entry);
|
||||
entry
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>> {
|
||||
let (mut y, mut u, mut v) = (std::mem::take(&mut self.y), std::mem::take(&mut self.u), std::mem::take(&mut self.v));
|
||||
|
||||
@@ -92,7 +92,9 @@ impl Hevc {
|
||||
impl VideoEncoder for Hevc {
|
||||
fn sample_entry(&self) -> SampleEntry {
|
||||
let cfg = HevcConfig::parse(&self.enc.hvcc()).unwrap_or_default();
|
||||
SampleEntry::hevc(cfg, self.w as u16, self.h as u16)
|
||||
let mut entry = SampleEntry::hevc(cfg, self.w as u16, self.h as u16);
|
||||
crate::encode::tag_bt709_limited(&mut entry);
|
||||
entry
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> {
|
||||
let (w, h) = (self.w as usize, self.h as usize);
|
||||
@@ -148,7 +150,9 @@ impl Av1 {
|
||||
impl VideoEncoder for Av1 {
|
||||
fn sample_entry(&self) -> SampleEntry {
|
||||
let cfg = Av1Config::parse(&self.enc.av1c()).unwrap_or_default();
|
||||
SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), self.w as u16, self.h as u16)
|
||||
let mut entry = SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), self.w as u16, self.h as u16);
|
||||
crate::encode::tag_bt709_limited(&mut entry);
|
||||
entry
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> {
|
||||
let (w, h) = (self.w as usize, self.h as usize);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! | Uncompressed | QuickTime MOV (`raw `) | 8-bit RGB (depth 24, rows padded to even bytes) or ARGB (depth 32) | RGB + Alpha (straight or premultiplied) | PCM 16/24-bit or 32-bit float, mono/stereo |
|
||||
//! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — |
|
||||
//! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — |
|
||||
//! | Targa sequence | one file per frame | uncompressed 8-bit: 24-bit RGB, 32-bit RGB + Alpha (8 alpha bits), 8-bit grey for Alpha; top-left origin | yes (straight or premultiplied) | — |
|
||||
//! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — |
|
||||
//! | WebM | WebM (Matroska) | VP9 profile 0 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 8-bit 4:2:0; or AV1 (`effectcraft-av1enc`) | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`: SILK / hybrid / CELT by bitrate and application), 48 kHz |
|
||||
//! | HEVC | MP4 (`hvc1`) | `effectcraft-hevcenc` Main / Main 10 4:2:0, I + P slices, bitrate or constant quality, level, key-frame interval | no | AAC-LC (FilmCraft) |
|
||||
@@ -335,19 +336,22 @@ fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &I
|
||||
.write_image(&rgb, w, h, ExtendedColorType::Rgb8)
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))
|
||||
}
|
||||
OutputFormat::PngSequence | OutputFormat::TiffSequence => {
|
||||
OutputFormat::PngSequence | OutputFormat::TiffSequence | OutputFormat::TargaSequence => {
|
||||
let px = cx.pixels(img, comp, channels, w, h);
|
||||
let (data, ct) = match channels {
|
||||
Channels::Rgba => (px, ExtendedColorType::Rgba8),
|
||||
Channels::Alpha => (px.as_chunks::<4>().0.iter().map(|p| p[0]).collect(), ExtendedColorType::L8),
|
||||
Channels::Rgb => (px.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8),
|
||||
};
|
||||
if fmt == OutputFormat::PngSequence {
|
||||
image::codecs::png::PngEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
} else {
|
||||
match fmt {
|
||||
OutputFormat::PngSequence => image::codecs::png::PngEncoder::new(file).write_image(&data, w, h, ct),
|
||||
// Uncompressed (the encoder's run-length compression is off), top-left origin, the
|
||||
// alpha bits in the image descriptor: 8 for RGB + Alpha, 0 otherwise.
|
||||
OutputFormat::TargaSequence => image::codecs::tga::TgaEncoder::new(file).disable_rle().write_image(&data, w, h, ct),
|
||||
// (the TIFF encoder needs Seek)
|
||||
image::codecs::tiff::TiffEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
_ => image::codecs::tiff::TiffEncoder::new(file).write_image(&data, w, h, ct),
|
||||
}
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))
|
||||
}
|
||||
f => Err(ExportError::Unsupported(format!("{} is not a still format", f.label()))),
|
||||
}
|
||||
|
||||
+176
-19
@@ -39,6 +39,57 @@ pub(crate) fn gpu_inflight(var: Option<&str>) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Caps CPU frames in flight (#1615): a whole number of frames, at least 1. Unset, CPU renders
|
||||
/// keep one frame per thread in flight as before. A heavy comp can need gigabytes of working
|
||||
/// memory per frame, so 16 threads at once can get the process OOM-killed; the headless CLI
|
||||
/// reads no `memory.*` preferences, so this is how a render there is held to fewer frames.
|
||||
pub(crate) const CPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_CPU_INFLIGHT";
|
||||
|
||||
/// CPU frames in flight from the `EFFECTCRAFT_CPU_INFLIGHT` value: `None` (no cap) when unset or
|
||||
/// blank, a positive whole number, or `None` with a warning when it is 0, negative, not a
|
||||
/// number or too large.
|
||||
pub(crate) fn cpu_inflight(var: Option<&str>) -> Option<usize> {
|
||||
let v = var?;
|
||||
if v.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
match v.trim().parse::<usize>() {
|
||||
Ok(n) if n >= 1 => Some(n),
|
||||
_ => {
|
||||
log::warn!(target: "effectcraft_export", "{CPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: CPU frames are not capped");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The frames-in-flight caps of a batch: `gpu` (#879) for the GPU's memory, `cpu` (#1615) for
|
||||
/// the process's.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct Inflight {
|
||||
pub gpu: Option<usize>,
|
||||
pub cpu: Option<usize>,
|
||||
}
|
||||
|
||||
impl Inflight {
|
||||
/// Frames rendered at once: the smaller cap while the GPU renders, and only the CPU cap once
|
||||
/// it has stopped for good (the CPU fallback renders in parallel, #1511). `None`: uncapped.
|
||||
pub fn renders(&self, gpu_live: bool) -> Option<usize> {
|
||||
let gpu = self.gpu.filter(|_| gpu_live);
|
||||
let n = match (gpu, self.cpu) {
|
||||
(Some(g), Some(c)) => Some(g.min(c)),
|
||||
(g, c) => g.or(c),
|
||||
};
|
||||
n.map(|n| n.max(1))
|
||||
}
|
||||
|
||||
/// Frames per window, rendered (`renders` at a time) and then encoded in parallel: with a CPU
|
||||
/// cap at most that many, so finished images held before encoding count against it too;
|
||||
/// with a GPU cap alone, one per thread (the images are in RAM, not VRAM).
|
||||
pub fn window(&self, renders: usize) -> usize {
|
||||
self.cpu.map_or(renders.max(rayon::current_num_threads()), |c| c.max(1))
|
||||
}
|
||||
}
|
||||
|
||||
/// `render` then `encode` over the frames `ks`, results in `ks` order.
|
||||
///
|
||||
/// Without `cap` every frame renders and encodes in one parallel batch (the CPU path, as
|
||||
@@ -55,38 +106,41 @@ pub(crate) fn map_frames<I: Send, T: Send>(
|
||||
render: impl Fn(u64) -> I + Sync + Send,
|
||||
encode: impl Fn(u64, I) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
map_frames_while(ks, cap, || true, render, encode)
|
||||
map_frames_while(ks, Inflight { gpu: cap, cpu: None }, || true, render, encode)
|
||||
}
|
||||
|
||||
/// [`map_frames`] whose `cap` holds only while `capped()`: once it turns false (the GPU stopped
|
||||
/// for good, so every frame falls back to the CPU), the remaining frames render in parallel
|
||||
/// like the uncapped CPU path instead of one chunk of `cap` at a time.
|
||||
/// [`map_frames`] with the caps of `caps`, the GPU cap holding only while `gpu_live()`: once it
|
||||
/// turns false (the GPU stopped for good, so every frame falls back to the CPU), the remaining
|
||||
/// frames render under the CPU cap alone, in parallel when there is none, instead of one chunk
|
||||
/// of the GPU cap at a time. A CPU cap also bounds each window ([`Inflight::window`]).
|
||||
pub(crate) fn map_frames_while<I: Send, T: Send>(
|
||||
ks: Vec<u64>,
|
||||
cap: Option<usize>,
|
||||
capped: impl Fn() -> bool,
|
||||
caps: Inflight,
|
||||
gpu_live: impl Fn() -> bool,
|
||||
render: impl Fn(u64) -> I + Sync + Send,
|
||||
encode: impl Fn(u64, I) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
use rayon::prelude::*;
|
||||
let Some(n) = cap.map(|n| n.max(1)).filter(|_| capped()) else {
|
||||
if caps.renders(gpu_live()).is_none() {
|
||||
return ks.into_par_iter().map(|k| encode(k, render(k))).collect();
|
||||
};
|
||||
let window = n.max(rayon::current_num_threads());
|
||||
}
|
||||
let mut out = Vec::with_capacity(ks.len());
|
||||
for (w, win) in ks.chunks(window).enumerate() {
|
||||
if !capped() {
|
||||
out.par_extend(ks[w * window..].par_iter().map(|&k| encode(k, render(k))));
|
||||
let mut rest = &ks[..];
|
||||
while !rest.is_empty() {
|
||||
let Some(n) = caps.renders(gpu_live()) else {
|
||||
out.par_extend(rest.par_iter().map(|&k| encode(k, render(k))));
|
||||
break;
|
||||
}
|
||||
};
|
||||
let (win, tail) = rest.split_at(caps.window(n).min(rest.len()));
|
||||
let mut imgs = Vec::with_capacity(win.len());
|
||||
let mut i = 0;
|
||||
while i < win.len() {
|
||||
let end = if capped() { (i + n).min(win.len()) } else { win.len() };
|
||||
let end = caps.renders(gpu_live()).map_or(win.len(), |n| (i + n).min(win.len()));
|
||||
imgs.par_extend(win[i..end].par_iter().map(|&k| render(k)));
|
||||
i = end;
|
||||
}
|
||||
out.par_extend(win.par_iter().copied().zip(imgs.into_par_iter()).map(|(k, img)| encode(k, img)));
|
||||
rest = tail;
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -226,10 +280,10 @@ impl<'a> Cx<'a> {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// The cap is for the GPU's memory: once it has stopped for good, frames render on the
|
||||
// CPU in parallel.
|
||||
// The GPU cap is for the GPU's memory: once it has stopped for good, frames render on the
|
||||
// CPU, in parallel or under the CPU cap (#1615), which holds throughout.
|
||||
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
|
||||
map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f)
|
||||
map_frames_while(ks, self.inflight(), gpu_live, |k| self.frame(comp, k), f)
|
||||
}
|
||||
|
||||
/// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the
|
||||
@@ -286,6 +340,11 @@ impl<'a> Cx<'a> {
|
||||
(self.job.accel.is_some() && self.project.settings.gpu_acceleration).then(|| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref()))
|
||||
}
|
||||
|
||||
/// The GPU and CPU frames-in-flight caps every frame loop shares (#1615).
|
||||
fn inflight(&self) -> Inflight {
|
||||
Inflight { gpu: self.gpu_cap(), cpu: cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) }
|
||||
}
|
||||
|
||||
/// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized.
|
||||
pub fn frame(&self, comp: &Comp, i: u64) -> Image {
|
||||
effectcraft_render::passes::block_on(self.frame_async(comp, i))
|
||||
@@ -363,7 +422,7 @@ impl<'a> Cx<'a> {
|
||||
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
|
||||
map_frames_while(
|
||||
ks,
|
||||
self.gpu_cap(),
|
||||
self.inflight(),
|
||||
gpu_live,
|
||||
|k| self.yuv420_frame(comp, k, w, h).ok_or_else(|| self.frame(comp, k)),
|
||||
|k, r| match r {
|
||||
@@ -575,6 +634,7 @@ fn premultiplied8(img: &Image) -> Vec<u8> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rayon::prelude::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
/// Frames `0..n` through [`map_frames`] with `cap`, recording the most running at once.
|
||||
fn run_capped(n: u64, cap: Option<usize>) -> (Vec<u64>, usize) {
|
||||
@@ -607,7 +667,7 @@ mod tests {
|
||||
let out = pool.install(|| {
|
||||
map_frames_while(
|
||||
(0..24).collect(),
|
||||
Some(1),
|
||||
Inflight { gpu: Some(1), cpu: None },
|
||||
|| live.load(Ordering::SeqCst),
|
||||
|k| {
|
||||
if k == 1 {
|
||||
@@ -767,6 +827,103 @@ mod tests {
|
||||
assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_inflight_is_off_unless_a_positive_number() {
|
||||
assert_eq!(cpu_inflight(None), None);
|
||||
assert_eq!(cpu_inflight(Some("2")), Some(2));
|
||||
assert_eq!(cpu_inflight(Some(" 4 ")), Some(4));
|
||||
for bad in ["0", "", " ", "-2", "three", "2.5", "99999999999999999999999"] {
|
||||
assert_eq!(cpu_inflight(Some(bad)), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_cap_holds_and_combines_with_the_gpu_cap() {
|
||||
// #1615: CPU renders had no cap; a GPU cap alone still lapses when the GPU stops.
|
||||
let caps = |gpu, cpu| Inflight { gpu, cpu };
|
||||
for live in [true, false] {
|
||||
assert_eq!(caps(None, None).renders(live), None);
|
||||
assert_eq!(caps(None, Some(2)).renders(live), Some(2));
|
||||
}
|
||||
assert_eq!(caps(Some(1), None).renders(true), Some(1));
|
||||
assert_eq!(caps(Some(1), None).renders(false), None);
|
||||
assert_eq!(caps(Some(3), Some(2)).renders(true), Some(2));
|
||||
assert_eq!(caps(Some(1), Some(4)).renders(true), Some(1));
|
||||
// Review of #1615: once the GPU stops, the CPU cap applies, not the GPU's 1.
|
||||
assert_eq!(caps(Some(1), Some(4)).renders(false), Some(4));
|
||||
// A CPU cap of 2 on 8 threads renders at most 2 frames at once.
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
|
||||
let (now, max) = (AtomicUsize::new(0), AtomicUsize::new(0));
|
||||
let out = pool.install(|| map_frames_while((0..16).collect(), caps(None, Some(2)), || true, |k| track(&now, &max, k), |_, k| k));
|
||||
assert_eq!(out, (0..16).collect::<Vec<_>>());
|
||||
assert!(max.load(Ordering::SeqCst) <= 2, "{max:?} frames in flight");
|
||||
}
|
||||
|
||||
/// Counts `k` in flight in `now`, records the most in `max`, and holds it for a moment.
|
||||
fn track(now: &AtomicUsize, max: &AtomicUsize, k: u64) -> u64 {
|
||||
let c = now.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
max.fetch_max(c, Ordering::SeqCst);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
now.fetch_sub(1, Ordering::SeqCst);
|
||||
k
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cpu_cap_bounds_the_images_waiting_to_encode() {
|
||||
// Review of #1615: a window of one image per thread was rendered before any encoded, so
|
||||
// a cap of 2 on 8 threads still held 8 finished images.
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
|
||||
for gpu in [None, Some(1)] {
|
||||
let (held, max) = (AtomicUsize::new(0), AtomicUsize::new(0));
|
||||
let out = pool.install(|| {
|
||||
map_frames_while(
|
||||
(0..20).collect(),
|
||||
Inflight { gpu, cpu: Some(2) },
|
||||
|| true,
|
||||
|k| {
|
||||
let c = held.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
max.fetch_max(c, Ordering::SeqCst);
|
||||
k
|
||||
},
|
||||
|k, img| {
|
||||
held.fetch_sub(1, Ordering::SeqCst);
|
||||
assert_eq!(k, img);
|
||||
k
|
||||
},
|
||||
)
|
||||
});
|
||||
assert_eq!(out, (0..20).collect::<Vec<_>>());
|
||||
assert!(max.load(Ordering::SeqCst) <= 2, "gpu {gpu:?}: {max:?} images held");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cpu_cap_takes_over_once_the_gpu_stops() {
|
||||
// Review of #1615: with both caps, frames after the GPU stopped rendered one at a time.
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
|
||||
let (live, now, max) = (AtomicBool::new(true), AtomicUsize::new(0), AtomicUsize::new(0));
|
||||
let out = pool.install(|| {
|
||||
map_frames_while(
|
||||
(0..40).collect(),
|
||||
Inflight { gpu: Some(1), cpu: Some(4) },
|
||||
|| live.load(Ordering::SeqCst),
|
||||
|k| {
|
||||
if k == 1 {
|
||||
live.store(false, Ordering::SeqCst);
|
||||
}
|
||||
if k < 4 {
|
||||
return k;
|
||||
}
|
||||
track(&now, &max, k)
|
||||
},
|
||||
|_, k| k,
|
||||
)
|
||||
});
|
||||
assert_eq!(out, (0..40).collect::<Vec<_>>());
|
||||
let max = max.load(Ordering::SeqCst);
|
||||
assert!((2..=4).contains(&max), "{max} frames in flight after the GPU stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_inflight_override_is_a_positive_number_or_the_default() {
|
||||
assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT);
|
||||
|
||||
@@ -123,7 +123,15 @@ fn ffprobe_video(path: &Path) -> Option<String> {
|
||||
.output()
|
||||
.expect("ffprobe");
|
||||
assert!(out.status.success() && out.stderr.is_empty(), "ffprobe rejected {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
|
||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
// An ffprobe whose build lacks this codec's decoder (distro "free" builds) only parses the
|
||||
// container: pix_fmt and the counted frames come back unknown/N/A, describing the build
|
||||
// rather than our output. Skip the oracle, as when ffprobe is absent.
|
||||
if s.contains("|unknown|") || s.ends_with("|N/A") {
|
||||
eprintln!("ffprobe cannot decode this codec here: skipping the oracle ({s})");
|
||||
return None;
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Decode every frame with ffmpeg (no errors allowed) and check frames 0, 4, 5 and the last.
|
||||
@@ -140,7 +148,14 @@ fn ffmpeg_check(path: &Path, decoder: Option<&str>, tol: f32) {
|
||||
cmd.args(["-c:v", d]);
|
||||
}
|
||||
let out = cmd.arg("-i").arg(path).args(["-pix_fmt", "rgba"]).arg(dir.join("f%03d.png")).output().expect("ffmpeg");
|
||||
assert!(out.status.success() && out.stderr.is_empty(), "ffmpeg ({decoder:?}) failed on {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
// Same as ffprobe: an ffmpeg without this codec's decoder cannot judge our output, so the
|
||||
// oracle skips instead of failing (the in-repo decode above already proved the frames).
|
||||
if !out.status.success() && err.contains("no decoder found") {
|
||||
eprintln!("ffmpeg cannot decode this codec here: skipping the oracle decode ({err})");
|
||||
return;
|
||||
}
|
||||
assert!(out.status.success() && err.is_empty(), "ffmpeg ({decoder:?}) failed on {}: {}", path.display(), err);
|
||||
for k in [0, 4, 5, FRAMES - 1] {
|
||||
let img = image::open(dir.join(format!("f{:03}.png", k + 1))).expect("ffmpeg frame").to_rgba8();
|
||||
check_frame(&Image::from_rgba8(img.width(), img.height(), img.as_raw()), k, tol);
|
||||
|
||||
@@ -12,5 +12,6 @@ mod native_audio_sample_positions;
|
||||
mod opus_roundtrip;
|
||||
mod render_settings;
|
||||
mod roundtrip;
|
||||
mod targa_sequence;
|
||||
mod uncompressed_mov;
|
||||
mod vp9_roundtrip;
|
||||
|
||||
@@ -94,7 +94,7 @@ fn export_bytes(format: OutputFormat, name: &str) -> Vec<u8> {
|
||||
#[test]
|
||||
fn h264_movie_bytes_are_pinned() {
|
||||
let data = export_bytes(OutputFormat::H264, "/out/pinned.mp4");
|
||||
assert_eq!((data.len(), fnv1a(&data)), (104_161, 10_466_446_817_305_548_344), "H.264 MP4");
|
||||
assert_eq!((data.len(), fnv1a(&data)), (104_180, 8_728_430_532_616_484_528), "H.264 MP4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,6 +23,24 @@ const H: u32 = 48;
|
||||
const BG: [f32; 3] = [0.1, 0.1, 0.4];
|
||||
const FRAMES: u64 = 12;
|
||||
|
||||
/// An H.264 encoder this ffmpeg build actually ships, or `None` when it has none. Distro "free"
|
||||
/// builds often omit `libx264` but ship an alternative (e.g. libopenh264), so pick the first
|
||||
/// present by name rather than hard-coding one that may be absent. The oracle only needs *an*
|
||||
/// H.264 encoder; the encoder identity is irrelevant to the assertion.
|
||||
fn h264_encoder() -> Option<&'static str> {
|
||||
static ENC: std::sync::OnceLock<Option<&'static str>> = std::sync::OnceLock::new();
|
||||
*ENC.get_or_init(|| {
|
||||
let out = Command::new("ffmpeg").args(["-hide_banner", "-encoders"]).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
["libx264", "libopenh264", "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_v4l2m2m", "h264_amf"]
|
||||
.into_iter()
|
||||
.find(|name| text.lines().any(|l| l.split_whitespace().any(|tok| tok == *name)))
|
||||
})
|
||||
}
|
||||
|
||||
fn out_dir(name: &str) -> PathBuf {
|
||||
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
@@ -260,6 +278,8 @@ fn image_sequences_roundtrip() {
|
||||
(OutputFormat::JpegSequence, "jpg", 0.06, Channels::Rgb),
|
||||
(OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgb),
|
||||
(OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgba),
|
||||
(OutputFormat::TargaSequence, "tga", 0.01, Channels::Rgb),
|
||||
(OutputFormat::TargaSequence, "tga", 0.01, Channels::Rgba),
|
||||
] {
|
||||
let d = out_dir(&format!("seq-{ext}-{ch:?}"));
|
||||
let om = OutputModule { channels: ch, ..OutputModule::for_format(fmt) };
|
||||
@@ -489,14 +509,18 @@ fn ffmpeg_audio(src: &Path) -> Vec<f32> {
|
||||
fn movies_carry_resampled_footage_audio() {
|
||||
let d = out_dir("audio-44k");
|
||||
let clip = d.join("clip.mp4");
|
||||
let Some(enc) = h264_encoder() else {
|
||||
eprintln!("ffmpeg has no H.264 encoder: skipping");
|
||||
return;
|
||||
};
|
||||
let made = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=30000/1001:duration=3", "-f", "lavfi", "-i"])
|
||||
.args(["aevalsrc=0.5*sin(2*PI*440*t)|0.5*sin(2*PI*(200+400*t/3)*t):s=44100:d=3", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
|
||||
.args(["aevalsrc=0.5*sin(2*PI*440*t)|0.5*sin(2*PI*(200+400*t/3)*t):s=44100:d=3", "-c:v", enc, "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
|
||||
.arg(&clip)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success());
|
||||
if !made {
|
||||
eprintln!("ffmpeg not found: skipping");
|
||||
eprintln!("ffmpeg could not make the H.264 fixture: skipping");
|
||||
return;
|
||||
}
|
||||
let fps = FrameRate::new(30000, 1001);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Targa sequence output: every file is an uncompressed TGA (Truevision TGA 2.0 specification:
|
||||
//! an 18-byte header, BGR(A) pixels, top-left origin, the alpha bits in the image descriptor)
|
||||
//! holding exactly the pixels the lossless PNG sequence of the same job writes, and EffectCraft's
|
||||
//! own Targa import (#745) reads the frames back as the same picture.
|
||||
//!
|
||||
//! The comp is 7×4 @ 10 fps, 3 frames: frame 0 is the background alone, frames 1 and 2 add a
|
||||
//! 2×2 solid in the top two rows (so a flipped picture shows), on a half pixel horizontally, so
|
||||
//! its left and right columns carry partial alpha.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{AlphaMode, Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, sequence_path};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_media::MediaPool;
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_render::NoFootage;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
const W: u32 = 7;
|
||||
const H: u32 = 4;
|
||||
const FRAMES: u64 = 3;
|
||||
|
||||
fn out_dir(name: &str) -> PathBuf {
|
||||
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).expect("mkdir");
|
||||
d
|
||||
}
|
||||
|
||||
fn project() -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(FRAMES as f64 / 10.0));
|
||||
comp.background = [0.1, 0.3, 0.6];
|
||||
let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [0.9, 0.2, 0.1], width: 2, height: 2, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (2, 2), None);
|
||||
l.props.prop_mut("transform/position").expect("position").value = Value::Vec3([3.5, 1.0, 0.0]);
|
||||
l.in_point = comp.frame_rate.tick_of(1);
|
||||
comp.layers.push(l);
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// Render the comp through `om` to `dir/f_[#####].<ext>`; the first frame's path.
|
||||
fn render(p: &Project, cid: ItemId, om: &OutputModule, dir: &Path) -> String {
|
||||
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let path = dir.join(format!("f_[#####].{}", om.format.extension())).to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &s,
|
||||
output: om,
|
||||
path: &path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
let r = export(&job, &mut |_| true).expect("export");
|
||||
assert_eq!((r.frames, r.width, r.height), (FRAMES, W, H));
|
||||
r.path
|
||||
}
|
||||
|
||||
/// The numbered files of a render of `ext`: frames 0..FRAMES and nothing after.
|
||||
fn frame_paths(dir: &Path, ext: &str) -> Vec<String> {
|
||||
let template = dir.join(format!("f_[#####].{ext}")).to_string_lossy().to_string();
|
||||
assert!(!Path::new(&sequence_path(&template, FRAMES as i64)).exists(), "no frame after the last");
|
||||
(0..FRAMES as i64).map(|k| sequence_path(&template, k)).collect()
|
||||
}
|
||||
|
||||
/// A Targa file's header fields and its pixels as top-down RGBA bytes.
|
||||
struct Tga {
|
||||
image_type: u8,
|
||||
width: u16,
|
||||
height: u16,
|
||||
depth: u8,
|
||||
descriptor: u8,
|
||||
rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
fn read_tga(path: &str) -> Tga {
|
||||
let b = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
let u16_at = |i: usize| u16::from_le_bytes([b[i], b[i + 1]]);
|
||||
assert_eq!((b[0], b[1]), (0, 0), "{path}: no image ID, no colour map");
|
||||
assert_eq!((u16_at(8), u16_at(10)), (0, 0), "{path}: X / Y origin");
|
||||
let (width, height, depth, descriptor) = (u16_at(12), u16_at(14), b[16], b[17]);
|
||||
let bpp = usize::from(depth / 8);
|
||||
// Uncompressed, without an image ID, colour map or 2.0 footer: header and pixels only.
|
||||
assert_eq!(b.len(), 18 + usize::from(width) * usize::from(height) * bpp, "{path}: file size");
|
||||
let rgba = b[18..]
|
||||
.chunks(bpp)
|
||||
.flat_map(|p| match p {
|
||||
[b, g, r, a] => [*r, *g, *b, *a],
|
||||
[b, g, r] => [*r, *g, *b, 255],
|
||||
[v] => [*v, *v, *v, 255],
|
||||
_ => panic!("{path}: {depth}-bit pixels"),
|
||||
})
|
||||
.collect();
|
||||
Tga { image_type: b[2], width, height, depth, descriptor, rgba }
|
||||
}
|
||||
|
||||
fn png_rgba(path: &str) -> Vec<u8> {
|
||||
image::open(path).unwrap_or_else(|e| panic!("{path}: {e}")).to_rgba8().into_raw()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targa_sequence_writes_the_png_sequence_pixels() {
|
||||
let (p, cid) = project();
|
||||
for (channels, alpha_mode, image_type, depth, descriptor) in [
|
||||
(Channels::Rgb, AlphaMode::Straight, 2, 24, 0x20),
|
||||
(Channels::Rgba, AlphaMode::Straight, 2, 32, 0x28),
|
||||
(Channels::Rgba, AlphaMode::Premultiplied, 2, 32, 0x28),
|
||||
(Channels::Alpha, AlphaMode::Straight, 3, 8, 0x20),
|
||||
] {
|
||||
let name = format!("tga-{channels:?}-{alpha_mode:?}");
|
||||
let tga_dir = out_dir(&name);
|
||||
let png_dir = out_dir(&format!("{name}-png"));
|
||||
let om = OutputModule { channels, alpha_mode, ..OutputModule::for_format(OutputFormat::TargaSequence) };
|
||||
let first = render(&p, cid, &om, &tga_dir);
|
||||
assert!(first.ends_with("f_00000.tga"), "{first}");
|
||||
render(&p, cid, &OutputModule { format: OutputFormat::PngSequence, ..om.clone() }, &png_dir);
|
||||
let mut frames = Vec::new();
|
||||
for (tga, png) in frame_paths(&tga_dir, "tga").iter().zip(frame_paths(&png_dir, "png")) {
|
||||
let t = read_tga(tga);
|
||||
assert_eq!((t.image_type, t.width, t.height, t.depth, t.descriptor), (image_type, W as u16, H as u16, depth, descriptor), "{tga}");
|
||||
assert_eq!(t.rgba, png_rgba(&png), "{tga} matches {png}");
|
||||
frames.push(t.rgba);
|
||||
}
|
||||
// Frame 0 is background only; frames 1 and 2 hold the solid, so the numbering follows
|
||||
// the comp's frames.
|
||||
let at = |f: &[u8], x: u32, y: u32| -> [u8; 4] {
|
||||
let i = ((y * W + x) * 4) as usize;
|
||||
[f[i], f[i + 1], f[i + 2], f[i + 3]]
|
||||
};
|
||||
assert_ne!(at(&frames[0], 3, 1), at(&frames[1], 3, 1), "{name}");
|
||||
assert_eq!(frames[1], frames[2], "{name}");
|
||||
// Top-down rows: the solid is in the top half, the bottom rows are still background.
|
||||
assert_eq!(at(&frames[1], 3, 3), at(&frames[0], 3, 3), "{name}: bottom row");
|
||||
match channels {
|
||||
Channels::Rgb => {
|
||||
assert_eq!(at(&frames[0], 0, 0)[3], 255);
|
||||
assert_eq!(at(&frames[1], 3, 1), [230, 51, 26, 255], "{name}: the opaque solid");
|
||||
}
|
||||
Channels::Rgba => {
|
||||
assert_eq!(at(&frames[0], 0, 0), [0, 0, 0, 0], "{name}: transparent background");
|
||||
assert_eq!(at(&frames[1], 3, 1), [230, 51, 26, 255], "{name}: the opaque solid");
|
||||
let edge = at(&frames[1], 2, 1);
|
||||
assert!(edge[3] > 0 && edge[3] < 255, "{name}: partial alpha at the solid's edge {edge:?}");
|
||||
if alpha_mode == AlphaMode::Straight {
|
||||
// Unmatted from the renderer's premultiplied pixel: the solid's colour, within rounding.
|
||||
assert!(edge[..3].iter().zip([230u8, 51, 26]).all(|(a, b)| a.abs_diff(b) <= 1), "{name}: straight colour {edge:?}");
|
||||
} else {
|
||||
assert!(edge[0] < 230 && edge[0] <= edge[3], "{name}: premultiplied colour {edge:?}");
|
||||
}
|
||||
}
|
||||
Channels::Alpha => {
|
||||
assert_eq!(at(&frames[0], 0, 0), [0, 0, 0, 255], "{name}: no matte yet");
|
||||
assert_eq!(at(&frames[1], 3, 1), [255, 255, 255, 255], "{name}: the solid's matte");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Numbered stills read back as an image sequence through the media layer (Targa: #745).
|
||||
fn import_sequence(paths: &[String]) -> (Footage, MediaPool) {
|
||||
let pool = MediaPool::new();
|
||||
pool.set_read_ahead(false);
|
||||
let first = effectcraft_media::probe(&paths[0]).expect("probe our output");
|
||||
assert_eq!((first.kind, first.width, first.height), (FootageKind::Still, W, H));
|
||||
for p in paths {
|
||||
let bytes: Arc<[u8]> = std::fs::read(p).expect("read").into();
|
||||
pool.add_bytes(p, bytes);
|
||||
}
|
||||
let mut footage = Footage { kind: FootageKind::Sequence, sequence: paths.to_vec(), ..first };
|
||||
footage.sync_sequence_duration();
|
||||
(footage, pool)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targa_sequence_round_trips_through_targa_import() {
|
||||
let (p, cid) = project();
|
||||
for alpha_mode in [AlphaMode::Straight, AlphaMode::Premultiplied] {
|
||||
let name = format!("tga-import-{alpha_mode:?}");
|
||||
let tga_dir = out_dir(&name);
|
||||
let png_dir = out_dir(&format!("{name}-png"));
|
||||
let om = OutputModule { channels: Channels::Rgba, alpha_mode, ..OutputModule::for_format(OutputFormat::TargaSequence) };
|
||||
render(&p, cid, &om, &tga_dir);
|
||||
// The reference: the same job as a straight-alpha PNG sequence (PNG's alpha is labelled).
|
||||
render(&p, cid, &OutputModule { format: OutputFormat::PngSequence, alpha_mode: AlphaMode::Straight, ..om.clone() }, &png_dir);
|
||||
let (mut tga, tga_pool) = import_sequence(&frame_paths(&tga_dir, "tga"));
|
||||
let (png, png_pool) = import_sequence(&frame_paths(&png_dir, "png"));
|
||||
assert_eq!(tga.codec, "TGA");
|
||||
assert_eq!(tga.sequence_frames(), FRAMES as i64);
|
||||
// Targa doesn't label its alpha: import guesses straight, and Interpret Footage sets
|
||||
// premultiplied for a premultiplied render.
|
||||
assert_eq!(tga.alpha, effectcraft_project::AlphaMode::Straight);
|
||||
if alpha_mode == AlphaMode::Premultiplied {
|
||||
tga.alpha = effectcraft_project::AlphaMode::Premultiplied;
|
||||
}
|
||||
// Premultiplying to 8 bits rounds partially transparent colour.
|
||||
let tol = if alpha_mode == AlphaMode::Straight { 0.0 } else { 1.0 / 255.0 };
|
||||
for k in 0..FRAMES as i64 {
|
||||
let a = tga_pool.frame_at(&tga, tga.frame_rate.tick_of(k)).expect("decode Targa");
|
||||
let b = png_pool.frame_at(&png, png.frame_rate.tick_of(k)).expect("decode PNG");
|
||||
assert_eq!((a.width, a.height), (W, H));
|
||||
for (x, y) in a.data.iter().zip(&b.data) {
|
||||
assert!(x.iter().zip(y).all(|(x, y)| (x - y).abs() <= tol), "{name} frame {k}: {x:?} != {y:?}");
|
||||
}
|
||||
}
|
||||
let opaque = tga_pool.frame_at(&tga, tga.frame_rate.tick_of(1)).expect("decode");
|
||||
assert_eq!(opaque.data[opaque.idx(0, 0)][3], 0.0, "{name}: the background stays transparent");
|
||||
assert_eq!(opaque.data[opaque.idx(3, 1)][3], 1.0, "{name}: the solid is opaque");
|
||||
}
|
||||
}
|
||||
+114
-11
@@ -670,14 +670,17 @@ var __groupHandler = {
|
||||
},
|
||||
};
|
||||
|
||||
function __mkGroup(c, l, info) {
|
||||
var f = function (k) { return f.__get(k); };
|
||||
delete f.name;
|
||||
delete f.length;
|
||||
Object.setPrototypeOf(f, __GroupProto);
|
||||
f.__c = c; f.__l = l; f.__p = info[0]; f.__info = info;
|
||||
return new Proxy(f, __groupHandler);
|
||||
}
|
||||
// `Proxy` is private to the object model (see "Stack safety" at the end).
|
||||
var __mkGroup = (function (P) {
|
||||
return function (c, l, info) {
|
||||
var f = function (k) { return f.__get(k); };
|
||||
delete f.name;
|
||||
delete f.length;
|
||||
Object.setPrototypeOf(f, __GroupProto);
|
||||
f.__c = c; f.__l = l; f.__p = info[0]; f.__info = info;
|
||||
return new P(f, __groupHandler);
|
||||
};
|
||||
})(Proxy);
|
||||
|
||||
// Properties ------------------------------------------------------------------------------------
|
||||
|
||||
@@ -852,9 +855,11 @@ var __propHandler = {
|
||||
},
|
||||
};
|
||||
|
||||
function __mkProp(c, l, path) {
|
||||
return new Proxy(new Prop(c, l, path), __propHandler);
|
||||
}
|
||||
var __mkProp = (function (P) {
|
||||
return function (c, l, path) {
|
||||
return new P(new Prop(c, l, path), __propHandler);
|
||||
};
|
||||
})(Proxy);
|
||||
|
||||
// Text styles (the Source Text style API) ------------------------------------------------------
|
||||
// A TextStyle is the source document (as JSON from the host), the character index it reads its
|
||||
@@ -1069,3 +1074,101 @@ function velocityAtTime(t) { return thisProperty.velocityAtTime(t); }
|
||||
function speedAtTime(t) { return thisProperty.speedAtTime(t); }
|
||||
function key(n) { return thisProperty.key(n); }
|
||||
function nearestKey(t) { return thisProperty.nearestKey(t); }
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Stack safety (#2124, #2125). A stack overflow aborts the whole app, so nothing a script does
|
||||
// may nest native code without bound. The expression text is checked for nesting before it is
|
||||
// compiled; source handed to `eval` and the function constructors at run time is checked by
|
||||
// `__nest`. The natives that walk nested values recursively (`join`, which `toString` and string
|
||||
// conversion use, `toLocaleString`, `JSON.stringify`, `flat`) call `__stack` once per level,
|
||||
// which throws once the run has used its share of the stack. `Proxy` (chains of proxies nest
|
||||
// without any script frame) and `Reflect` are not available to expressions.
|
||||
(function () {
|
||||
var nest = __nest, stack = __stack, apply = Reflect.apply, construct = Reflect.construct;
|
||||
var define = Object.defineProperty, describe = Object.getOwnPropertyDescriptor, proto = Object.getPrototypeOf;
|
||||
// Replace `obj[name]` keeping its attributes.
|
||||
function replace(obj, name, f) {
|
||||
var d = describe(obj, name);
|
||||
define(obj, name, { value: f, writable: d.writable, enumerable: d.enumerable, configurable: true });
|
||||
}
|
||||
function guard(obj, name) {
|
||||
var f = obj[name];
|
||||
replace(obj, name, function () { stack(); return apply(f, this, arguments); });
|
||||
}
|
||||
|
||||
// Indirect on purpose: a wrapper can't make a direct eval, so `eval` sees globals and `var`s
|
||||
// but not `let`/`const` locals of the expression. Keeping direct eval would mean leaving it
|
||||
// unchecked; expressions practically never need their locals inside eval.
|
||||
var evalJs = eval;
|
||||
eval = function (x) {
|
||||
if (typeof x === 'string') {
|
||||
stack();
|
||||
nest(x);
|
||||
}
|
||||
return evalJs(x);
|
||||
};
|
||||
// `Function` and the generator / async variants (reachable as `(function* () {}).constructor`).
|
||||
function dynamic(F) {
|
||||
var w = function () {
|
||||
var args = [];
|
||||
for (var i = 0; i < arguments.length; i++) args.push(String(arguments[i]));
|
||||
stack();
|
||||
nest(args.slice(0, -1).join(','));
|
||||
nest(args.length ? args[args.length - 1] : '');
|
||||
return construct(F, args);
|
||||
};
|
||||
define(w, 'name', { value: F.name, configurable: true });
|
||||
w.prototype = F.prototype;
|
||||
replace(F.prototype, 'constructor', w);
|
||||
return w;
|
||||
}
|
||||
Function = dynamic(Function);
|
||||
dynamic(proto(function* () {}).constructor);
|
||||
dynamic(proto(async function () {}).constructor);
|
||||
dynamic(proto(async function* () {}).constructor);
|
||||
|
||||
guard(Array.prototype, 'join');
|
||||
guard(Array.prototype, 'toLocaleString');
|
||||
var flat = Array.prototype.flat;
|
||||
replace(Array.prototype, 'flat', function (depth) {
|
||||
stack();
|
||||
var d = depth === undefined ? 1 : Number(depth);
|
||||
return apply(flat, this, [d > 64 ? 64 : d]);
|
||||
});
|
||||
|
||||
// A replacer function sees every value before it is serialized: the per-level check. An array
|
||||
// replacer (a list of keys) becomes one that copies each object's listed keys, in list order.
|
||||
// Each source object gets one copy per call, so a cycle in the source is a cycle of copies and
|
||||
// the native check reports it (TypeError); an object shared without a cycle is still fine.
|
||||
var stringify = JSON.stringify, MapJs = Map;
|
||||
replace(JSON, 'stringify', function (value, replacer, space) {
|
||||
var fn = typeof replacer === 'function' ? replacer : null;
|
||||
var keys = null, copies = new MapJs();
|
||||
if (Array.isArray(replacer)) {
|
||||
keys = [];
|
||||
for (var i = 0; i < replacer.length; i++) {
|
||||
var k = replacer[i];
|
||||
if (typeof k === 'number' || k instanceof Number || k instanceof String) k = String(k);
|
||||
if (typeof k === 'string' && keys.indexOf(k) < 0) keys.push(k);
|
||||
}
|
||||
}
|
||||
return apply(stringify, JSON, [value, function (key, v) {
|
||||
stack();
|
||||
if (fn !== null) v = apply(fn, this, [key, v]);
|
||||
if (keys !== null && v !== null && typeof v === 'object' && !Array.isArray(v) &&
|
||||
!(v instanceof Number || v instanceof String || v instanceof Boolean)) {
|
||||
var o = copies.get(v);
|
||||
if (o === undefined) {
|
||||
o = {};
|
||||
for (var j = 0; j < keys.length; j++) if (keys[j] in v) o[keys[j]] = v[keys[j]];
|
||||
copies.set(v, o);
|
||||
}
|
||||
v = o;
|
||||
}
|
||||
return v;
|
||||
}, space]);
|
||||
});
|
||||
|
||||
delete globalThis.Proxy;
|
||||
delete globalThis.Reflect;
|
||||
})();
|
||||
|
||||
@@ -805,6 +805,12 @@ pub fn check_nesting(src: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An error when plain JavaScript `src` (not rewritten: what `eval` and `Function` get at run
|
||||
/// time) nests deeper than [`MAX_NESTING`] levels.
|
||||
pub fn check_plain_nesting(src: &str) -> Result<(), String> {
|
||||
if too_nested(&tokenize(src).0) { Err(too_deep()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn too_deep() -> String {
|
||||
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets, or too long a chain of operators)")
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ thread_local! {
|
||||
pub static FRAMES: RefCell<Vec<Frame>> = const { RefCell::new(Vec::new()) };
|
||||
/// Script runs since the outermost evaluation on this thread began (nested ones included).
|
||||
pub static RUNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
/// Stack address where the current script run began (0: none; see [`native_stack`]).
|
||||
static STACK_BASE: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -232,17 +234,54 @@ fn native_random(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<J
|
||||
Ok(JsValue::from(noise::unit(noise::mix(&parts))))
|
||||
}
|
||||
|
||||
/// Stack a script run may use below [`run`] for natives that recurse once per level of a nested
|
||||
/// value (`join`, `JSON.stringify`, …; the prelude calls [`native_stack`] per level). It is the
|
||||
/// same on every thread, so a value converts or fails alike in preview, export and commands.
|
||||
/// Expressions run on render, export and diagnostics threads (2 MiB, std and rayon's default)
|
||||
/// and on the main thread for commands and syntax checks (1 MiB on Windows). A debug build
|
||||
/// stops the nested-value tests within a fresh 512 KiB thread, which leaves the main thread
|
||||
/// 512 KiB for its callers. The parser's limit ([`crate::rewrite::MAX_NESTING`]) is the tighter
|
||||
/// one there: 30 levels need about 1 MiB in debug builds. On wasm32 the address of a local is on
|
||||
/// the shadow stack, not the engine's call stack, so this measures nothing useful there.
|
||||
const NATIVE_STACK: usize = 256 << 10;
|
||||
|
||||
/// An address on the current stack (on wasm32, the shadow stack).
|
||||
fn stack_here() -> usize {
|
||||
let marker = 0u8;
|
||||
std::hint::black_box(&marker) as *const u8 as usize
|
||||
}
|
||||
|
||||
/// `__stack()`: throw once the current run has used [`NATIVE_STACK`] (values nested so deeply
|
||||
/// would overflow the stack, which aborts the app).
|
||||
fn native_stack(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
|
||||
let base = STACK_BASE.with(|b| b.get());
|
||||
if base != 0 && base.abs_diff(stack_here()) > NATIVE_STACK {
|
||||
return Err(JsNativeError::range().with_message("value is nested too deeply").into());
|
||||
}
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `__nest(src)`: throw when source handed to `eval` / `Function` at run time nests deeper than
|
||||
/// the parser's stack allows (the expression text itself is checked before it is compiled).
|
||||
fn native_nesting(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let src = string(args, 0, ctx)?;
|
||||
crate::rewrite::check_plain_nesting(&src).map_err(|e| JsNativeError::range().with_message(e.trim_start_matches("Error: ").to_string()))?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
fn new() -> Result<Runtime, String> {
|
||||
let mut ctx = Context::default();
|
||||
let limits = ctx.runtime_limits_mut();
|
||||
limits.set_loop_iteration_limit(LOOP_LIMIT);
|
||||
limits.set_recursion_limit(400);
|
||||
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 4] = [
|
||||
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 6] = [
|
||||
(js_string!("__stack"), 0, native_stack),
|
||||
(js_string!("__h"), 6, native_host),
|
||||
(js_string!("__wig"), 8, native_wiggle),
|
||||
(js_string!("__noise"), 3, native_noise),
|
||||
(js_string!("__rnd"), 6, native_random),
|
||||
(js_string!("__nest"), 1, native_nesting),
|
||||
];
|
||||
for (name, len, f) in natives {
|
||||
ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?;
|
||||
@@ -361,6 +400,9 @@ pub fn out_of_time() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels of nested arrays a script result keeps.
|
||||
const MAX_DETACH: usize = 4;
|
||||
|
||||
/// Copy a JS value out of the heap.
|
||||
fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
|
||||
if v.is_undefined() {
|
||||
@@ -380,7 +422,7 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
|
||||
}
|
||||
let err = |e: boa_engine::JsError| format_error(&e.to_string(), usize::MAX);
|
||||
if let Some(o) = v.as_object() {
|
||||
if o.is_array() && depth < 4 {
|
||||
if o.is_array() && depth < MAX_DETACH {
|
||||
let arr = JsArray::from_object(o).map_err(err)?;
|
||||
let n = arr.length(ctx).map_err(err)?.min(1 << 16);
|
||||
let mut out = Vec::with_capacity(n as usize);
|
||||
@@ -408,6 +450,11 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
|
||||
return Ok(Out::Path { points, ins, outs, closed });
|
||||
}
|
||||
}
|
||||
// Below the levels detached above, an object's text could be its whole nested content
|
||||
// (`String([[[…]]])`), converted by natives that recurse once per level.
|
||||
if depth >= MAX_DETACH && v.is_object() {
|
||||
return Ok(Out::Object("[object]".into()));
|
||||
}
|
||||
Ok(Out::Object(v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default()))
|
||||
}
|
||||
|
||||
@@ -484,6 +531,8 @@ fn position_line(msg: &str) -> Option<usize> {
|
||||
/// `__begin` with memoized answers, so nothing carries over. A script that really overflows the
|
||||
/// stack fails again on the fresh context and reports the error.
|
||||
pub fn run(text: &str, b: &Begin) -> Run {
|
||||
// Runs don't nest (other expressions are evaluated between runs), so one base suffices.
|
||||
STACK_BASE.with(|s| s.set(stack_here()));
|
||||
RUNTIME.with(|r| {
|
||||
let mut r = r.borrow_mut();
|
||||
for fresh in [false, true] {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
mod deep_nesting;
|
||||
mod fuzz;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Deep nesting the expression text check can't see (#2124, #2125): source handed to `eval` /
|
||||
//! `Function` at run time, and deeply nested values that boa's natives (`join`, `JSON.stringify`,
|
||||
//! proxies…) walk recursively. Each used to overflow the stack, which aborts the whole app
|
||||
//! (`catch_unwind` can't catch it); now each is an expression error.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The render threads' stack size (rayon's default): what the app gets.
|
||||
const RENDER_STACK: usize = 2 << 20;
|
||||
|
||||
/// Evaluate `text` on the title's source text (any result converts) on a render-sized stack.
|
||||
fn eval_small_stack(text: &str) -> Result<Value, String> {
|
||||
eval_on_stack(text, RENDER_STACK)
|
||||
}
|
||||
|
||||
/// Evaluate `text` on the title's source text on a thread with `size` bytes of stack.
|
||||
fn eval_on_stack(text: &str, size: usize) -> Result<Value, String> {
|
||||
let text = text.to_string();
|
||||
std::thread::Builder::new()
|
||||
.stack_size(size)
|
||||
.spawn(move || {
|
||||
let f = fx();
|
||||
f.ev(f.title, "text/sourceText", &text, 0.0)
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn text_of(r: Result<Value, String>) -> String {
|
||||
match r {
|
||||
Ok(Value::Text(d)) => d.text,
|
||||
other => panic!("not text: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A 100 000-deep array: `var a = []; … a = [a]` in a loop (under the loop limit).
|
||||
const DEEP_ARRAY: &str = "var a = []; for (var i = 0; i < 100000; i++) a = [a];";
|
||||
|
||||
#[test]
|
||||
fn eval_and_function_constructors_check_nesting() {
|
||||
let deep = r#""(".repeat(1000) + "1" + ")".repeat(1000)"#;
|
||||
for call in [
|
||||
format!("eval({deep})"),
|
||||
format!("Function({deep})()"),
|
||||
format!("new Function('a', {deep})(1)"),
|
||||
format!("Function('a = ' + {deep}, 'return a')()"),
|
||||
"Function.apply(null, '('.repeat(1000).split('').concat(['1']))".to_string(),
|
||||
format!("(function () {{}}).constructor({deep})()"),
|
||||
format!("Object.getPrototypeOf(function* () {{}}).constructor({deep})"),
|
||||
format!("Object.getPrototypeOf(async function () {{}}).constructor({deep})"),
|
||||
format!("Object.getPrototypeOf(async function* () {{}}).constructor({deep})"),
|
||||
] {
|
||||
let e = eval_small_stack(&call).expect_err(&call);
|
||||
assert!(e.contains("nested too deeply"), "{call}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_and_function_still_work() {
|
||||
let f = fx();
|
||||
assert_eq!(f.num("eval('1 + 2')", 0.0), 3.0);
|
||||
assert_eq!(f.num("eval('[1, [2, (3)]]')[1][1]", 0.0), 3.0);
|
||||
assert_eq!(f.num("new Function('a', 'b', 'return a * b')(3, 4)", 0.0), 12.0);
|
||||
assert_eq!(f.num("Function('return 5')()", 0.0), 5.0);
|
||||
assert_eq!(f.num("(function () {}).constructor('return 6')()", 0.0), 6.0);
|
||||
assert_eq!(f.num("(function () {}) instanceof Function ? 1 : 0", 0.0), 1.0);
|
||||
assert_eq!(f.num("(function () {}).constructor === Function ? 1 : 0", 0.0), 1.0);
|
||||
assert_eq!(f.num("Object.getPrototypeOf(function* () { yield 7; }).constructor('yield 7')().next().value", 0.0), 7.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_array_result_is_an_error_or_text_not_a_crash() {
|
||||
// The result itself: detaching it used to `toString` it past depth 4.
|
||||
let _ = eval_small_stack(&format!("{DEEP_ARRAY} a"));
|
||||
// Explicit conversions inside the script.
|
||||
for tail in ["a.join()", "a.toString()", "String(a)", "a + ''", "`${a}`", "a.toLocaleString()", "JSON.stringify(a)", "JSON.stringify({ a: a })"] {
|
||||
let text = format!("{DEEP_ARRAY} {tail}");
|
||||
let e = eval_small_stack(&text).expect_err(&text);
|
||||
assert!(e.contains("nested too deeply"), "{text}: {e}");
|
||||
}
|
||||
// A deep array as a text style value goes through JSON.stringify in the prelude.
|
||||
let _ = eval_small_stack(&format!("{DEEP_ARRAY} text.sourceText.style.setFontSize(a)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_proxy_chain_is_an_error_not_a_crash() {
|
||||
let text = "var p = {}; for (var i = 0; i < 100000; i++) p = new Proxy(p, {}); p.x";
|
||||
let e = eval_small_stack(text).expect_err(text);
|
||||
assert!(e.contains("Proxy") && e.contains("not defined"), "{text}: {e}");
|
||||
// Neither is available to expressions; the object model's own proxies still work.
|
||||
let f = fx();
|
||||
assert_eq!(f.text("typeof Proxy + ' ' + typeof Reflect", 0.0), "undefined undefined");
|
||||
assert_eq!(f.num("thisComp.layer('A').effect('Slider Control')('Slider')", 0.0), 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_values_still_convert() {
|
||||
let f = fx();
|
||||
assert_eq!(f.text("[[1, 2], [3, [4, 5]]].join(';')", 0.0), "1,2;3,4,5");
|
||||
assert_eq!(f.text("String([[1, 2], 3])", 0.0), "1,2,3");
|
||||
assert_eq!(f.text("JSON.stringify({ a: [1, { b: [2] }] })", 0.0), r#"{"a":[1,{"b":[2]}]}"#);
|
||||
assert_eq!(f.text("JSON.stringify({ a: 1, b: 2 }, ['b'])", 0.0), r#"{"b":2}"#);
|
||||
assert_eq!(f.text("JSON.stringify([1, [2]], null, 1)", 0.0), "[\n 1,\n [\n 2\n ]\n]");
|
||||
assert_eq!(f.text("JSON.stringify({ a: 1 }, function (k, v) { return k === 'a' ? v + 1 : v; })", 0.0), r#"{"a":2}"#);
|
||||
// Nested deeper than the detached levels: still converts to its text.
|
||||
assert_eq!(text_of(eval_small_stack("[[[[[[1, 2]]]]]]")), "");
|
||||
assert_eq!(f.text("String([[[[[[[[1]]]]]]]])", 0.0), "1");
|
||||
assert_eq!(f.num("[1, [2, [3, [4]]]].flat(Infinity).length", 0.0), 4.0);
|
||||
}
|
||||
|
||||
/// The main thread runs commands and syntax checks: 1 MiB on Windows. Half of it, for the
|
||||
/// callers above the evaluation, must still turn the nested-value triggers into errors.
|
||||
#[test]
|
||||
fn nested_values_are_an_error_on_half_the_main_thread() {
|
||||
for tail in ["a.join()", "String([a])", "JSON.stringify(a)", "a.toLocaleString()"] {
|
||||
let text = format!("{DEEP_ARRAY} {tail}");
|
||||
let e = eval_on_stack(&text, 512 << 10).expect_err(&text);
|
||||
assert!(e.contains("nested too deeply"), "{tail}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stringify_with_a_key_list_reports_cycles_and_allows_shared_objects() {
|
||||
let f = fx();
|
||||
let cyclic = f.text("var o = {a: 1}; o.self = o; try { JSON.stringify(o, ['a', 'self']) } catch (e) { e.name }", 0.0);
|
||||
assert_eq!(cyclic, "TypeError");
|
||||
let shared = f.text("var x = {a: 1, b: 2}; JSON.stringify({p: x, q: x}, ['p', 'q', 'a'])", 0.0);
|
||||
assert_eq!(shared, r#"{"p":{"a":1},"q":{"a":1}}"#);
|
||||
}
|
||||
@@ -74,6 +74,8 @@ struct Lease {
|
||||
pool: Weak<Mutex<Pool>>,
|
||||
/// The [`Enc`] that took it (see [`Pool::owned`]).
|
||||
owner: u64,
|
||||
/// The pool's [`Pool::generation`] when it was taken.
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Lease {
|
||||
@@ -87,7 +89,7 @@ impl Drop for Lease {
|
||||
if let Some(pool) = self.pool.upgrade()
|
||||
&& let Ok(mut p) = pool.lock()
|
||||
{
|
||||
p.release_owned(self.texture.clone(), self.owner);
|
||||
p.release_owned(self.texture.clone(), self.owner, self.generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +120,8 @@ pub(crate) struct Pool {
|
||||
/// working texture of another `Enc` before it is gone.
|
||||
owned: HashMap<u64, HashMap<(u32, u32), Vec<wgpu::Texture>>>,
|
||||
next_owner: u64,
|
||||
/// Raised by [`Pool::discard`]: a texture taken before it never comes back to the pool.
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// In-`Enc` texture reuse ([`Pool::owned`]); tests turn it off to compare.
|
||||
@@ -276,7 +280,10 @@ impl Pool {
|
||||
}
|
||||
}
|
||||
|
||||
fn release_owned(&mut self, t: wgpu::Texture, owner: u64) {
|
||||
fn release_owned(&mut self, t: wgpu::Texture, owner: u64, generation: u64) {
|
||||
if generation != self.generation {
|
||||
return;
|
||||
}
|
||||
if let Some(m) = self.owned.get_mut(&owner) {
|
||||
m.entry((t.width(), t.height())).or_default().push(t);
|
||||
return;
|
||||
@@ -306,6 +313,18 @@ impl Pool {
|
||||
self.free.clear();
|
||||
self.bytes = 0;
|
||||
}
|
||||
|
||||
/// After a failed frame (#1319): drop every kept texture, the free ones and those waiting
|
||||
/// for an open encoder or kept by one, and the textures still out never return. The
|
||||
/// encoders' bookkeeping stays, so reuse remains ordered.
|
||||
fn discard(&mut self) {
|
||||
self.clear();
|
||||
self.released.clear();
|
||||
for kept in self.owned.values_mut() {
|
||||
kept.clear();
|
||||
}
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Uniform parameter block shared by every kernel (see `shaders/common.wgsl`).
|
||||
@@ -1015,8 +1034,9 @@ impl GpuContext {
|
||||
}
|
||||
|
||||
fn texture(&self, w: u32, h: u32) -> wgpu::Texture {
|
||||
// Named with its size, so a GPU error about it says which texture failed (#1319).
|
||||
self.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
label: Some(&format!("working image {w}×{h}")),
|
||||
size: wgpu::Extent3d { width: w.max(1), height: h.max(1), depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
@@ -1031,10 +1051,10 @@ impl GpuContext {
|
||||
/// one is zeroed.
|
||||
fn pooled(&self, w: u32, h: u32, owner: u64) -> (GpuImage, bool) {
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
let reused = self.pool.lock().ok().and_then(|mut p| p.take_owned(owner, w, h).or_else(|| p.take(w, h)));
|
||||
let (reused, generation) = self.pool.lock().map(|mut p| (p.take_owned(owner, w, h).or_else(|| p.take(w, h)), p.generation)).unwrap_or((None, u64::MAX));
|
||||
let was_reused = reused.is_some();
|
||||
let texture = reused.unwrap_or_else(|| self.texture(w, h));
|
||||
let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner });
|
||||
let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner, generation });
|
||||
(GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused)
|
||||
}
|
||||
|
||||
@@ -1150,6 +1170,21 @@ impl GpuContext {
|
||||
s.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// After a failed frame: forget every texture and buffer kept for reuse, since any of them
|
||||
/// may be one the device rejected (#1319). Later frames allocate afresh.
|
||||
pub fn discard_kept(&self) {
|
||||
self.clear_uploads();
|
||||
if let Ok(mut p) = self.pool.lock() {
|
||||
p.discard();
|
||||
}
|
||||
if let Ok(mut z) = self.zeros.lock() {
|
||||
z.clear();
|
||||
}
|
||||
if let Ok(mut c) = self.particle_states.lock() {
|
||||
c.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records GPU work for one render (a command encoder submitted at readback or the end).
|
||||
|
||||
+45
-13
@@ -97,6 +97,29 @@ pub use wgpu;
|
||||
|
||||
use crate::context::Enc;
|
||||
|
||||
/// Why [`Gpu::within_memory`] discarded a frame.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FrameError {
|
||||
/// The GPU context is retired (device lost, a readback timed out, an uncaptured error).
|
||||
Retired(String),
|
||||
/// Out of video memory (or a driver reporting a failed allocation as an internal error),
|
||||
/// with any validation errors it went on to cause.
|
||||
Memory(String),
|
||||
/// The device rejected a command with no allocation failure before it (an invalid texture
|
||||
/// or parameters, #1319): a compositor bug, not memory pressure.
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FrameError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FrameError::Retired(e) | FrameError::Memory(e) | FrameError::Validation(e) => f.write_str(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for FrameError {}
|
||||
|
||||
/// The GPU compositor (cheap to clone; one device shared by all clones).
|
||||
#[derive(Clone)]
|
||||
pub struct Gpu {
|
||||
@@ -265,33 +288,42 @@ impl Gpu {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run work in native thread-local out-of-memory and internal-error scopes. A failed
|
||||
/// result is discarded and cached allocations are released; callers can retry with a fresh
|
||||
/// CPU renderer. Some drivers report a failed allocation as an internal error rather than
|
||||
/// out-of-memory (Adreno's `ERROR_INITIALIZATION_FAILED`, #591), so both count.
|
||||
/// Run work in native thread-local out-of-memory, internal-error and validation scopes. A
|
||||
/// failed result is discarded and the textures and buffers kept for reuse are released;
|
||||
/// callers can retry with a fresh CPU renderer. Some drivers report a failed allocation as an
|
||||
/// internal error rather than out-of-memory (Adreno's `ERROR_INITIALIZATION_FAILED`, #591),
|
||||
/// so both count. A failed allocation leaves an invalid texture whose use is then a
|
||||
/// validation error (`Texture::create_view`, #1319): caught here, it fails this frame only
|
||||
/// instead of retiring the GPU compositor for the session as an uncaptured error.
|
||||
/// Browser callers use the host/owned-device failure bridge and bounded readbacks instead
|
||||
/// of blocking their event loop on an error-scope future.
|
||||
pub fn within_memory<T>(&self, f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
self.ctx.check_health()?;
|
||||
pub fn within_memory<T>(&self, f: impl FnOnce() -> T) -> Result<T, FrameError> {
|
||||
self.ctx.check_health().map_err(FrameError::Retired)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let oom = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let internal = self.ctx.device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let validation = self.ctx.device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let out = f();
|
||||
let validation = pollster::block_on(validation.pop());
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
let oom = pollster::block_on(oom.pop());
|
||||
match internal.or(oom) {
|
||||
Some(e) => {
|
||||
self.ctx.clear_uploads();
|
||||
Err(e.to_string())
|
||||
}
|
||||
None => self.ctx.check_health().map(|()| out),
|
||||
let memory = oom.is_some() || internal.is_some();
|
||||
// The allocation failure first: a validation error is often its consequence.
|
||||
let errors: Vec<String> = [oom, internal, validation].into_iter().flatten().map(|e| e.to_string()).collect();
|
||||
if errors.is_empty() {
|
||||
return self.ctx.check_health().map(|()| out).map_err(FrameError::Retired);
|
||||
}
|
||||
// Its textures go back to the pool when dropped: drop them before emptying it.
|
||||
drop(out);
|
||||
self.ctx.discard_kept();
|
||||
let errors = errors.join("; then: ");
|
||||
Err(if memory { FrameError::Memory(errors) } else { FrameError::Validation(errors) })
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let out = f();
|
||||
self.ctx.check_health().map(|()| out)
|
||||
self.ctx.check_health().map(|()| out).map_err(FrameError::Retired)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,13 @@ pub(crate) struct Checkpoints {
|
||||
clock: u64,
|
||||
}
|
||||
|
||||
impl Checkpoints {
|
||||
/// Forget every kept state (after a failed frame, whose buffers may be invalid).
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.list.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn pipes(g: &GpuContext) -> &Pipes {
|
||||
g.particles.get_or_init(|| {
|
||||
let d = &g.device;
|
||||
|
||||
@@ -238,7 +238,7 @@ fn dst_fbm(u: f32, v: f32, z: f32, seed: u32) -> f32 {
|
||||
if (o + 1u == n && frac > 0.0) {
|
||||
w = frac;
|
||||
}
|
||||
sum += value_noise(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w;
|
||||
sum += value_noise_linear_z(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w;
|
||||
norm += amp * w;
|
||||
amp *= falloff;
|
||||
f *= 2.0;
|
||||
|
||||
@@ -525,12 +525,25 @@ fn fade(t: f32) -> f32 {
|
||||
}
|
||||
|
||||
fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 {
|
||||
return value_noise_z(x, y, z, seed, true);
|
||||
}
|
||||
|
||||
// effects::generate::value_noise_linear_z: z (evolution) interpolates linearly, x and y keep the
|
||||
// quintic fade (Turbulent Displace, #943).
|
||||
fn value_noise_linear_z(x: f32, y: f32, z: f32, seed: u32) -> f32 {
|
||||
return value_noise_z(x, y, z, seed, false);
|
||||
}
|
||||
|
||||
fn value_noise_z(x: f32, y: f32, z: f32, seed: u32, ease_z: bool) -> f32 {
|
||||
let x0 = floor(x);
|
||||
let y0 = floor(y);
|
||||
let z0 = floor(z);
|
||||
let fx = fade(x - x0);
|
||||
let fy = fade(y - y0);
|
||||
let fz = fade(z - z0);
|
||||
var fz = z - z0;
|
||||
if (ease_z) {
|
||||
fz = fade(fz);
|
||||
}
|
||||
let ix = i32(x0);
|
||||
let iy = i32(y0);
|
||||
let iz = i32(z0);
|
||||
|
||||
@@ -864,6 +864,71 @@ fn within_memory_returns_the_work_when_there_is_room() {
|
||||
assert_eq!((img.width, img.height), (64, 32));
|
||||
}
|
||||
|
||||
/// A texture the device rejected (deliberately too wide), made outside any frame under a scope
|
||||
/// of its own: the view a frame then makes of it is the reported validation error.
|
||||
fn rejected_texture(g: &Gpu) -> wgpu::Texture {
|
||||
let scope = g.ctx.device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let too_wide = g.ctx.device.limits().max_texture_dimension_2d.saturating_add(1);
|
||||
let t = g.ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("deliberately too wide"),
|
||||
size: wgpu::Extent3d { width: too_wide, height: 1, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
assert!(pollster::block_on(scope.pop()).is_some(), "the device rejected the texture");
|
||||
t
|
||||
}
|
||||
|
||||
/// A frame that makes a view of an invalid texture ("Texture with '' label is invalid", #1319)
|
||||
/// fails alone: the validation error doesn't reach the device's handler (which retires the
|
||||
/// compositor for the session, and panics in tests), and the next frame composites on the GPU,
|
||||
/// read back, like the CPU.
|
||||
#[test]
|
||||
fn a_view_of_an_invalid_texture_fails_the_frame_not_the_compositor() {
|
||||
hold_gpu_lock();
|
||||
let Some(g) = Gpu::headless() else { return };
|
||||
let rejected = rejected_texture(&g);
|
||||
let err = g.within_memory(|| rejected.create_view(&Default::default())).expect_err("the frame fails");
|
||||
assert!(matches!(&err, crate::FrameError::Validation(e) if e.contains("deliberately too wide")), "{err:?}");
|
||||
assert!(g.ctx.check_health().is_ok(), "the compositor stays on: {:?}", g.ctx.check_health());
|
||||
let s = blend_scene(BitDepth::Bpc32, BlendMode::Normal);
|
||||
let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() }).comp_frame_cpu(s.cid, Tick::ZERO);
|
||||
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
|
||||
r.accel = Some(&g);
|
||||
let img = g.within_memory(|| g.render(&r, s.cid, Tick::ZERO)).expect("the next frame isn't rejected").expect("the GPU renders it");
|
||||
let mut d = diff(&cpu, &img, tolerance(BitDepth::Bpc32));
|
||||
d.quantized = false;
|
||||
check("after a rejected frame", Some(d), 0.0);
|
||||
}
|
||||
|
||||
/// No texture of a failed frame is reused: not one released while another encoder is still
|
||||
/// open (it waits for that encoder), nor one taken before the failure and handed back after.
|
||||
#[test]
|
||||
fn a_failed_frame_returns_no_texture_to_the_pool() {
|
||||
hold_gpu_lock();
|
||||
let Some(g) = Gpu::headless() else { return };
|
||||
let rejected = rejected_texture(&g);
|
||||
let earlier = g.within_memory(|| crate::context::Enc::new(&g.ctx).image(48, 16)).expect("an earlier frame");
|
||||
let mut other = crate::context::Enc::new(&g.ctx);
|
||||
other.encoder();
|
||||
g.within_memory(|| (crate::context::Enc::new(&g.ctx).image(64, 32), rejected.create_view(&Default::default()))).expect_err("the frame fails");
|
||||
drop(earlier);
|
||||
drop(other);
|
||||
let (hits, _) = g.ctx.pool_stats();
|
||||
let (a, b) = g
|
||||
.within_memory(|| {
|
||||
let mut e = crate::context::Enc::new(&g.ctx);
|
||||
(e.image(64, 32), e.image(48, 16))
|
||||
})
|
||||
.expect("the next frame");
|
||||
assert_eq!(((a.width, a.height), (b.width, b.height)), ((64, 32), (48, 16)));
|
||||
assert_eq!(g.ctx.pool_stats().0, hits, "no texture of the failed frame was reused");
|
||||
}
|
||||
|
||||
/// A buffer whose allocation fails (here: over the device's buffer size limit; on Adreno an
|
||||
/// internal `ERROR_INITIALIZATION_FAILED`) is reported to the caller's error scope instead of
|
||||
/// panicking in `create_buffer_init`'s mapped range on a frame thread (#591).
|
||||
|
||||
@@ -55,6 +55,11 @@ fn turbulent_displace_and_cc_lens() {
|
||||
],
|
||||
);
|
||||
effect_case("ec.distort.turbulentdisplace", &[("displacement", e(8)), ("size", n(15.0)), ("pinning", e(6))]);
|
||||
// #943: Evolution moves the pattern at a constant rate on both paths, including steps of a
|
||||
// few degrees and angles past a whole revolution, where an eased evolution axis diverges.
|
||||
for evolution in [10.0, 37.0, 400.0, 725.5] {
|
||||
effect_case("ec.distort.turbulentdisplace", &[("evolution", n(evolution)), ("size", n(12.0)), ("amount", n(30.0))]);
|
||||
}
|
||||
effect_case("ec.distort.cclens", &[("size", n(70.0))]);
|
||||
effect_case("ec.distort.cclens", &[("size", n(45.0)), ("convergence", n(60.0)), ("center", Value::Vec2([30.0, 20.0]))]);
|
||||
effect_case("ec.distort.cclens", &[("convergence", n(-70.0))]);
|
||||
|
||||
@@ -132,12 +132,36 @@ fn ffmpeg_available() -> bool {
|
||||
Command::new("ffmpeg").arg("-version").output().map(|o| o.status.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `true` when this ffmpeg/ffprobe build actually ships an HEVC decoder. Distro "free" builds
|
||||
/// (e.g. Fedora's ffmpeg-free) ship without one — the tools run but cannot decode our stream, so
|
||||
/// the external oracle means nothing and the test skips rather than failing on a gap in the tool.
|
||||
fn hevc_decoder_available() -> bool {
|
||||
Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-decoders"])
|
||||
.output()
|
||||
.map(|o| {
|
||||
String::from_utf8_lossy(&o.stdout).lines().any(|l| {
|
||||
// Decoder rows look like ` V....D hevc ...`; match the codec name, not the text.
|
||||
// Every row of `-decoders` is a decoder, so the flags column says nothing about
|
||||
// that — its trailing `D` is "supports direct rendering method 1", which a
|
||||
// build's hevc decoder may well lack (requiring it would skip a working oracle).
|
||||
let mut it = l.split_whitespace();
|
||||
it.next().is_some() && it.next() == Some("hevc")
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Decode the Annex-B stream with ffmpeg and require bit-exact equality and an empty stderr.
|
||||
fn check_ffmpeg(run: &Run, w: usize, h: usize, bd: u32, tag: &str) {
|
||||
if !ffmpeg_available() {
|
||||
eprintln!("ffmpeg not found; skipping the ffmpeg oracle");
|
||||
return;
|
||||
}
|
||||
if !hevc_decoder_available() {
|
||||
eprintln!("ffmpeg has no HEVC decoder here; skipping the ffmpeg oracle");
|
||||
return;
|
||||
}
|
||||
let dir = std::env::temp_dir();
|
||||
let uniq = format!("{}-{}-{}", tag, std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
|
||||
let inp = dir.join(format!("hevcenc-{uniq}.hevc"));
|
||||
@@ -197,6 +221,10 @@ fn ffprobe_stream_info() {
|
||||
if !ffmpeg_available() {
|
||||
return;
|
||||
}
|
||||
if !hevc_decoder_available() {
|
||||
eprintln!("ffmpeg has no HEVC decoder here; ffprobe cannot fully describe our stream, skipping");
|
||||
return;
|
||||
}
|
||||
let (w, h) = (130, 72);
|
||||
let pics: Vec<Pic> = (0..2).map(|i| synth(w, h, 10, i, 0)).collect();
|
||||
let mut c = cfg(w as u32, h as u32, 30, 30, Profile::Main10);
|
||||
|
||||
@@ -6,6 +6,7 @@ mod determinism;
|
||||
mod exr_layers;
|
||||
mod footage_reload;
|
||||
mod gif_animates;
|
||||
mod pick_whip;
|
||||
mod plugins;
|
||||
mod relink;
|
||||
mod tga_import;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! The pick whip's references read the property they were made from (#1072): an Alt pick whip's
|
||||
//! (`byIndex`) select an effect's properties by index, `effect("Fractal Noise")(i)(j)`, and must
|
||||
//! read the same value as the reference by name.
|
||||
//!
|
||||
//! (With Compact English off, a reference to another layer, `thisComp.layer("Other")("effects")…`,
|
||||
//! doesn't evaluate yet, by name or by index: "not a callable function".)
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn index_references_read_the_same_values_as_names() {
|
||||
let mut s = effectcraft_host::session();
|
||||
s.execute("comp.new", json!({"name": "T", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Reader", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for fx in ["Fractal Noise", "Point Control"] {
|
||||
s.execute("effect.apply", json!({"layer": o, "effect": fx})).unwrap();
|
||||
}
|
||||
s.execute("prop.set", json!({"layer": o, "path": "effects/#1/transform/scale", "value": 77})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": o, "path": "effects/#2/point", "value": [12, 34]})).unwrap();
|
||||
let evaluated = |s: &mut effectcraft_engine::Session, path: &str| -> Value {
|
||||
let v = s.execute("prop.get", json!({"layer": l, "path": path})).unwrap();
|
||||
assert!(v.get("expressionError").is_none(), "{v}");
|
||||
v["evaluated"].clone()
|
||||
};
|
||||
for (path, target, dimension, want) in
|
||||
[("transform/rotation", "effects/#1/transform/scale", None, json!(77.0)), ("transform/opacity", "effects/#2/point", Some(1), json!(34.0))]
|
||||
{
|
||||
let mut t = json!({"layer": o, "path": target});
|
||||
if let Some(d) = dimension {
|
||||
t["dimension"] = json!(d);
|
||||
}
|
||||
for by_index in [false, true] {
|
||||
let e = s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": t, "byIndex": by_index})).unwrap();
|
||||
assert_eq!(evaluated(&mut s, path), want, "by index {by_index}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1218,8 +1218,8 @@ mod tests {
|
||||
assert_eq!(px(&f, FloatCurve::Gamma24), g24);
|
||||
}
|
||||
|
||||
/// A model's material library is read beside it; one named by a path to another computer
|
||||
/// is not read.
|
||||
/// A model's material library is read beside it; one named by a path to another computer is
|
||||
/// looked up under its own file name beside the model, never fetched from that machine (#1274).
|
||||
#[test]
|
||||
fn model_files_on_other_computers_are_not_read() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-model-files-{}", std::process::id()));
|
||||
@@ -1233,11 +1233,13 @@ mod tests {
|
||||
model.materials.iter().any(|m| m.base_color[..3] == [1.0, 0.0, 0.0])
|
||||
};
|
||||
assert!(red("beside.obj", "red.mtl"));
|
||||
assert!(!red("unc.obj", r"\\192.0.2.1\share\red.mtl"));
|
||||
assert!(!red("url.obj", "file://192.0.2.1/s/red.mtl"));
|
||||
// 192.0.2.1 is a test-net address nothing here can reach, so these passes prove the
|
||||
// material library came from beside the model, not from another computer.
|
||||
assert!(red("unc.obj", r"\\192.0.2.1\share\red.mtl"));
|
||||
assert!(red("url.obj", "file://192.0.2.1/s/red.mtl"));
|
||||
// Linux reads `//dir/red.mtl` as the local file.
|
||||
#[cfg(unix)]
|
||||
assert!(!red("slashes.obj", &format!("/{}", dir.join("red.mtl").display())));
|
||||
assert!(red("slashes.obj", &format!("/{}", dir.join("red.mtl").display())));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,40 @@ fn have_ffmpeg() -> bool {
|
||||
*HAVE.get_or_init(|| Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()))
|
||||
}
|
||||
|
||||
/// An H.264 encoder this ffmpeg build actually ships, or `None` when it has none. Distro "free"
|
||||
/// builds often omit `libx264` (the one named historically here) but ship an alternative, so pick
|
||||
/// the first present by name rather than hard-coding one that may be absent. These tests only
|
||||
/// need *an* H.264 encoder to make an oracle fixture; the encoder identity is irrelevant.
|
||||
fn h264_encoder() -> Option<&'static str> {
|
||||
static ENC: std::sync::OnceLock<Option<&'static str>> = std::sync::OnceLock::new();
|
||||
*ENC.get_or_init(|| {
|
||||
let out = Command::new("ffmpeg").args(["-hide_banner", "-encoders"]).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
["libx264", "libopenh264", "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_v4l2m2m", "h264_amf"]
|
||||
.into_iter()
|
||||
.find(|name| text.lines().any(|l| l.split_whitespace().any(|tok| tok == *name)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Run ffmpeg to produce `name` (once; written to a temp name and renamed so concurrent test
|
||||
/// processes never see a partial file). `None` when ffmpeg is unavailable.
|
||||
fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option<PathBuf> {
|
||||
ffmpeg_fixture_inner(name, args, true)
|
||||
}
|
||||
|
||||
/// Like [`ffmpeg_fixture`] for a fixture this build of ffmpeg may fail to make: an encoder found
|
||||
/// in the `-encoders` listing can still not run here (the hardware backends need their device,
|
||||
/// Fedora's `libopenh264` is the `noopenh264` stub), and fixtures built from one, like
|
||||
/// [`h264_mp4`], should skip with a note rather than fail on the packager's choice of ffmpeg —
|
||||
/// the same way every test here skips when ffmpeg is missing altogether.
|
||||
fn try_ffmpeg_fixture(name: &str, args: &[&str]) -> Option<PathBuf> {
|
||||
ffmpeg_fixture_inner(name, args, false)
|
||||
}
|
||||
|
||||
fn ffmpeg_fixture_inner(name: &str, args: &[&str], must_succeed: bool) -> Option<PathBuf> {
|
||||
if !have_ffmpeg() {
|
||||
eprintln!("ffmpeg not found: skipping");
|
||||
return None;
|
||||
@@ -39,7 +70,14 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option<PathBuf> {
|
||||
let ext = out.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let tmp = out.with_file_name(format!("tmp-{}-{:?}.{ext}", std::process::id(), std::thread::current().id()).replace(['(', ')'], ""));
|
||||
let st = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).arg(&tmp).status().expect("run ffmpeg");
|
||||
assert!(st.success(), "ffmpeg failed for {name}");
|
||||
if !st.success() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
if must_succeed {
|
||||
panic!("ffmpeg failed for {name}");
|
||||
}
|
||||
eprintln!("this ffmpeg build cannot make {name}: skipping");
|
||||
return None;
|
||||
}
|
||||
if std::fs::rename(&tmp, &out).is_err() {
|
||||
// Another test made it first; on Windows the rename fails while that test has it open.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
@@ -48,39 +86,35 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option<PathBuf> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Video options the chosen H.264 encoder actually accepts. `libx264` keeps its constant-quality
|
||||
/// `-preset`/`-crf` pair; `libopenh264` rejects both (it takes a bitrate), and the hardware
|
||||
/// backends take a bitrate too — without this, a build whose picked encoder is not libx264 failed
|
||||
/// the fixture and silently skipped every check gated on it.
|
||||
fn h264_video_options(enc: &str) -> Vec<&'static str> {
|
||||
if enc == "libx264" { vec!["-preset", "medium", "-crf", "18"] } else { vec!["-b:v", "2M"] }
|
||||
}
|
||||
|
||||
fn h264_mp4() -> Option<PathBuf> {
|
||||
ffmpeg_fixture(
|
||||
"testsrc2_1080p.mp4",
|
||||
&[
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=1920x1080:rate=30:duration=2",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:sample_rate=48000:duration=2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-crf",
|
||||
"18",
|
||||
"-colorspace",
|
||||
"bt709",
|
||||
"-color_primaries",
|
||||
"bt709",
|
||||
"-color_trc",
|
||||
"bt709",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-ac",
|
||||
"2",
|
||||
"-shortest",
|
||||
],
|
||||
)
|
||||
// Made with whatever encoder this build has listed: a listed encoder can still fail at run
|
||||
// time (the hardware ones without their device), so this may skip rather than fail.
|
||||
let enc = h264_encoder()?;
|
||||
let mut args: Vec<&'static str> = vec![
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=1920x1080:rate=30:duration=2",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:sample_rate=48000:duration=2",
|
||||
"-c:v",
|
||||
enc,
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
];
|
||||
args.extend(h264_video_options(enc));
|
||||
args.extend(["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-c:a", "aac", "-ac", "2", "-shortest"]);
|
||||
try_ffmpeg_fixture("testsrc2_1080p.mp4", &args)
|
||||
}
|
||||
|
||||
fn prores_mov() -> Option<PathBuf> {
|
||||
@@ -281,7 +315,10 @@ fn h264_audio_samples() {
|
||||
/// H.264 + AAC at 44.1 kHz in MP4 (ffmpeg writes an edit list that skips the AAC priming):
|
||||
/// 440 Hz left, a 200 → 1000 Hz sweep right, 29.97 fps.
|
||||
fn aac_44k_mp4() -> Option<PathBuf> {
|
||||
ffmpeg_fixture(
|
||||
// Made with whatever encoder this build has listed: a listed encoder can still fail at run
|
||||
// time (the hardware ones without their device), so this may skip rather than fail.
|
||||
let enc = h264_encoder()?;
|
||||
try_ffmpeg_fixture(
|
||||
"aac_44k.mp4",
|
||||
&[
|
||||
"-f",
|
||||
@@ -293,7 +330,7 @@ fn aac_44k_mp4() -> Option<PathBuf> {
|
||||
"-i",
|
||||
"aevalsrc=0.5*sin(2*PI*440*t)|0.5*sin(2*PI*(200+400*t/3)*t):s=44100:d=3",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
enc,
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
@@ -1098,7 +1135,7 @@ fn a_called_off_read_does_not_decode() {
|
||||
/// footage reports it (not a black frame without a word).
|
||||
#[test]
|
||||
fn an_unsupported_stream_reports_why_it_shows_no_picture() {
|
||||
let Some(p) = ffmpeg_fixture(
|
||||
let Some(p) = try_ffmpeg_fixture(
|
||||
"h264_high422_10bit.mp4",
|
||||
&["-f", "lavfi", "-i", "testsrc=size=320x180:rate=24:duration=1", "-c:v", "libx264", "-profile:v", "high422", "-pix_fmt", "yuv422p10le"],
|
||||
) else {
|
||||
@@ -1122,3 +1159,13 @@ fn an_unsupported_stream_reports_why_it_shows_no_picture() {
|
||||
pool.forget(&f.path);
|
||||
assert_eq!(pool.problem(&f), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_h264_fixture_options_fit_the_picked_encoder() {
|
||||
assert_eq!(h264_video_options("libx264"), ["-preset", "medium", "-crf", "18"]);
|
||||
for enc in ["libopenh264", "h264_nvenc", "h264_vaapi", "h264_qsv"] {
|
||||
let options = h264_video_options(enc);
|
||||
assert!(!options.iter().any(|o| *o == "-preset" || *o == "-crf"), "{enc} rejects preset/crf: {options:?}");
|
||||
assert!(options.contains(&"-b:v"), "{enc} gets a bitrate: {options:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,26 @@ fn fixtures() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media")
|
||||
}
|
||||
|
||||
/// An H.264 encoder this ffmpeg build actually ships, or `None` when it has none (see the same
|
||||
/// helper in `footage.rs`: distro "free" builds often omit `libx264` and ship an alternative).
|
||||
fn h264_encoder() -> Option<&'static str> {
|
||||
static ENC: std::sync::OnceLock<Option<&'static str>> = std::sync::OnceLock::new();
|
||||
*ENC.get_or_init(|| {
|
||||
let out = Command::new("ffmpeg").args(["-hide_banner", "-encoders"]).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
["libx264", "libopenh264", "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_v4l2m2m", "h264_amf"]
|
||||
.into_iter()
|
||||
.find(|name| text.lines().any(|l| l.split_whitespace().any(|tok| tok == *name)))
|
||||
})
|
||||
}
|
||||
|
||||
/// A 2 s 320×240 H.264 + AAC movie (written to a temp name and renamed, so concurrent test
|
||||
/// processes never see a partial file). `None` without ffmpeg.
|
||||
/// processes never see a partial file). `None` without ffmpeg, or when this build cannot encode
|
||||
/// H.264 at all — the test only measures how a movie is read, so it skips rather than asserting
|
||||
/// on the packager's choice of ffmpeg.
|
||||
fn small_mp4() -> Option<PathBuf> {
|
||||
let out = fixtures().join("in_place_small.mp4");
|
||||
if out.exists() {
|
||||
@@ -29,16 +47,21 @@ fn small_mp4() -> Option<PathBuf> {
|
||||
if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) {
|
||||
return None;
|
||||
}
|
||||
let enc = h264_encoder()?;
|
||||
std::fs::create_dir_all(fixtures()).unwrap();
|
||||
let tmp = fixtures().join(format!("in_place_tmp_{}.mp4", std::process::id()));
|
||||
let st = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=2"])
|
||||
.args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2"])
|
||||
.args(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
|
||||
.args(["-c:v", enc, "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
|
||||
.arg(&tmp)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(st.success(), "ffmpeg failed");
|
||||
if !st.success() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
eprintln!("this ffmpeg build cannot make in_place_small.mp4: skipping");
|
||||
return None;
|
||||
}
|
||||
std::fs::rename(&tmp, &out).unwrap();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -14,5 +14,6 @@ effectcraft-geom = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
miniz_oxide = "0.9"
|
||||
|
||||
+72
-13
@@ -11,7 +11,7 @@
|
||||
//! `KHR_lights_punctual` lights (directional, point, spot). Sparse accessors and morph targets
|
||||
//! are ignored.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{cell::Cell, sync::Arc};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -114,6 +114,14 @@ fn load_uri(uri: &str, resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Vec<
|
||||
struct Doc<'a> {
|
||||
j: &'a Value,
|
||||
buffers: Vec<Vec<u8>>,
|
||||
/// Zero components that buffer-less accessors may still allocate, across the whole file:
|
||||
/// nothing backs them, so many references to one capped accessor must not multiply (#2139).
|
||||
zero_left: Cell<usize>,
|
||||
/// Components every accessor read may still allocate, across the whole file: each primitive
|
||||
/// that references an accessor keeps its own copy, so references must not multiply memory.
|
||||
read_left: Cell<usize>,
|
||||
/// The budget `read_left` started from, for the error message.
|
||||
read_cap: usize,
|
||||
}
|
||||
|
||||
fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] {
|
||||
@@ -128,10 +136,18 @@ fn indices(v: &Value, k: &str) -> Vec<usize> {
|
||||
arr(v, k).iter().filter_map(Value::as_u64).filter_map(|c| usize::try_from(c).ok()).collect()
|
||||
}
|
||||
|
||||
/// The most components an accessor without a buffer view (all zeros) may have: nothing in the
|
||||
/// file backs its count, so it is capped instead of checked against a buffer (#1235).
|
||||
/// The most components accessors without a buffer view (all zeros) may have, together: nothing
|
||||
/// in the file backs their counts, so they are capped instead of checked against a buffer
|
||||
/// (#1235, #2139).
|
||||
const MAX_ZERO_COMPONENTS: usize = 1 << 24;
|
||||
|
||||
/// The most components all accessor reads in one file may allocate together (about 1 GiB as
|
||||
/// `f32`/`u32`). Each primitive keeps its own copy of what it reads, so an accessor referenced
|
||||
/// N times costs N times; this bounds that by a fixed amount rather than by buffer size, because
|
||||
/// real assets legitimately share one vertex buffer across many primitives. A 10M-triangle scan
|
||||
/// (5M vertices with position, normal, UV and tangent, 30M indices) reads about 90M.
|
||||
const MAX_READ_COMPONENTS: usize = 1 << 28;
|
||||
|
||||
/// Where an accessor's elements lie in its buffer: `count` elements of `elem` bytes, `stride`
|
||||
/// apart from `base`, all checked to be inside `buf`.
|
||||
struct Span<'b> {
|
||||
@@ -152,7 +168,30 @@ fn nums<const N: usize>(v: &Value, k: &str, d: [f64; N]) -> [f64; N] {
|
||||
out
|
||||
}
|
||||
|
||||
/// Bytes of component `c` of element `i`, with checked offsets (the span was validated, but
|
||||
/// nothing derived from the file is indexed unchecked).
|
||||
fn component(buf: &[u8], base: usize, stride: usize, i: usize, c: usize, csize: usize) -> Result<&[u8]> {
|
||||
let o = i.checked_mul(stride).and_then(|o| o.checked_add(base)).and_then(|o| o.checked_add(c.checked_mul(csize)?));
|
||||
o.and_then(|o| buf.get(o..o.checked_add(csize)?)).ok_or_else(|| perr("accessor reads past its buffer"))
|
||||
}
|
||||
|
||||
impl Doc<'_> {
|
||||
/// Charge `total` components to the file's read budget before allocating them.
|
||||
fn charge(&self, total: usize) -> Result<()> {
|
||||
let left = self.read_left.get();
|
||||
let Some(rest) = left.checked_sub(total) else {
|
||||
let cap = self.read_cap;
|
||||
return Err(perr(format!("accessor reads need more than {cap} components in total ({total} requested, {left} left)")));
|
||||
};
|
||||
self.read_left.set(rest);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Charge `count` items of `per` components each.
|
||||
fn charge_each(&self, count: usize, per: usize) -> Result<()> {
|
||||
self.charge(count.checked_mul(per).ok_or_else(|| perr("primitive is too large"))?)
|
||||
}
|
||||
|
||||
/// The accessor's element count and total components (`count * n`), checked; and its span in
|
||||
/// the buffer, validated before anything is allocated from the count. `None` for an
|
||||
/// accessor without a buffer view, whose components are all zero.
|
||||
@@ -163,16 +202,19 @@ impl Doc<'_> {
|
||||
};
|
||||
let total = count.checked_mul(n).ok_or_else(|| perr("accessor count is too large"))?;
|
||||
let Some(bv) = idx(acc, "bufferView") else {
|
||||
if total > MAX_ZERO_COMPONENTS {
|
||||
return Err(perr(format!("accessor without a bufferView has {total} components (at most {MAX_ZERO_COMPONENTS})")));
|
||||
}
|
||||
let left = self.zero_left.get();
|
||||
let Some(rest) = left.checked_sub(total) else {
|
||||
return Err(perr(format!("accessors without a bufferView have more than {MAX_ZERO_COMPONENTS} components ({total} requested, {left} left)")));
|
||||
};
|
||||
self.zero_left.set(rest);
|
||||
self.charge(total)?;
|
||||
return Ok((count, total, None));
|
||||
};
|
||||
let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?;
|
||||
let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?;
|
||||
let past = || perr("accessor reads past its buffer");
|
||||
let base = idx(view, "byteOffset").unwrap_or(0).checked_add(idx(acc, "byteOffset").unwrap_or(0)).ok_or_else(past)?;
|
||||
let elem = csize * n;
|
||||
let elem = csize.checked_mul(n).ok_or_else(past)?;
|
||||
let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem);
|
||||
if stride < elem {
|
||||
return Err(perr(format!("bufferView byteStride {stride} is smaller than its {elem}-byte elements")));
|
||||
@@ -183,6 +225,7 @@ impl Doc<'_> {
|
||||
return Err(past());
|
||||
}
|
||||
}
|
||||
self.charge(total)?;
|
||||
Ok((count, total, Some(Span { buf, base, stride })))
|
||||
}
|
||||
|
||||
@@ -216,8 +259,7 @@ impl Doc<'_> {
|
||||
// Matrices of 1/2-byte components pad columns to 4 bytes; ignored (rare).
|
||||
for i in 0..count {
|
||||
for c in 0..n {
|
||||
let o = base + i * stride + c * csize;
|
||||
let s = buf.get(o..o + csize).ok_or_else(|| perr("accessor reads past its buffer"))?;
|
||||
let s = component(buf, base, stride, i, c, csize)?;
|
||||
out[i * n + c] = match ct {
|
||||
5126 => f32::from_le_bytes([s[0], s[1], s[2], s[3]]),
|
||||
5125 => u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f32,
|
||||
@@ -266,8 +308,7 @@ impl Doc<'_> {
|
||||
let mut out = Vec::with_capacity(total);
|
||||
for i in 0..count {
|
||||
for c in 0..n {
|
||||
let o = base + i * stride + c * csize;
|
||||
let s = buf.get(o..o + csize).ok_or_else(|| perr("accessor reads past its buffer"))?;
|
||||
let s = component(buf, base, stride, i, c, csize)?;
|
||||
out.push(match csize {
|
||||
1 => s[0] as u32,
|
||||
2 => u16::from_le_bytes([s[0], s[1]]) as u32,
|
||||
@@ -281,6 +322,11 @@ impl Doc<'_> {
|
||||
|
||||
/// Parse a `.gltf` (JSON) or `.glb` file.
|
||||
pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Model> {
|
||||
parse_with_budget(bytes, resolve, MAX_READ_COMPONENTS)
|
||||
}
|
||||
|
||||
/// [`parse`] with a read budget of `read_budget` accessor components for the whole file.
|
||||
pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>, read_budget: usize) -> Result<Model> {
|
||||
let (json, bin) = if bytes.starts_with(b"glTF") { split_glb(bytes)? } else { (bytes, None) };
|
||||
let j: Value = serde_json::from_slice(json).map_err(|e| perr(format!("glTF JSON: {e}")))?;
|
||||
let ver = j.get("asset").and_then(|a| a.get("version")).and_then(Value::as_str).unwrap_or("");
|
||||
@@ -302,7 +348,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
|
||||
};
|
||||
buffers.push(data);
|
||||
}
|
||||
let d = Doc { j: &j, buffers };
|
||||
let d = Doc { j: &j, buffers, zero_left: Cell::new(MAX_ZERO_COMPONENTS), read_left: Cell::new(read_budget), read_cap: read_budget };
|
||||
|
||||
// Images → textures (decoded once per image; samplers set the wrap modes).
|
||||
let mut images: Vec<Option<Arc<Texture>>> = vec![];
|
||||
@@ -569,7 +615,11 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result<Option<Primit
|
||||
};
|
||||
let raw: Vec<u32> = match idx(p, "indices") {
|
||||
Some(a) => d.ints(a)?,
|
||||
None => (0..n as u32).collect(),
|
||||
None => {
|
||||
// Generated indices are allocated like a read, once per referencing primitive.
|
||||
d.charge(n)?;
|
||||
(0..n as u32).collect()
|
||||
}
|
||||
};
|
||||
if raw.iter().any(|&i| i as usize >= n) {
|
||||
return Err(perr("index out of range"));
|
||||
@@ -589,11 +639,19 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result<Option<Primit
|
||||
}
|
||||
_ => indices.extend(raw.as_chunks::<3>().0.iter().flatten()),
|
||||
}
|
||||
// The triangle list is kept besides the raw indices (a strip or fan of k indices becomes
|
||||
// 3(k-2)), so it is charged like a read.
|
||||
d.charge(indices.len())?;
|
||||
if normals.len() != n {
|
||||
normals.clear();
|
||||
}
|
||||
let material = idx(p, "material").filter(|&m| m < materials.len());
|
||||
let skinned = joints.len() == n && weights.len() == n;
|
||||
if normals.is_empty() {
|
||||
// Flat normals unshare every vertex: position and normal, plus UVs and joints/weights.
|
||||
let per = 6 + if uvs.len() == n { 2 } else { 0 } + if skinned { 8 } else { 0 };
|
||||
d.charge_each(indices.len(), per)?;
|
||||
}
|
||||
let mut prim = Primitive {
|
||||
positions,
|
||||
normals,
|
||||
@@ -610,6 +668,7 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result<Option<Primit
|
||||
}
|
||||
let needs_tangents = material.is_some_and(|m| materials[m].normal_tex.is_some());
|
||||
if needs_tangents && prim.tangents.is_empty() {
|
||||
d.charge_each(prim.positions.len(), 4)?;
|
||||
prim.compute_tangents();
|
||||
}
|
||||
Ok(Some(prim))
|
||||
|
||||
+47
-8
@@ -14,18 +14,52 @@ use std::sync::Arc;
|
||||
|
||||
use crate::{AlphaMode, Material, Mesh, Model, Primitive, Result, TexRef, Texture, perr};
|
||||
|
||||
/// The last component of a referenced path, treating `\` as a separator too: a path from a
|
||||
/// machine of another kind (`C:\textures\checker.png`) still finds `checker.png` next to the
|
||||
/// model (#1274). `None` when the name carries no path at all.
|
||||
fn sibling_name(name: &str) -> Option<&str> {
|
||||
let file = name.rsplit(['/', '\\']).next()?;
|
||||
(!file.is_empty() && file.len() < name.len()).then_some(file)
|
||||
}
|
||||
|
||||
/// A material reference resolved against the model's folder: the name as written (a name
|
||||
/// containing spaces is one name), then its file name (an absolute path from another machine,
|
||||
/// #1274), then the final token (the options convention, `-bm 1 file.png`).
|
||||
fn resolve_reference(resolve: &dyn Fn(&str) -> Option<Vec<u8>>, name: &str) -> Option<Vec<u8>> {
|
||||
let mut tried: Vec<&str> = vec![];
|
||||
for candidate in [name, sibling_name(name).unwrap_or(name), name.split_whitespace().last().unwrap_or(name)] {
|
||||
if tried.contains(&candidate) {
|
||||
continue;
|
||||
}
|
||||
tried.push(candidate);
|
||||
if let Some(b) = resolve(candidate) {
|
||||
return Some(b);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse MTL text into named materials (textures appended to `textures`).
|
||||
pub fn parse_mtl(text: &str, resolve: &dyn Fn(&str) -> Option<Vec<u8>>, textures: &mut Vec<Arc<Texture>>) -> Vec<Material> {
|
||||
let mut out: Vec<Material> = vec![];
|
||||
let mut loaded: HashMap<String, Option<usize>> = HashMap::new();
|
||||
let mut tex = |name: &str, textures: &mut Vec<Arc<Texture>>| -> Option<TexRef> {
|
||||
// Options (`-bm 1 file.png`): the file name is the last token.
|
||||
let file = name.split_whitespace().last()?.to_string();
|
||||
let t = *loaded.entry(file.clone()).or_insert_with(|| {
|
||||
let b = resolve(&file)?;
|
||||
let t = Texture::decode(&b).ok()?;
|
||||
textures.push(Arc::new(t));
|
||||
Some(textures.len() - 1)
|
||||
let key = name.trim().to_string();
|
||||
let t = *loaded.entry(key.clone()).or_insert_with(|| {
|
||||
let Some(b) = resolve_reference(resolve, &key) else {
|
||||
log::warn!("model: texture `{key}` was not found next to the model");
|
||||
return None;
|
||||
};
|
||||
match Texture::decode(&b) {
|
||||
Ok(t) => {
|
||||
textures.push(Arc::new(t));
|
||||
Some(textures.len() - 1)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("model: cannot decode texture `{key}`: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
t.map(|texture| TexRef { texture, uv_set: 0 })
|
||||
};
|
||||
@@ -147,13 +181,18 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
|
||||
}
|
||||
"mtllib" => {
|
||||
let name = line["mtllib".len()..].trim();
|
||||
for file in std::iter::once(name).chain(name.split_whitespace()) {
|
||||
let mut found = false;
|
||||
for file in std::iter::once(name).chain(name.split_whitespace()).chain(sibling_name(name)) {
|
||||
if let Some(b) = resolve(file) {
|
||||
let mut ms = parse_mtl(&String::from_utf8_lossy(&b), resolve, &mut textures);
|
||||
materials.append(&mut ms);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found && !name.is_empty() {
|
||||
log::warn!("model: material library `{name}` was not found next to the model");
|
||||
}
|
||||
}
|
||||
"usemtl" => {
|
||||
let name = line["usemtl".len()..].trim();
|
||||
|
||||
@@ -119,6 +119,92 @@ fn obj_without_normals_gets_them() {
|
||||
assert!(obj::parse(b"v 0 0 0\n", &|_| None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obj_textures_from_another_machine_find_the_file_next_to_the_model() {
|
||||
// No committed media: the material file and its textures are written by the test itself.
|
||||
let d = std::env::temp_dir().join(format!("ec-obj-sibling-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let png = {
|
||||
let img = image::RgbaImage::from_fn(2, 2, |x, y| if (x + y) % 2 == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) });
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
std::fs::write(d.join("checker.png"), &png).unwrap();
|
||||
std::fs::write(d.join("Diffuse Map.png"), &png).unwrap();
|
||||
let mtl = "newmtl Mat\nKd 0.640000 0.640000 0.640000\nmap_Kd C:\\Users\\somebody\\Desktop\\textures\\checker.png\n\
|
||||
newmtl Spaced\nKd 1.000000 1.000000 1.000000\nmap_Kd Diffuse Map.png\n\
|
||||
newmtl Missing\nKd 0.500000 0.500000 0.500000\nmap_Kd nowhere.png\n";
|
||||
let obj = "mtllib quad.mtl\nv -1 -1 0\nv 1 -1 0\nv 1 1 0\nv -1 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\
|
||||
usemtl Mat\nf 1/1 2/2 3/3\nf 1/1 3/3 4/4\n";
|
||||
// Like the media layer's resolver: a relative name joins the model's folder; an absolute
|
||||
// path is only looked up where it points (here: nowhere else).
|
||||
let resolve = |uri: &str| -> Option<Vec<u8>> {
|
||||
let p = std::path::Path::new(uri);
|
||||
if p.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
std::fs::read(d.join(p)).ok()
|
||||
};
|
||||
let resolver = |uri: &str| -> Option<Vec<u8>> { if uri == "quad.mtl" { Some(mtl.as_bytes().to_vec()) } else { resolve(uri) } };
|
||||
let m = obj::parse(obj.as_bytes(), &resolver).unwrap();
|
||||
assert_eq!(m.materials.len(), 3);
|
||||
let mat = &m.materials[0];
|
||||
let t = mat.base_color_tex.expect("the absolute map_Kd path finds checker.png next to the .obj");
|
||||
let tex = &m.textures[t.texture];
|
||||
assert_eq!((tex.width, tex.height), (2, 2));
|
||||
assert_eq!(tex.data[0], [1.0, 0.0, 0.0, 1.0]);
|
||||
assert!((mat.base_color[0] - obj::srgb_to_linear(0.64)).abs() < 1e-6, "Kd still tints the texture");
|
||||
assert!(m.materials[1].base_color_tex.is_some(), "a texture name containing spaces is one name");
|
||||
assert!(m.materials[2].base_color_tex.is_none(), "a genuinely missing texture keeps the Kd colour");
|
||||
assert!((m.materials[2].base_color[0] - obj::srgb_to_linear(0.5)).abs() < 1e-6);
|
||||
// The same fallback applies to the material library itself.
|
||||
let here = obj.replace("mtllib quad.mtl", "mtllib C:\\build\\quad.mtl");
|
||||
assert_eq!(obj::parse(here.as_bytes(), &resolver).unwrap().materials.len(), 3);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obj_texture_prefers_the_complete_name_over_its_final_token() {
|
||||
// Two textures sit beside the model: `Map.png` (the final whitespace token of the reference)
|
||||
// and `Diffuse Map.png` (the complete name). The complete name must win, its basename next,
|
||||
// the final token last (the `-bm 1 file.png` options convention).
|
||||
let d = std::env::temp_dir().join(format!("ec-obj-full-name-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let png = |w: u32| {
|
||||
let img = image::RgbaImage::from_fn(w, 2, |_, _| image::Rgba([255, 0, 0, 255]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
std::fs::write(d.join("Map.png"), png(2)).unwrap();
|
||||
std::fs::write(d.join("Diffuse Map.png"), png(4)).unwrap();
|
||||
let mtl = "newmtl Spaced\nmap_Kd Diffuse Map.png\n\
|
||||
newmtl Absolute\nmap_Kd C:\\tex\\Diffuse Map.png\n\
|
||||
newmtl Options\nmap_Kd -bm 1 Map.png\n";
|
||||
let obj = "mtllib quad.mtl\nv -1 -1 0\nv 1 -1 0\nv 1 1 0\nv -1 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\
|
||||
usemtl Spaced\nf 1/1 2/2 3/3\nf 1/1 3/3 4/4\n";
|
||||
// Like the media layer's resolver: a relative name joins the model's folder; an absolute
|
||||
// path is only looked up where it points (here: nowhere else).
|
||||
let resolve = |uri: &str| -> Option<Vec<u8>> {
|
||||
let p = std::path::Path::new(uri);
|
||||
if p.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
std::fs::read(d.join(p)).ok()
|
||||
};
|
||||
let resolver = |uri: &str| -> Option<Vec<u8>> { if uri == "quad.mtl" { Some(mtl.as_bytes().to_vec()) } else { resolve(uri) } };
|
||||
let m = obj::parse(obj.as_bytes(), &resolver).unwrap();
|
||||
assert_eq!(m.materials.len(), 3);
|
||||
let size = |mat: &Material| mat.base_color_tex.map(|t| (m.textures[t.texture].width, m.textures[t.texture].height));
|
||||
assert_eq!(size(&m.materials[0]), Some((4, 2)), "the complete `Diffuse Map.png` wins over its final token `Map.png`");
|
||||
assert_eq!(size(&m.materials[1]), Some((4, 2)), "an absolute path's spaced basename beats its final token");
|
||||
assert_eq!(size(&m.materials[2]), Some((2, 2)), "the `-bm 1 file.png` options convention still finds the final token");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_interpolation_modes() {
|
||||
let mut c = Channel { node: 0, path: ChannelPath::Translation, interp: Interp::Step, times: vec![0.0, 1.0], values: vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0] };
|
||||
@@ -564,3 +650,84 @@ fn gltf_strided_and_buffer_less_accessors_still_import() {
|
||||
let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 59);
|
||||
assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gltf_issue_2139_huge_mat4_count_is_an_error() {
|
||||
// The audit's trigger (#2139): a MAT4 accessor with a count of 10^11, with and without a
|
||||
// buffer view, must fail cleanly instead of sizing an allocation from the count.
|
||||
let view = r#"{"buffer": 0, "byteLength": 64}"#;
|
||||
for acc in [
|
||||
r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 100000000000}"#,
|
||||
r#"{"componentType": 5126, "type": "MAT4", "count": 100000000000}"#,
|
||||
] {
|
||||
let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 1}"#, view, 64)
|
||||
.replace(r#""count": 1}]"#, &format!(r#""count": 1}}, {acc}]"#))
|
||||
.replace(r#""nodes": [{"mesh": 0}]"#, r#""nodes": [{"mesh": 0, "skin": 0}], "skins": [{"joints": [0], "inverseBindMatrices": 1}]"#);
|
||||
assert!(doc.contains("inverseBindMatrices"), "{doc}");
|
||||
assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err(), "{acc}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gltf_buffer_less_accessors_share_one_zero_budget() {
|
||||
// Each buffer-less accessor is capped, but nothing backs any of them, so referencing a capped
|
||||
// one from many primitives must not multiply the allocation (#2139).
|
||||
let acc = r#"{"componentType": 5126, "type": "VEC3", "count": 5000000}"#;
|
||||
let prims = [r#"{"attributes": {"POSITION": 0}}"#; 4].join(", ");
|
||||
let doc = gltf_doc(acc, r#"{"buffer": 0}"#, 3).replace(r#"[{"attributes": {"POSITION": 0}}]"#, &format!("[{prims}]"));
|
||||
assert_eq!(doc.matches(r#""POSITION": 0"#).count(), 4);
|
||||
let e = crate::gltf::parse(doc.as_bytes(), &|_| None).err().unwrap_or_else(|| panic!("imported"));
|
||||
assert!(e.to_string().contains("without a bufferView"), "{e}");
|
||||
// One such accessor read once still imports.
|
||||
let doc = gltf_doc(acc, r#"{"buffer": 0}"#, 3);
|
||||
assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_ok());
|
||||
}
|
||||
|
||||
/// `refs` primitives sharing one buffer-backed `verts`-vertex POSITION accessor, without indices.
|
||||
fn gltf_shared_positions(verts: usize, refs: usize) -> String {
|
||||
let acc = format!(r#"{{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": {verts}}}"#);
|
||||
let prims = vec![r#"{"attributes": {"POSITION": 0}}"#; refs].join(", ");
|
||||
gltf_doc(&acc, r#"{"buffer": 0}"#, verts * 12).replace(r#"[{"attributes": {"POSITION": 0}}]"#, &format!("[{prims}]"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gltf_buffer_backed_reads_share_one_read_budget() {
|
||||
// A buffer-backed accessor is checked against its buffer, but each primitive that references
|
||||
// it reads and keeps its own copy, so a small file listing many references must not ask for
|
||||
// memory set by the reference count (#2139 review). Everything a primitive keeps is charged
|
||||
// to one per-file budget: 1,000 vertices without indices or normals cost 3,000 position
|
||||
// components, 1,000 generated indices, a 999-index triangle list and 999 flat-shaded
|
||||
// vertices of 6 components, 10,993 in all.
|
||||
let one = gltf_shared_positions(1000, 1);
|
||||
let m = crate::gltf::parse_with_budget(one.as_bytes(), &|_| None, 20_000).unwrap();
|
||||
assert_eq!(m.meshes[0].primitives.len(), 1);
|
||||
let three = gltf_shared_positions(1000, 3);
|
||||
assert_eq!(three.matches(r#""POSITION": 0"#).count(), 3);
|
||||
let e = crate::gltf::parse_with_budget(three.as_bytes(), &|_| None, 20_000).err().unwrap_or_else(|| panic!("imported"));
|
||||
assert!(e.to_string().contains("more than 20000 components"), "{e}");
|
||||
// The real cap leaves room for legitimate sharing: many primitives over one vertex buffer.
|
||||
let m = crate::gltf::parse(gltf_shared_positions(1000, 50).as_bytes(), &|_| None).unwrap();
|
||||
assert_eq!(m.meshes[0].primitives.len(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gltf_missing_indices_are_charged_to_the_read_budget() {
|
||||
// Without `indices` every vertex gets a generated index, allocated like a read: after the
|
||||
// 3,000 position components, 999 left is one short for the 1,000 indices.
|
||||
let doc = gltf_shared_positions(1000, 1);
|
||||
let e = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 3999).err().unwrap_or_else(|| panic!("imported"));
|
||||
assert!(e.to_string().contains("(1000 requested, 999 left)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gltf_strip_expansion_is_charged_to_the_read_budget() {
|
||||
// A strip of k indices becomes 3(k-2) triangle indices and as many flat-shaded vertices, so
|
||||
// the output is charged, not only the 1,009 components read.
|
||||
let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0}"#, 1036)
|
||||
.replace(r#""count": 3}]"#, r#""count": 3}, {"bufferView": 0, "componentType": 5121, "type": "SCALAR", "count": 1000}]"#)
|
||||
.replace(r#""attributes": {"POSITION": 0}"#, r#""attributes": {"POSITION": 0}, "indices": 1, "mode": 5"#);
|
||||
let m = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 30_000).unwrap();
|
||||
assert_eq!(m.meshes[0].primitives[0].indices.len(), 2994);
|
||||
let e = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 5000).err().unwrap_or_else(|| panic!("imported"));
|
||||
assert!(e.to_string().contains("more than 5000 components"), "{e}");
|
||||
}
|
||||
|
||||
+264
-35
@@ -97,12 +97,24 @@ pub fn exp_rotation_inv(x: &mut [f32], len: usize, k: usize, spread: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative slack for the pulse-candidate filter in [`pvq_search`]: far above the f32 rounding of
|
||||
/// the filter score (a few 1e-7), far below the spread of real scores.
|
||||
const PVQ_SCORE_SLACK: f32 = 1e-5;
|
||||
|
||||
/// Greedy PVQ search: the integer vector `y` with `sum |y| = k` closest in direction to `x`.
|
||||
pub fn pvq_search(x: &[f32], k: usize, y: &mut [i32]) {
|
||||
let n = x.len();
|
||||
y[..n].fill(0);
|
||||
let abs: Vec<f32> = x.iter().map(|v| v.abs()).collect();
|
||||
let sum: f32 = abs.iter().sum();
|
||||
// `abs`, `dy` (2 y + 1, infinite in the padding) and the per-pass scores `score`, each padded to
|
||||
// whole 8-lane chunks.
|
||||
let np = n.next_multiple_of(8);
|
||||
let mut buf = vec![0f32; 3 * np];
|
||||
let (abs, rest) = buf.split_at_mut(np);
|
||||
let (dy, score) = rest.split_at_mut(np);
|
||||
for (a, v) in abs.iter_mut().zip(x) {
|
||||
*a = v.abs();
|
||||
}
|
||||
let sum: f32 = abs[..n].iter().sum();
|
||||
if !sum.is_finite() || sum <= 1e-15 {
|
||||
y[0] = k as i32;
|
||||
return;
|
||||
@@ -114,30 +126,69 @@ pub fn pvq_search(x: &[f32], k: usize, y: &mut [i32]) {
|
||||
// Pre-project onto the pyramid (rounding down) and place the rest greedily.
|
||||
let r = (k as f32 - 1.0) / sum;
|
||||
for j in 0..n {
|
||||
let p = (abs[j] * r).floor() as i32;
|
||||
// Non-negative and at most k, so the truncating cast is the floor.
|
||||
let p = (abs[j] * r) as i32;
|
||||
y[j] = p;
|
||||
placed += p as usize;
|
||||
xy += abs[j] * p as f32;
|
||||
yy += (p * p) as f32;
|
||||
}
|
||||
}
|
||||
for j in 0..np {
|
||||
dy[j] = if j < n { 2.0 * y[j] as f32 + 1.0 } else { f32::INFINITY };
|
||||
}
|
||||
while placed < k {
|
||||
// Vectorisable scores (xy + |x_j|)^2 / (yy + 2 y_j + 1) and their maximum.
|
||||
for ((sc, &a), &d) in score.iter_mut().zip(abs.iter()).zip(dy.iter()) {
|
||||
let t = xy + a;
|
||||
*sc = t * t / (yy + d);
|
||||
}
|
||||
let mut lane_max = [0f32; 8];
|
||||
for c in score.as_chunks::<8>().0 {
|
||||
for i in 0..8 {
|
||||
lane_max[i] = if c[i] > lane_max[i] { c[i] } else { lane_max[i] };
|
||||
}
|
||||
}
|
||||
let top = lane_max.iter().fold(0f32, |m, &v| if v > m { v } else { m });
|
||||
// The exact cross-multiplied test below, in index order with the first maximum winning, only
|
||||
// matters for entries within rounding distance of the top score: every other entry is beaten
|
||||
// by the top one whenever it is reached and never beats it afterwards. Running the test on
|
||||
// those alone therefore picks the same pulse as running it on all of them.
|
||||
let filter = top > 1e-30 && top.is_finite();
|
||||
let lo = top * (1.0 - PVQ_SCORE_SLACK);
|
||||
// Usually exactly one entry qualifies; its index falls out of a lane-wise count.
|
||||
let mut count = [0u32; 8];
|
||||
let mut index = [0u32; 8];
|
||||
for (ci, c) in score.as_chunks::<8>().0.iter().enumerate() {
|
||||
for i in 0..8 {
|
||||
let hit = u32::from(c[i] >= lo);
|
||||
count[i] += hit;
|
||||
index[i] += hit * (ci * 8 + i) as u32;
|
||||
}
|
||||
}
|
||||
let mut best = 0usize;
|
||||
let mut best_num = -1f32;
|
||||
let mut best_den = 1f32;
|
||||
for j in 0..n {
|
||||
let num = xy + abs[j];
|
||||
let num = num * num;
|
||||
let den = yy + 2.0 * y[j] as f32 + 1.0;
|
||||
if num * best_den > best_num * den {
|
||||
best_num = num;
|
||||
best_den = den;
|
||||
best = j;
|
||||
if filter && count.iter().sum::<u32>() == 1 {
|
||||
best = index.iter().sum::<u32>() as usize;
|
||||
} else {
|
||||
let mut best_num = -1f32;
|
||||
let mut best_den = 1f32;
|
||||
for j in 0..n {
|
||||
if !filter || score[j] >= lo {
|
||||
let num = xy + abs[j];
|
||||
let num = num * num;
|
||||
let den = yy + 2.0 * y[j] as f32 + 1.0;
|
||||
if num * best_den > best_num * den {
|
||||
best_num = num;
|
||||
best_den = den;
|
||||
best = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
xy += abs[best];
|
||||
yy += 2.0 * y[best] as f32 + 1.0;
|
||||
y[best] += 1;
|
||||
dy[best] = 2.0 * y[best] as f32 + 1.0;
|
||||
placed += 1;
|
||||
}
|
||||
for j in 0..n {
|
||||
@@ -147,25 +198,45 @@ pub fn pvq_search(x: &[f32], k: usize, y: &mut [i32]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes the PVQ codeword `y` (`sum |y| = k`) as the index the RFC 6716 §4.3.4.2 decoder maps
|
||||
/// back to `y`.
|
||||
pub fn encode_pulses(y: &[i32], k: usize, enc: &mut RangeEncoder, v: &mut Vec<u64>) {
|
||||
let n = y.len();
|
||||
let w = k + 1;
|
||||
v.clear();
|
||||
v.resize((n + 1) * w, 0);
|
||||
/// Largest band width and pulse count the encoder codes (176 coefficients, `get_pulses(40)` = 128).
|
||||
const PVQ_MAX_N: usize = 176;
|
||||
const PVQ_MAX_K: usize = 128;
|
||||
|
||||
/// Row-major `V(n, k)` for `n <= max_n`, `k <= max_k` with row stride `max_k + 1`, saturated at
|
||||
/// `u32::MAX` (a codebook that large is never coded, see `rate::fits_in32`).
|
||||
fn build_pvq_table(max_n: usize, max_k: usize) -> Vec<u32> {
|
||||
let w = max_k + 1;
|
||||
let mut v = vec![0u32; (max_n + 1) * w];
|
||||
v[0] = 1;
|
||||
for nn in 1..=n {
|
||||
for nn in 1..=max_n {
|
||||
v[nn * w] = 1;
|
||||
for kk in 1..=k {
|
||||
let a = v[(nn - 1) * w + kk];
|
||||
let b = v[nn * w + kk - 1];
|
||||
let c = v[(nn - 1) * w + kk - 1];
|
||||
v[nn * w + kk] = a.saturating_add(b).saturating_add(c);
|
||||
for kk in 1..=max_k {
|
||||
let sum = v[(nn - 1) * w + kk] as u64 + v[nn * w + kk - 1] as u64 + v[(nn - 1) * w + kk - 1] as u64;
|
||||
v[nn * w + kk] = sum.min(u32::MAX as u64) as u32;
|
||||
}
|
||||
}
|
||||
let total = v[n * w + k];
|
||||
let vv = |nn: usize, kx: usize| v[nn * w + kx];
|
||||
v
|
||||
}
|
||||
|
||||
fn pvq_table() -> &'static [u32] {
|
||||
static TABLE: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
|
||||
TABLE.get_or_init(|| build_pvq_table(PVQ_MAX_N, PVQ_MAX_K))
|
||||
}
|
||||
|
||||
/// Encodes the PVQ codeword `y` (`sum |y| = k`) as the index the RFC 6716 §4.3.4.2 decoder maps
|
||||
/// back to `y`.
|
||||
pub fn encode_pulses(y: &[i32], k: usize, enc: &mut RangeEncoder) {
|
||||
let n = y.len();
|
||||
// Shapes outside the coded range (never produced by the band layout) get a local table.
|
||||
let local;
|
||||
let (v, w) = if n <= PVQ_MAX_N && k <= PVQ_MAX_K {
|
||||
(pvq_table(), PVQ_MAX_K + 1)
|
||||
} else {
|
||||
local = build_pvq_table(n, k);
|
||||
(&local[..], k + 1)
|
||||
};
|
||||
let row = |nn: usize| &v[nn * w..(nn + 1) * w];
|
||||
let total = row(n)[k] as u64;
|
||||
let mut index = 0u64;
|
||||
let mut kk = k;
|
||||
for j in 0..n {
|
||||
@@ -174,10 +245,11 @@ pub fn encode_pulses(y: &[i32], k: usize, enc: &mut RangeEncoder, v: &mut Vec<u6
|
||||
}
|
||||
let rem = n - j;
|
||||
let t = y[j].unsigned_abs() as usize;
|
||||
let below = row(rem - 1);
|
||||
// Codewords with |y_j| = t start at sum_{m < kk - t} V(rem-1, m) within their sign half.
|
||||
let mut base: u64 = (0..kk - t).map(|m| vv(rem - 1, m)).sum();
|
||||
let mut base: u64 = below[..kk - t].iter().map(|&x| x as u64).sum();
|
||||
if y[j] < 0 {
|
||||
base += (vv(rem - 1, kk) + vv(rem, kk)) / 2;
|
||||
base += (below[kk] as u64 + row(rem)[kk] as u64) / 2;
|
||||
}
|
||||
index += base;
|
||||
kk -= t;
|
||||
@@ -194,7 +266,6 @@ pub struct BandEnc<'m, 'e> {
|
||||
pub i: usize,
|
||||
pub spread: usize,
|
||||
pub remaining_bits: i32,
|
||||
pvq_v: Vec<u64>,
|
||||
iy: Vec<i32>,
|
||||
tmp: Vec<f32>,
|
||||
}
|
||||
@@ -288,7 +359,7 @@ impl BandEnc<'_, '_> {
|
||||
iy.clear();
|
||||
iy.resize(n, 0);
|
||||
pvq_search(&tmp, k, &mut iy);
|
||||
encode_pulses(&iy, k, self.enc, &mut self.pvq_v);
|
||||
encode_pulses(&iy, k, self.enc);
|
||||
self.iy = iy;
|
||||
self.tmp = tmp;
|
||||
}
|
||||
@@ -310,7 +381,7 @@ pub fn quant_all_bands(m: &Mode, frame: CeltFrame, x: &[f32], n_total: usize, al
|
||||
let BandCoding { spread, total_bits } = coding;
|
||||
let mm = 1usize << lm;
|
||||
debug_assert!(c == 1 || dual_stereo || intensity <= start, "stereo frames are coded as dual stereo");
|
||||
let mut ctx = BandEnc { m, enc, i: 0, spread, remaining_bits: 0, pvq_v: Vec::new(), iy: Vec::new(), tmp: Vec::new() };
|
||||
let mut ctx = BandEnc { m, enc, i: 0, spread, remaining_bits: 0, iy: Vec::new(), tmp: Vec::new() };
|
||||
for i in start..end {
|
||||
ctx.i = i;
|
||||
let xo = mm * EBANDS[i] as usize;
|
||||
@@ -460,12 +531,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pvq_index_roundtrip_exhaustive() {
|
||||
let mut v = Vec::new();
|
||||
for n in 1..6usize {
|
||||
for k in 1..6usize {
|
||||
for y in all_codewords(n, k) {
|
||||
let mut e = RangeEncoder::new();
|
||||
encode_pulses(&y, k, &mut e, &mut v);
|
||||
encode_pulses(&y, k, &mut e);
|
||||
let buf = e.done(16);
|
||||
let mut d = RangeDecoder::new(&buf);
|
||||
assert_eq!(decode_pulses(n, k, &mut d), y, "n={n} k={k}");
|
||||
@@ -474,6 +544,165 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-call table encoder this module used before the shared table.
|
||||
fn encode_pulses_reference(y: &[i32], k: usize, enc: &mut RangeEncoder) {
|
||||
let n = y.len();
|
||||
let w = k + 1;
|
||||
let mut v = vec![0u64; (n + 1) * w];
|
||||
v[0] = 1;
|
||||
for nn in 1..=n {
|
||||
v[nn * w] = 1;
|
||||
for kk in 1..=k {
|
||||
let a = v[(nn - 1) * w + kk];
|
||||
let b = v[nn * w + kk - 1];
|
||||
let c = v[(nn - 1) * w + kk - 1];
|
||||
v[nn * w + kk] = a.saturating_add(b).saturating_add(c);
|
||||
}
|
||||
}
|
||||
let total = v[n * w + k];
|
||||
let vv = |nn: usize, kx: usize| v[nn * w + kx];
|
||||
let mut index = 0u64;
|
||||
let mut kk = k;
|
||||
for j in 0..n {
|
||||
if kk == 0 {
|
||||
break;
|
||||
}
|
||||
let rem = n - j;
|
||||
let t = y[j].unsigned_abs() as usize;
|
||||
let mut base: u64 = (0..kk - t).map(|m| vv(rem - 1, m)).sum();
|
||||
if y[j] < 0 {
|
||||
base += (vv(rem - 1, kk) + vv(rem, kk)) / 2;
|
||||
}
|
||||
index += base;
|
||||
kk -= t;
|
||||
}
|
||||
if total >= 2 {
|
||||
enc.uint(index as u32, total as u32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pvq_table_encode_matches_per_call_table() {
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let mut next = move |m: u64| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed % m
|
||||
};
|
||||
let mut checked = 0;
|
||||
for n in (1..=PVQ_MAX_N).step_by(5).chain([PVQ_MAX_N]) {
|
||||
for k in (1..=PVQ_MAX_K).step_by(3).chain([PVQ_MAX_K]) {
|
||||
if crate::rate::pvq_v(n, k) > u32::MAX as u128 {
|
||||
continue;
|
||||
}
|
||||
for _ in 0..4 {
|
||||
let mut y = vec![0i32; n];
|
||||
for _ in 0..k {
|
||||
let at = next(n as u64) as usize;
|
||||
y[at] += if next(2) == 0 { 1 } else { -1 };
|
||||
}
|
||||
// Opposite pulses cancel; top up so that sum |y| = k again.
|
||||
let mut have: usize = y.iter().map(|v| v.unsigned_abs() as usize).sum();
|
||||
while have < k {
|
||||
let at = next(n as u64) as usize;
|
||||
y[at] += if y[at] < 0 { -1 } else { 1 };
|
||||
have += 1;
|
||||
}
|
||||
let (mut a, mut b) = (RangeEncoder::new(), RangeEncoder::new());
|
||||
encode_pulses(&y, k, &mut a);
|
||||
encode_pulses_reference(&y, k, &mut b);
|
||||
assert_eq!(a.done(16), b.done(16), "n={n} k={k}");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 100, "{checked}");
|
||||
}
|
||||
|
||||
/// The scalar greedy search the vectorised one replaced.
|
||||
fn pvq_search_ref(x: &[f32], k: usize, y: &mut [i32]) {
|
||||
let n = x.len();
|
||||
y[..n].fill(0);
|
||||
let abs: Vec<f32> = x.iter().map(|v| v.abs()).collect();
|
||||
let sum: f32 = abs.iter().sum();
|
||||
if !sum.is_finite() || sum <= 1e-15 {
|
||||
y[0] = k as i32;
|
||||
return;
|
||||
}
|
||||
let mut placed = 0usize;
|
||||
let mut xy = 0f32;
|
||||
let mut yy = 0f32;
|
||||
if k > n / 2 {
|
||||
let r = (k as f32 - 1.0) / sum;
|
||||
for j in 0..n {
|
||||
let p = (abs[j] * r).floor() as i32;
|
||||
y[j] = p;
|
||||
placed += p as usize;
|
||||
xy += abs[j] * p as f32;
|
||||
yy += (p * p) as f32;
|
||||
}
|
||||
}
|
||||
while placed < k {
|
||||
let mut best = 0usize;
|
||||
let mut best_num = -1f32;
|
||||
let mut best_den = 1f32;
|
||||
for j in 0..n {
|
||||
let num = xy + abs[j];
|
||||
let num = num * num;
|
||||
let den = yy + 2.0 * y[j] as f32 + 1.0;
|
||||
if num * best_den > best_num * den {
|
||||
best_num = num;
|
||||
best_den = den;
|
||||
best = j;
|
||||
}
|
||||
}
|
||||
xy += abs[best];
|
||||
yy += 2.0 * y[best] as f32 + 1.0;
|
||||
y[best] += 1;
|
||||
placed += 1;
|
||||
}
|
||||
for j in 0..n {
|
||||
if x[j] < 0.0 {
|
||||
y[j] = -y[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pvq_search_matches_scalar_reference() {
|
||||
let mut state = 0x9e3779b97f4a7c15u64;
|
||||
let mut rnd = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
(state >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0
|
||||
};
|
||||
let mut a = vec![0i32; 200];
|
||||
let mut b = vec![0i32; 200];
|
||||
let mut cases = 0;
|
||||
for round in 0..6000 {
|
||||
let n = 1 + (round * 37 + (rnd().abs() * 176.0) as usize) % 176;
|
||||
let k = 1 + (rnd().abs() * 127.0) as usize;
|
||||
let scale = [1.0f32, 1e-9, 1e6, 3e-3][round % 4];
|
||||
let x: Vec<f32> = (0..n)
|
||||
.map(|_| match round % 6 {
|
||||
0 => rnd(),
|
||||
1 => if rnd() > 0.8 { rnd() } else { 0.0 },
|
||||
2 => (rnd() * 4.0).round() / 4.0,
|
||||
3 => if rnd() > 0.0 { 1.0 } else { -1.0 },
|
||||
4 => rnd().powi(5),
|
||||
_ => if rnd() > 0.95 { 3.0 * rnd() } else { 1e-4 * rnd() },
|
||||
} * scale)
|
||||
.collect();
|
||||
pvq_search(&x, k, &mut a);
|
||||
pvq_search_ref(&x, k, &mut b);
|
||||
assert_eq!(a[..n], b[..n], "n={n} k={k} round={round}");
|
||||
cases += 1;
|
||||
}
|
||||
assert_eq!(cases, 6000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pvq_search_finds_good_direction() {
|
||||
let x: Vec<f32> = (0..32).map(|i| ((i as f32) * 0.7).sin()).collect();
|
||||
|
||||
@@ -69,28 +69,55 @@ pub fn levinson(r: &[f64], order: usize) -> Vec<f64> {
|
||||
}
|
||||
|
||||
/// Evaluates the symmetric polynomial `p` (length `d + 1`, `d` even) as the cosine series
|
||||
/// `e^{jωd/2} P(e^{jω})`.
|
||||
fn cos_series(p: &[f64], w: f64) -> f64 {
|
||||
/// `e^{jωd/2} P(e^{jω})`, with `cos_m(m)` giving `cos(m ω)`.
|
||||
#[inline]
|
||||
fn cos_series_with(p: &[f64], cos_m: impl Fn(usize) -> f64) -> f64 {
|
||||
let h = (p.len() - 1) / 2;
|
||||
let mut s = p[h];
|
||||
for k in 0..h {
|
||||
s += 2.0 * p[k] * ((h - k) as f64 * w).cos();
|
||||
s += 2.0 * p[k] * cos_m(h - k);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn cos_series(p: &[f64], w: f64) -> f64 {
|
||||
cos_series_with(p, |m| (m as f64 * w).cos())
|
||||
}
|
||||
|
||||
/// Number of grid intervals the root search scans over `[0, π]`.
|
||||
const GRID: usize = 1024;
|
||||
/// Largest multiple of ω in the series (half the LPC order, at most 16 / 2).
|
||||
const MAX_H: usize = 8;
|
||||
/// Bisection steps per root: the bracket shrinks to 3e-9 rad, far below one Q15 NLSF step (9.6e-5 rad).
|
||||
const BISECT_STEPS: usize = 20;
|
||||
|
||||
/// `cos(m · π g / GRID)` for every grid point `g` and `m` in `0..=MAX_H`, computed with the same
|
||||
/// expression as [`cos_series`], so the table values are bit-identical to calling `cos` there.
|
||||
fn grid_cos() -> &'static [[f64; MAX_H + 1]] {
|
||||
static TABLE: std::sync::OnceLock<Vec<[f64; MAX_H + 1]>> = std::sync::OnceLock::new();
|
||||
TABLE.get_or_init(|| {
|
||||
(0..=GRID)
|
||||
.map(|g| {
|
||||
let w = std::f64::consts::PI * g as f64 / GRID as f64;
|
||||
std::array::from_fn(|m| (m as f64 * w).cos())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn roots(p: &[f64], out: &mut Vec<f64>) {
|
||||
const GRID: usize = 1024;
|
||||
let grid = grid_cos();
|
||||
let at_grid = |g: usize| cos_series_with(p, |m| grid[g][m]);
|
||||
let mut w0 = 0.0;
|
||||
let mut f0 = cos_series(p, w0);
|
||||
let mut f0 = at_grid(0);
|
||||
for g in 1..=GRID {
|
||||
let w1 = std::f64::consts::PI * g as f64 / GRID as f64;
|
||||
let f1 = cos_series(p, w1);
|
||||
let f1 = at_grid(g);
|
||||
if f0 == 0.0 {
|
||||
out.push(w0);
|
||||
} else if f0 * f1 < 0.0 {
|
||||
let (mut lo, mut hi, mut flo) = (w0, w1, f0);
|
||||
for _ in 0..40 {
|
||||
for _ in 0..BISECT_STEPS {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
let fm = cos_series(p, mid);
|
||||
if fm * flo <= 0.0 {
|
||||
@@ -111,6 +138,9 @@ fn roots(p: &[f64], out: &mut Vec<f64>) {
|
||||
/// when the root search fails (unstable or degenerate filter).
|
||||
pub fn a2nlsf(a: &[f64]) -> Option<Vec<f64>> {
|
||||
let d = a.len();
|
||||
if d > 2 * MAX_H {
|
||||
return None;
|
||||
}
|
||||
let mut c = vec![0f64; d + 2];
|
||||
c[0] = 1.0;
|
||||
for k in 0..d {
|
||||
@@ -199,9 +229,20 @@ pub fn pitch_search(x: &[f32], start: usize, len: usize, fs_khz: usize) -> Pitch
|
||||
let score = full[(lag - min_lag) as usize];
|
||||
// Contour: per-subframe lag offsets around the chosen lag.
|
||||
let cb: &[[i32; 4]] = if fs_khz == 8 { &CB_NB_20 } else { &CB_WB_20 };
|
||||
// The contour search revisits the same (subframe, lag) pairs for neighbouring base lags and
|
||||
// codebook entries, so each correlation is computed once.
|
||||
let span = (18 * fs - min_lag + 1) as usize;
|
||||
let memo = std::cell::RefCell::new(vec![f64::NAN; 4 * span]);
|
||||
let sub_score = |k: usize, l: i32| -> f64 {
|
||||
let l = l.clamp(min_lag, 18 * fs);
|
||||
ncorr(x, start + k * sub..start + (k + 1) * sub, l as usize)
|
||||
let slot = k * span + (l - min_lag) as usize;
|
||||
let cached = memo.borrow()[slot];
|
||||
if !cached.is_nan() {
|
||||
return cached;
|
||||
}
|
||||
let v = ncorr(x, start + k * sub..start + (k + 1) * sub, l as usize);
|
||||
memo.borrow_mut()[slot] = v;
|
||||
v
|
||||
};
|
||||
let mut best = (f64::MIN, lag, 0usize);
|
||||
for l0 in (lag - 2).max(min_lag)..=(lag + 2).min(max_lag) {
|
||||
@@ -319,4 +360,59 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The root search as it was before the grid table: `cos` called for every evaluation.
|
||||
fn roots_reference(p: &[f64], out: &mut Vec<f64>) {
|
||||
let mut w0 = 0.0;
|
||||
let mut f0 = cos_series(p, w0);
|
||||
for g in 1..=GRID {
|
||||
let w1 = std::f64::consts::PI * g as f64 / GRID as f64;
|
||||
let f1 = cos_series(p, w1);
|
||||
if f0 == 0.0 {
|
||||
out.push(w0);
|
||||
} else if f0 * f1 < 0.0 {
|
||||
let (mut lo, mut hi, mut flo) = (w0, w1, f0);
|
||||
for _ in 0..BISECT_STEPS {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
let fm = cos_series(p, mid);
|
||||
if fm * flo <= 0.0 {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
flo = fm;
|
||||
}
|
||||
}
|
||||
out.push(0.5 * (lo + hi));
|
||||
}
|
||||
w0 = w1;
|
||||
f0 = f1;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_table_roots_are_bit_identical_to_direct_cos() {
|
||||
let mut seed = 7u64;
|
||||
let mut rnd = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
|
||||
};
|
||||
let mut found = 0;
|
||||
for len in [11usize, 17] {
|
||||
for _ in 0..300 {
|
||||
let mut p = vec![0f64; len];
|
||||
for k in 0..len / 2 + 1 {
|
||||
p[k] = rnd();
|
||||
p[len - 1 - k] = p[k];
|
||||
}
|
||||
let (mut a, mut b) = (Vec::new(), Vec::new());
|
||||
roots(&p, &mut a);
|
||||
roots_reference(&p, &mut b);
|
||||
assert_eq!(a.iter().map(|v| v.to_bits()).collect::<Vec<_>>(), b.iter().map(|v| v.to_bits()).collect::<Vec<_>>());
|
||||
found += a.len();
|
||||
}
|
||||
}
|
||||
assert!(found > 1000, "{found}");
|
||||
}
|
||||
}
|
||||
|
||||
+61
-5
@@ -130,13 +130,38 @@ pub fn from_kurbo(path: &BezPath) -> Vec<ShapePath> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Rectangle path centred at `pos` with corner `roundness` (AE draws clockwise from the top-right).
|
||||
/// Rectangle path centred at `pos` with corner `roundness`, drawn as After Effects draws it
|
||||
/// (Trim Paths measures from its first point): clockwise from the top-right corner, or with
|
||||
/// round corners from the end of the top-right arc on the right edge (After Effects 2026,
|
||||
/// 400 × 300 rectangles trimmed 0–25 %, case 930).
|
||||
pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath {
|
||||
let r = kurbo::Rect::from_center_size(Point::new(pos[0], pos[1]), (size[0].abs(), size[1].abs()));
|
||||
let max_rad = size[0].abs().min(size[1].abs()) / 2.0;
|
||||
let (w, h) = (size[0].abs(), size[1].abs());
|
||||
let (x0, y0, x1, y1) = (pos[0] - w / 2.0, pos[1] - h / 2.0, pos[0] + w / 2.0, pos[1] + h / 2.0);
|
||||
let max_rad = w.min(h) / 2.0;
|
||||
// `f64::clamp` panics on a NaN bound (both size components NaN): fall back to no roundness.
|
||||
let rad = if max_rad.is_nan() { 0.0 } else { roundness.clamp(0.0, max_rad) };
|
||||
if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) }
|
||||
let rad = if roundness.is_finite() && !max_rad.is_nan() { roundness.clamp(0.0, max_rad) } else { 0.0 };
|
||||
let mut p = BezPath::new();
|
||||
if rad <= 0.0 {
|
||||
p.move_to((x1, y0));
|
||||
p.line_to((x1, y1));
|
||||
p.line_to((x0, y1));
|
||||
p.line_to((x0, y0));
|
||||
p.close_path();
|
||||
return p;
|
||||
}
|
||||
// Quarter circles as cubics (handle 0.5523 × radius).
|
||||
let k = rad * 0.552_284_749_8;
|
||||
p.move_to((x1, y0 + rad));
|
||||
p.line_to((x1, y1 - rad));
|
||||
p.curve_to((x1, y1 - rad + k), (x1 - rad + k, y1), (x1 - rad, y1));
|
||||
p.line_to((x0 + rad, y1));
|
||||
p.curve_to((x0 + rad - k, y1), (x0, y1 - rad + k), (x0, y1 - rad));
|
||||
p.line_to((x0, y0 + rad));
|
||||
p.curve_to((x0, y0 + rad - k), (x0 + rad - k, y0), (x0 + rad, y0));
|
||||
p.line_to((x1 - rad, y0));
|
||||
p.curve_to((x1 - rad + k, y0), (x1, y0 + rad - k), (x1, y0 + rad));
|
||||
p.close_path();
|
||||
p
|
||||
}
|
||||
|
||||
pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath {
|
||||
@@ -369,6 +394,37 @@ pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec<BezPath> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// After Effects 2026 (case 930): 400 × 300 rectangles at the origin with Trim Paths. Plain:
|
||||
/// 0–25 % runs from the top-right corner down the right edge and 50 px along the bottom.
|
||||
/// Round corners 40: from the end of the top-right arc (200, −110) down, round the
|
||||
/// bottom-right corner, to x 110 on the bottom. Start 70 / End 30 draws 30–70 %.
|
||||
#[test]
|
||||
fn rectangles_start_where_after_effects_starts_them() {
|
||||
let ends = |p: &BezPath| {
|
||||
let mut first = None;
|
||||
let mut last = None;
|
||||
for el in p.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(q) if first.is_none() => first = Some(q),
|
||||
PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) | PathEl::QuadTo(_, q) => last = Some(q),
|
||||
PathEl::ClosePath => {}
|
||||
}
|
||||
}
|
||||
(first.unwrap_or_default(), last.unwrap_or_default())
|
||||
};
|
||||
let near = |a: Point, b: (f64, f64)| (a.x - b.0).abs() < 1.0 && (a.y - b.1).abs() < 1.0;
|
||||
let plain = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 0.0, 25.0, 0.0);
|
||||
let (a, b) = ends(&plain[0]);
|
||||
assert!(near(a, (200.0, -150.0)) && near(b, (150.0, 150.0)), "{a:?} {b:?}");
|
||||
let round = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 40.0)], 0.0, 25.0, 0.0);
|
||||
let (a, b) = ends(&round[0]);
|
||||
assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "{a:?} {b:?}");
|
||||
// Start > End: AE draws 30..70 % (plain: from x 80 on the bottom up to y −130 on the left).
|
||||
let swapped = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 70.0, 30.0, 0.0);
|
||||
let (a, b) = ends(&swapped[0]);
|
||||
assert!(near(a, (80.0, 150.0)) && near(b, (-200.0, -130.0)), "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_with_nan_size_does_not_panic() {
|
||||
let _ = rect([f64::NAN, f64::NAN], [0.0, 0.0], 5.0);
|
||||
|
||||
+42
-23
@@ -275,15 +275,35 @@ impl Comp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A protected time marker with a duration sets the comp's timing (Responsive Design), so it is
|
||||
/// part of the pixels even where plain markers are not.
|
||||
fn is_timing_marker(m: &Marker) -> bool {
|
||||
m.protected && m.duration > Tick::ZERO
|
||||
}
|
||||
|
||||
fn timing_markers(m: &[Marker]) -> impl Iterator<Item = (Tick, Tick)> + '_ {
|
||||
m.iter().filter(|m| is_timing_marker(m)).map(|m| (m.time, m.duration))
|
||||
}
|
||||
|
||||
impl Comp {
|
||||
/// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide
|
||||
/// Shy Layers and the viewer's Resolution.
|
||||
pub fn same_pixels(&self, o: &Comp) -> bool {
|
||||
self.same_settings(o) && self.layers.len() == o.layers.len() && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b))
|
||||
self.same_pixels_with(o, true)
|
||||
}
|
||||
|
||||
/// [`Self::same_pixels`] but for the layers.
|
||||
fn same_settings(&self, o: &Comp) -> bool {
|
||||
/// [`Self::same_pixels`], with the comp's and its layers' markers counting only when
|
||||
/// `with_markers`: only expressions read them, so while none does, adding or moving a marker
|
||||
/// (even during playback) leaves every rendered frame valid. Protected time markers with a
|
||||
/// duration set the timing (Responsive Design), so those always count.
|
||||
pub fn same_pixels_with(&self, o: &Comp, with_markers: bool) -> bool {
|
||||
self.same_settings(o, with_markers)
|
||||
&& self.layers.len() == o.layers.len()
|
||||
&& self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels_with(b, with_markers))
|
||||
}
|
||||
|
||||
/// [`Self::same_pixels_with`] but for the layers.
|
||||
fn same_settings(&self, o: &Comp, with_markers: bool) -> bool {
|
||||
// Every field (no `..`: a new field must be considered here).
|
||||
let Comp {
|
||||
width,
|
||||
@@ -321,7 +341,7 @@ impl Comp {
|
||||
&& *display_start == o.display_start
|
||||
&& *background == o.background
|
||||
&& *work_area == o.work_area
|
||||
&& *markers == o.markers
|
||||
&& (if with_markers { *markers == o.markers } else { timing_markers(markers).eq(timing_markers(&o.markers)) })
|
||||
&& *shutter_angle == o.shutter_angle
|
||||
&& *shutter_phase == o.shutter_phase
|
||||
&& *motion_blur_samples == o.motion_blur_samples
|
||||
@@ -356,7 +376,7 @@ impl Comp {
|
||||
})
|
||||
}
|
||||
// Solo hides every other layer at every frame.
|
||||
if !self.same_settings(o) || self.any_solo() != o.any_solo() {
|
||||
if !self.same_settings(o, true) || self.any_solo() != o.any_solo() {
|
||||
return None;
|
||||
}
|
||||
let common = |a: &Comp, b: &Comp| a.layers.iter().map(|l| l.id).filter(|id| b.layer(*id).is_some()).collect::<Vec<_>>();
|
||||
@@ -393,34 +413,33 @@ impl Comp {
|
||||
self.layers.iter().any(|l| l.switches.solo && l.source.is_av())
|
||||
}
|
||||
|
||||
/// Draws the same pixels as `o` once plain markers are ignored: they change no pixels. Protected
|
||||
/// time markers with a duration set the timing (Responsive Design), so those still count.
|
||||
pub fn same_pixels_ignoring_markers(&self, o: &Comp) -> bool {
|
||||
let protected =
|
||||
|m: &[Marker]| -> Vec<(Tick, Tick)> { m.iter().filter(|m| m.protected && m.duration > Tick::ZERO).map(|m| (m.time, m.duration)).collect() };
|
||||
if protected(&self.markers) != protected(&o.markers) {
|
||||
return false;
|
||||
}
|
||||
let mut c = self.clone();
|
||||
c.markers = o.markers.clone();
|
||||
for (a, b) in c.layers.iter_mut().zip(&o.layers) {
|
||||
a.markers = b.markers.clone();
|
||||
a.markers_locked = b.markers_locked;
|
||||
}
|
||||
c.same_pixels(o)
|
||||
}
|
||||
|
||||
/// The comp with those switches and the Resolution at their defaults (borrowed when they
|
||||
/// already are), for keys that hash the whole comp.
|
||||
pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> {
|
||||
if !self.hide_shy && self.resolution.is_auto() && self.layers.iter().all(|l| l.switches == l.switches.pixels()) {
|
||||
self.pixel_form_with(true)
|
||||
}
|
||||
|
||||
/// [`Self::pixel_form`], also without the comp's plain markers and its layers' markers when not
|
||||
/// `with_markers` (see [`Self::same_pixels_with`] and [`Project::reads_markers`]).
|
||||
pub fn pixel_form_with(&self, with_markers: bool) -> std::borrow::Cow<'_, Comp> {
|
||||
if !self.hide_shy
|
||||
&& self.resolution.is_auto()
|
||||
&& self.layers.iter().all(|l| l.switches == l.switches.pixels())
|
||||
&& (with_markers || (self.markers.iter().all(is_timing_marker) && self.layers.iter().all(|l| l.markers.is_empty())))
|
||||
{
|
||||
return std::borrow::Cow::Borrowed(self);
|
||||
}
|
||||
let mut c = self.clone();
|
||||
c.hide_shy = false;
|
||||
c.resolution = Resolution::Auto;
|
||||
if !with_markers {
|
||||
c.markers.retain(is_timing_marker);
|
||||
}
|
||||
for l in &mut c.layers {
|
||||
l.switches = l.switches.pixels();
|
||||
if !with_markers {
|
||||
l.markers.clear();
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(c)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,21 @@ impl Default for Project {
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Some layer has an expression, which can read markers (`marker.key(1)`, `numKeys`,
|
||||
/// `nearestKey`...). Only then do the markers of comps and layers affect what is rendered, so
|
||||
/// frame caches ignore them otherwise.
|
||||
pub fn reads_markers(&self) -> bool {
|
||||
// Called for every frame identity and disk-cache key: stops at the first expression and
|
||||
// builds no paths (`PropGroup::walk` formats one per node).
|
||||
fn any_expr(g: &crate::PropGroup) -> bool {
|
||||
g.children.iter().any(|c| match c {
|
||||
crate::Node::Prop(p) => p.expr.is_some(),
|
||||
crate::Node::Group(sub) => any_expr(sub),
|
||||
})
|
||||
}
|
||||
self.comps().any(|(_, c)| c.layers.iter().any(|l| any_expr(&l.props)))
|
||||
}
|
||||
|
||||
/// Allocate an id (items, layers and property uids share one counter).
|
||||
pub fn alloc(&mut self) -> u64 {
|
||||
let id = self.next_id;
|
||||
|
||||
@@ -195,6 +195,12 @@ impl Switches {
|
||||
impl Layer {
|
||||
/// Draws the same pixels as `o`: equal but for the Audio, Lock and Shy switches.
|
||||
pub fn same_pixels(&self, o: &Layer) -> bool {
|
||||
self.same_pixels_with(o, true)
|
||||
}
|
||||
|
||||
/// [`Self::same_pixels`], with the markers counting only when `with_markers` (they are read by
|
||||
/// expressions alone, so a project without any can edit them freely).
|
||||
pub fn same_pixels_with(&self, o: &Layer, with_markers: bool) -> bool {
|
||||
// Every field (no `..`: a new field must be considered here); the property tree last.
|
||||
let Layer {
|
||||
id,
|
||||
@@ -232,7 +238,7 @@ impl Layer {
|
||||
&& *preserve_transparency == o.preserve_transparency
|
||||
&& *track_matte == o.track_matte
|
||||
&& *parent == o.parent
|
||||
&& *markers == o.markers
|
||||
&& (!with_markers || *markers == o.markers)
|
||||
&& *markers_locked == o.markers_locked
|
||||
&& *auto_orient == o.auto_orient
|
||||
&& *environment == o.environment
|
||||
|
||||
@@ -205,7 +205,8 @@ pub enum MaskMotionBlur {
|
||||
Off,
|
||||
}
|
||||
|
||||
/// How a mask's feather ramps: Smooth (Gaussian) or Linear.
|
||||
/// How a mask's feather ramps: Smooth (three box passes, an S-shaped ramp of finite reach, as
|
||||
/// measured in After Effects) or Linear (one box pass: a straight ramp).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum FeatherFalloff {
|
||||
#[default]
|
||||
|
||||
@@ -509,6 +509,9 @@ pub enum OutputFormat {
|
||||
TiffSequence,
|
||||
/// OpenEXR sequence (32-bit float, linear light).
|
||||
ExrSequence,
|
||||
/// Targa sequence: uncompressed 24-bit RGB or 32-bit RGB + Alpha (8 alpha bits), top-left
|
||||
/// origin.
|
||||
TargaSequence,
|
||||
/// Animated GIF.
|
||||
Gif,
|
||||
/// WebM: VP9 video (alpha with RGB + Alpha) + Opus audio.
|
||||
@@ -528,7 +531,7 @@ pub enum OutputFormat {
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
pub const ALL: [OutputFormat; 13] = [
|
||||
pub const ALL: [OutputFormat; 14] = [
|
||||
OutputFormat::H264,
|
||||
OutputFormat::Hevc,
|
||||
OutputFormat::Av1,
|
||||
@@ -539,6 +542,7 @@ impl OutputFormat {
|
||||
OutputFormat::JpegSequence,
|
||||
OutputFormat::TiffSequence,
|
||||
OutputFormat::ExrSequence,
|
||||
OutputFormat::TargaSequence,
|
||||
OutputFormat::Gif,
|
||||
OutputFormat::Wav,
|
||||
OutputFormat::Aiff,
|
||||
@@ -552,6 +556,7 @@ impl OutputFormat {
|
||||
OutputFormat::JpegSequence => "JPEG Sequence",
|
||||
OutputFormat::TiffSequence => "TIFF Sequence",
|
||||
OutputFormat::ExrSequence => "OpenEXR Sequence",
|
||||
OutputFormat::TargaSequence => "Targa Sequence",
|
||||
OutputFormat::Gif => "Animated GIF",
|
||||
OutputFormat::WebM => "WebM (VP9 + Opus)",
|
||||
OutputFormat::Wav => "WAV",
|
||||
@@ -569,6 +574,7 @@ impl OutputFormat {
|
||||
OutputFormat::JpegSequence => "jpg",
|
||||
OutputFormat::TiffSequence => "tif",
|
||||
OutputFormat::ExrSequence => "exr",
|
||||
OutputFormat::TargaSequence => "tga",
|
||||
OutputFormat::Gif => "gif",
|
||||
OutputFormat::WebM => "webm",
|
||||
OutputFormat::Wav => "wav",
|
||||
@@ -577,7 +583,10 @@ impl OutputFormat {
|
||||
}
|
||||
}
|
||||
pub fn is_sequence(self) -> bool {
|
||||
matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence)
|
||||
matches!(
|
||||
self,
|
||||
OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence | OutputFormat::TargaSequence
|
||||
)
|
||||
}
|
||||
pub fn is_movie(self) -> bool {
|
||||
matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::WebM | OutputFormat::Hevc | OutputFormat::Av1)
|
||||
@@ -603,7 +612,7 @@ impl OutputFormat {
|
||||
if matches!(self, OutputFormat::H264 | OutputFormat::Hevc) { (even(w), even(h)) } else { (w, h) }
|
||||
}
|
||||
/// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `uncompressed`, `png`, `jpeg`/`jpg`,
|
||||
/// `tiff`/`tif`, `exr`, `gif`, or a label.
|
||||
/// `tiff`/`tif`, `exr`, `tga`/`targa`, `gif`, or a label.
|
||||
pub fn from_name(s: &str) -> Option<OutputFormat> {
|
||||
let n = s.trim().to_ascii_lowercase().replace([' ', '.', '-', '_'], "");
|
||||
Some(match n.as_str() {
|
||||
@@ -616,6 +625,7 @@ impl OutputFormat {
|
||||
"jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence,
|
||||
"tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence,
|
||||
"exr" | "openexr" | "openexrsequence" => OutputFormat::ExrSequence,
|
||||
"tga" | "targa" | "targasequence" => OutputFormat::TargaSequence,
|
||||
"gif" | "animatedgif" => OutputFormat::Gif,
|
||||
"webm" | "vp9" | "webmvp9opus" => OutputFormat::WebM,
|
||||
"wav" | "wave" => OutputFormat::Wav,
|
||||
@@ -1682,6 +1692,28 @@ mod tests {
|
||||
assert_eq!(serde_json::from_str::<OutputModule>(&json).unwrap().format, f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targa_sequence_format() {
|
||||
let f = OutputFormat::from_name("tga").unwrap();
|
||||
assert_eq!(f, OutputFormat::TargaSequence);
|
||||
assert_eq!(OutputFormat::from_name(&format!("{f:?}")), Some(f));
|
||||
assert_eq!(OutputFormat::from_name("Targa"), Some(f));
|
||||
assert_eq!(OutputFormat::from_name("Targa Sequence"), Some(f));
|
||||
assert_eq!(OutputFormat::from_path("/out/shot_00001.TGA"), Some(f));
|
||||
assert!(OutputFormat::ALL.contains(&f));
|
||||
assert!(f.is_sequence() && f.supports_alpha() && !f.supports_audio() && !f.is_movie());
|
||||
assert_eq!((f.label(), f.extension()), ("Targa Sequence", "tga"));
|
||||
assert_eq!(f.coded_size(3, 5), (3, 5));
|
||||
// A movie name becomes a numbered sequence; RGB + Alpha survives the switch.
|
||||
let mut m = OutputModule::for_format(OutputFormat::ProRes);
|
||||
m.output = "/out/Comp 1.mov".into();
|
||||
m.channels = Channels::Rgba;
|
||||
m.set_format(f);
|
||||
assert_eq!((m.output.as_str(), m.channels), ("/out/Comp 1_[#####].tga", Channels::Rgba));
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert_eq!(serde_json::from_str::<OutputModule>(&json).unwrap().format, f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_av1_codec_options() {
|
||||
assert_eq!(OutputFormat::from_name("H.265"), Some(OutputFormat::Hevc));
|
||||
|
||||
@@ -721,7 +721,7 @@ pub fn read_layer_entry(data: &[u8]) -> Option<Buf> {
|
||||
/// The disk key of a segmentation: its chain key and scale.
|
||||
fn seg_key(key: u64, scale: f64) -> u128 {
|
||||
let mut h = Hash128::default();
|
||||
h.write(b"segmentation-v1");
|
||||
h.write(b"segmentation-v2");
|
||||
h.write_u64(key);
|
||||
h.write_u64(scale.to_bits());
|
||||
h.finish()
|
||||
@@ -903,7 +903,8 @@ pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Opt
|
||||
/// Content key of a composition's pixels: project render settings, the comp and everything it
|
||||
/// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy
|
||||
/// files' size and time). With expressions in play (which can read any comp) the whole project
|
||||
/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count.
|
||||
/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count, and
|
||||
/// neither do markers unless an expression mentions them.
|
||||
pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 {
|
||||
comp_content_key_with(project, comp, None)
|
||||
}
|
||||
@@ -942,13 +943,15 @@ fn comp_content_key_for_renderer(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Markers are pixels only to the expressions that read them (#1083).
|
||||
let markers = project.reads_markers();
|
||||
let ids: Vec<effectcraft_project::ItemId> = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() };
|
||||
for id in ids {
|
||||
let Some(it) = project.item(id) else { continue };
|
||||
h.write_u64(id.0);
|
||||
h.write(it.name.as_bytes());
|
||||
match &it.kind {
|
||||
ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form()),
|
||||
ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form_with(markers)),
|
||||
k => hash_debug(&mut h, k),
|
||||
}
|
||||
if let ItemKind::Footage(f) = &it.kind {
|
||||
|
||||
+239
-19
@@ -5,7 +5,7 @@ use effectcraft_geom::{Mat3, vec2};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_path::{FillRule, Join};
|
||||
use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur};
|
||||
use effectcraft_raster::{Mask, box_blur, gaussian_blur};
|
||||
use effectcraft_raster::{Mask, box_blur};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::eval::EvalCtx;
|
||||
@@ -60,40 +60,213 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu
|
||||
coverage_at(ctx, layer, g, buf)
|
||||
}
|
||||
|
||||
/// The largest feather taken, in buffer pixels (larger and non-finite values are clamped).
|
||||
const MAX_FEATHER_PX: f64 = 1.0e6;
|
||||
/// Smooth falloff (After Effects' default), measured on After Effects 2026 renders (feather 100,
|
||||
/// 300 and 1000; red value along rows, columns and the corner diagonal of a feathered rect mask):
|
||||
/// the edge is blurred separably by three box passes, each `2r + 1` pixels wide with
|
||||
/// `r = round(SMOOTH_BOX_RADIUS * feather)`. That is a quadratic B-spline reaching about
|
||||
/// 1.11 x feather either side of the path (finite support; a Gaussian of any width misses the
|
||||
/// measured profile by 3/255 or more, this kernel by less than 1/255).
|
||||
const SMOOTH_BOX_RADIUS: f64 = 0.37;
|
||||
/// Up to this box radius (buffer pixels) a feather is blurred at the buffer's resolution. A wider
|
||||
/// one is blurred on a grid coarser per axis, about `COARSE_RADIUS` grid pixels per box: a wide
|
||||
/// blur is smooth, so the coarse grid loses nothing visible, and the padding stays about
|
||||
/// 3 x `COARSE_RADIUS` grid pixels. (Box passes cost the same at any radius; the coarse grid
|
||||
/// bounds the padded area, i.e. memory.)
|
||||
const FULL_RES_RADIUS: usize = 48;
|
||||
const COARSE_RADIUS: usize = 32;
|
||||
/// The largest padded grid (pixels). A grid that would be bigger (a huge buffer) is coarsened
|
||||
/// (`grid`) until it fits, so the padding, and with it the feather of a mask edge outside the
|
||||
/// buffer, is never dropped.
|
||||
const MAX_GRID_PX: usize = 1 << 26;
|
||||
/// The coarsest factor `grid` tries before giving up on the padding (unreachable for any buffer
|
||||
/// that fits in memory: 1024 x 1024 buffer pixels per grid pixel).
|
||||
const MAX_COARSEN: usize = 1024;
|
||||
/// Padding for a variable-width feather, which is rasterised at the buffer's resolution.
|
||||
const MAX_VARIABLE_PAD: usize = 256;
|
||||
|
||||
/// One axis of the grid a feathered mask is rasterised and blurred on: `step` buffer pixels per
|
||||
/// grid pixel, `pad` grid pixels on each side of the buffer, and the box radii in grid pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Axis {
|
||||
step: usize,
|
||||
pad: usize,
|
||||
/// Radii of the box passes (Smooth: three; Linear: one, the others 0).
|
||||
radii: [usize; 3],
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
/// `feather` in buffer pixels. The pad covers the blur's whole support, so the coverage of
|
||||
/// the path outside the buffer reaches into it as on an unbounded plane.
|
||||
fn new(feather: f64, linear: bool, full_res_only: bool) -> Axis {
|
||||
let f = if feather.is_finite() { feather.clamp(0.0, MAX_FEATHER_PX) } else { 0.0 };
|
||||
let none = Axis { step: 1, pad: 0, radii: [0; 3] };
|
||||
if f <= 0.0 {
|
||||
return none;
|
||||
}
|
||||
let cap = |pad: usize| if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad };
|
||||
if linear {
|
||||
// Linear falloff (not measured against After Effects): one box pass, a straight ramp
|
||||
// `feather` pixels wide across the edge, as before. Not quite unchanged: a ramp wider
|
||||
// than about `4 x FULL_RES_RADIUS` pixels is now blurred on the coarse grid and
|
||||
// resampled bilinearly (a smooth ramp, so the difference is a rounding of the ramp).
|
||||
let r = (f / 2.0).round().max(1.0) as usize;
|
||||
let step = if full_res_only || r <= 2 * FULL_RES_RADIUS { 1 } else { r.div_ceil(2 * COARSE_RADIUS) };
|
||||
let rg = ((r as f64 / step as f64).round() as usize).max(1);
|
||||
return Axis { step, pad: cap(rg), radii: [rg, 0, 0] };
|
||||
}
|
||||
let r = (SMOOTH_BOX_RADIUS * f).round() as usize;
|
||||
if r == 0 {
|
||||
return none;
|
||||
}
|
||||
if full_res_only || r <= FULL_RES_RADIUS {
|
||||
return Axis { step: 1, pad: cap(3 * r + 1), radii: [r; 3] };
|
||||
}
|
||||
// On a coarse grid the three windows (2 rg + 1 grid pixels each) should still add up to
|
||||
// 3 (2r + 1) buffer pixels: of a few steps take the one that comes closest, and spread
|
||||
// the radii over the passes.
|
||||
let want = 3 * (2 * r + 1);
|
||||
let s0 = r.div_ceil(COARSE_RADIUS);
|
||||
let fit = |s: usize| -> (usize, usize) {
|
||||
let sum = ((want as f64 / s as f64 - 3.0) / 2.0).round().max(0.0) as usize;
|
||||
let got = (2 * sum + 3).saturating_mul(s);
|
||||
(sum, got.abs_diff(want))
|
||||
};
|
||||
let (step, sum) = (s0..=s0 + (s0 / 2).min(8)).map(|s| (s, fit(s))).min_by_key(|&(s, (_, e))| (e, s)).map_or((s0, fit(s0).0), |(s, (sum, _))| (s, sum));
|
||||
let radii = [0, 1, 2].map(|i| sum / 3 + usize::from(i < sum % 3));
|
||||
Axis { step, pad: sum + 1, radii }
|
||||
}
|
||||
|
||||
fn blurs(&self) -> bool {
|
||||
self.radii.iter().any(|&r| r > 0)
|
||||
}
|
||||
|
||||
/// The same blur on a grid `k` times coarser: radii and padding scaled down (a blurring
|
||||
/// axis keeps at least one grid pixel of radius), the padding still covering the support.
|
||||
fn coarser(self, k: usize) -> Axis {
|
||||
if k <= 1 {
|
||||
return self;
|
||||
}
|
||||
let blurs = self.blurs();
|
||||
let mut radii = self.radii.map(|r| (r as f64 / k as f64).round() as usize);
|
||||
if blurs && radii.iter().all(|&r| r == 0) {
|
||||
radii[0] = 1;
|
||||
}
|
||||
let support = radii.iter().fold(0usize, |a, &r| a.saturating_add(r)).saturating_add(1);
|
||||
let pad = if self.pad == 0 { 0 } else { self.pad.div_ceil(k).max(support) };
|
||||
Axis { step: self.step.saturating_mul(k), pad, radii }
|
||||
}
|
||||
|
||||
/// Grid pixels for `n` buffer pixels.
|
||||
fn len(&self, n: u32) -> Option<usize> {
|
||||
(n as usize).div_ceil(self.step).checked_add(self.pad.checked_mul(2)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resamples a grid coverage (`gw` wide) back to the `w x h` buffer: bilinear between the grid
|
||||
/// pixel centres (an exact copy when both steps are 1).
|
||||
fn crop(grid: &Mask, ax: Axis, ay: Axis, w: u32, h: u32) -> Mask {
|
||||
let (gw, gh) = (grid.width as usize, grid.height as usize);
|
||||
let mut out = Mask::new(w, h, 0.0);
|
||||
let at = |x: usize, y: usize| grid.data.get(y.saturating_mul(gw).saturating_add(x)).copied().unwrap_or(0.0);
|
||||
// Grid coordinate of a buffer pixel centre, as (index, fraction) on that axis.
|
||||
let map = |i: u32, a: Axis, n: usize| -> (usize, usize, f32) {
|
||||
let u = (i as f64 + 0.5) / a.step as f64 + a.pad as f64 - 0.5;
|
||||
let u = u.clamp(0.0, n.saturating_sub(1) as f64);
|
||||
let i0 = u.floor() as usize;
|
||||
(i0, (i0 + 1).min(n.saturating_sub(1)), (u - i0 as f64) as f32)
|
||||
};
|
||||
let xs: Vec<(usize, usize, f32)> = (0..w).map(|x| map(x, ax, gw)).collect();
|
||||
out.data.par_chunks_mut(w.max(1) as usize).enumerate().for_each(|(y, row)| {
|
||||
let (y0, y1, fy) = map(y as u32, ay, gh);
|
||||
for (o, &(x0, x1, fx)) in row.iter_mut().zip(&xs) {
|
||||
let top = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * fx;
|
||||
let bottom = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * fx;
|
||||
*o = top + (bottom - top) * fy;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option<Mask> {
|
||||
let Value::Path(sp) = ctx.group_value(layer, g, "path")? else { return None };
|
||||
let path = effectcraft_path::to_kurbo(&sp);
|
||||
let (w, h) = (buf.img.width, buf.img.height);
|
||||
let m = Mat3::translate(vec2(buf.offset[0], buf.offset[1])) * Mat3::scale(vec2(buf.scale, buf.scale));
|
||||
let exp = ctx.f(layer, g, "expansion", 0.0);
|
||||
let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. });
|
||||
let feather = ctx.v2(layer, g, "feather", [0.0; 2]);
|
||||
let feather = [feather[0] * buf.scale, feather[1] * buf.scale];
|
||||
Some(feathered(&sp, &m, (buf.img.width, buf.img.height), exp, feather, linear, MAX_GRID_PX))
|
||||
}
|
||||
|
||||
/// The padded grid for a `w x h` buffer: the axes as given, or coarsened together (by 2, 4, …)
|
||||
/// until the grid has at most `max_px` pixels. `None` without a blur (or, unreachably, when even
|
||||
/// the coarsest grid is too big).
|
||||
fn grid(w: u32, h: u32, ax: Axis, ay: Axis, max_px: usize) -> Option<(u32, u32, Axis, Axis)> {
|
||||
if !(ax.blurs() || ay.blurs()) {
|
||||
return None;
|
||||
}
|
||||
let mut k = 1usize;
|
||||
while k <= MAX_COARSEN {
|
||||
let (cx, cy) = (ax.coarser(k), ay.coarser(k));
|
||||
if let (Some(gw), Some(gh)) = (cx.len(w), cy.len(h))
|
||||
&& gw.checked_mul(gh).is_some_and(|n| n <= max_px)
|
||||
&& let (Ok(gw), Ok(gh)) = (u32::try_from(gw), u32::try_from(gh))
|
||||
{
|
||||
return Some((gw, gh, cx, cy));
|
||||
}
|
||||
k = k.saturating_mul(2);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Coverage of the mask path `sp` (layer -> buffer `m`) on a `w x h` buffer, expanded by `exp`
|
||||
/// and feathered by `feather` buffer pixels per axis, on a padded grid of at most `max_px` pixels.
|
||||
fn feathered(sp: &effectcraft_keyframe::ShapePath, m: &Mat3, (w, h): (u32, u32), exp: f64, feather: [f64; 2], linear: bool, max_px: usize) -> Mask {
|
||||
let path = effectcraft_path::to_kurbo(sp);
|
||||
// Variable-width feather (Mask Feather tool points): expansion and the per-point feather
|
||||
// are applied together from the signed distance to the path.
|
||||
let variable = !sp.feather.is_empty();
|
||||
// The feather blurs the mask's own edge, also where the path lies outside the layer (After
|
||||
// Effects: a feathered mask just below a layer still fades into it). So a feathered mask is
|
||||
// rasterised on a grid padded by the blur's reach, blurred there and cropped to the buffer.
|
||||
// A variable feather scales its point radii by the mean scale: it stays at full resolution
|
||||
// (unless the grid has to be coarsened to fit `max_px`).
|
||||
let (ax, ay) = (Axis::new(feather[0], linear, variable), Axis::new(feather[1], linear, variable));
|
||||
let blurred = ax.blurs() || ay.blurs();
|
||||
let grid = grid(w, h, ax, ay, max_px);
|
||||
// Without the grid (no blur, or, unreachably, a grid too big even at the coarsest step):
|
||||
// blurred within the buffer at its resolution.
|
||||
let unpadded = |f: f64| Axis { pad: 0, ..Axis::new(f, linear, true) };
|
||||
let (gw, gh, gx, gy) = grid.unwrap_or_else(|| (w, h, unpadded(feather[0]), unpadded(feather[1])));
|
||||
// Buffer pixels -> grid pixels.
|
||||
let to_grid = Mat3::scale(vec2(1.0 / gx.step as f64, 1.0 / gy.step as f64)) * Mat3::translate(vec2((gx.pad * gx.step) as f64, (gy.pad * gy.step) as f64));
|
||||
let mg = to_grid * *m;
|
||||
let mut cov = if variable {
|
||||
effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear)
|
||||
effectcraft_path::feather::variable_feather_coverage(sp, &mg, gw, gh, exp, linear)
|
||||
} else if exp.abs() > 0.01 {
|
||||
// Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right
|
||||
// when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a
|
||||
// ring around an uncovered middle (growing) or islands (shrinking).
|
||||
let grown = effectcraft_path::offset::offset_path(&path, exp, Join::Round, 4.0);
|
||||
effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &m, w, h, FillRule::NonZero)
|
||||
effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &mg, gw, gh, FillRule::NonZero)
|
||||
} else {
|
||||
effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero)
|
||||
effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mg, gw, gh, FillRule::NonZero)
|
||||
};
|
||||
let feather = ctx.v2(layer, g, "feather", [0.0; 2]);
|
||||
if feather[0] > 0.0 || feather[1] > 0.0 {
|
||||
let img = cov.to_image();
|
||||
let b = if linear {
|
||||
// One box pass: a straight ramp `feather` pixels wide across the edge.
|
||||
let r = |f: f64| ((f * buf.scale / 2.0).round() as usize).max(if f > 0.0 { 1 } else { 0 });
|
||||
box_blur(&img, r(feather[0]), r(feather[1]), 1, false)
|
||||
} else {
|
||||
gaussian_blur(&img, feather[0] * buf.scale / 2.0, feather[1] * buf.scale / 2.0, false)
|
||||
};
|
||||
cov = Mask::from_alpha(&b);
|
||||
if blurred {
|
||||
// Separable box passes (the corner of a feathered rect is the product of its edges).
|
||||
let mut img = cov.to_image();
|
||||
for (rx, ry) in gx.radii.into_iter().zip(gy.radii) {
|
||||
if rx > 0 || ry > 0 {
|
||||
img = box_blur(&img, rx, ry, 1, false);
|
||||
}
|
||||
}
|
||||
cov = Mask::from_alpha(&img);
|
||||
}
|
||||
Some(cov)
|
||||
if grid.is_some() {
|
||||
cov = crop(&cov, gx, gy, w, h);
|
||||
}
|
||||
cov
|
||||
}
|
||||
|
||||
/// The layer's enabled masks at the context time, flattened to polylines in layer space (for
|
||||
@@ -133,7 +306,9 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo
|
||||
if list.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Masks may extend past the source: grow the buffer to cover them when feather/expansion pads.
|
||||
// Masks may extend past the source: the buffer keeps its size (pixels outside the layer stay
|
||||
// empty), but a feathered mask is blurred on a padded grid, so the feather of an edge outside
|
||||
// the buffer still reaches into it (`coverage_at`).
|
||||
let n = (buf.img.width * buf.img.height) as usize;
|
||||
let first_sub = matches!(list[0].kind, GroupKind::Mask { mode: MaskMode::Subtract, .. });
|
||||
let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n];
|
||||
@@ -169,3 +344,48 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo
|
||||
buf.img.mul_mask(&acc);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_keyframe::ShapePath;
|
||||
|
||||
/// CodeRabbit on #1874: an 8192 x 8192 layer with a 40 px Smooth feather needs a padded
|
||||
/// grid larger than `MAX_GRID_PX`; it used to fall back to the unpadded buffer, so an Add mask
|
||||
/// just outside the layer covered nothing and the whole layer vanished. The grid is now
|
||||
/// coarsened instead and keeps a pad that covers the blur.
|
||||
#[test]
|
||||
fn a_grid_too_big_is_coarsened_and_keeps_its_padding() {
|
||||
let a = Axis::new(40.0, false, false);
|
||||
assert!(a.len(8192).zip(a.len(8192)).is_some_and(|(x, y)| x * y > MAX_GRID_PX), "the full-resolution grid is over the limit");
|
||||
let (gw, gh, gx, gy) = grid(8192, 8192, a, a, MAX_GRID_PX).expect("a padded grid");
|
||||
assert!((gw as usize) * (gh as usize) <= MAX_GRID_PX);
|
||||
for g in [gx, gy] {
|
||||
assert!(g.step > 1 && g.pad > 0 && g.blurs(), "{g:?}");
|
||||
// The pad covers the blur's support (the three boxes' radii) in buffer pixels.
|
||||
assert!(g.pad * g.step >= g.radii.iter().sum::<usize>() * g.step, "{g:?}");
|
||||
}
|
||||
// A grid that fits is not touched.
|
||||
assert_eq!(grid(200, 100, a, a, MAX_GRID_PX).map(|g| g.2), Some(a));
|
||||
}
|
||||
|
||||
/// The same failure at a testable size: a 200 x 100 buffer, a 40 px feather and a grid limit
|
||||
/// just below the full-resolution padded grid. The Add mask lies just right of the buffer
|
||||
/// (x 210..260); its feather still fades into the layer, close to the full-resolution result.
|
||||
#[test]
|
||||
fn a_coarsened_grid_still_feathers_a_mask_outside_the_buffer() {
|
||||
let sp = ShapePath::rect([235.0, 50.0], 50.0, 200.0);
|
||||
let m = Mat3::IDENTITY;
|
||||
let full = feathered(&sp, &m, (200, 100), 0.0, [40.0, 40.0], false, MAX_GRID_PX);
|
||||
let a = Axis::new(40.0, false, false);
|
||||
let limit = a.len(200).zip(a.len(100)).map_or(0, |(x, y)| x * y) - 1;
|
||||
let coarse = feathered(&sp, &m, (200, 100), 0.0, [40.0, 40.0], false, limit);
|
||||
let at = |c: &Mask, x: usize, y: usize| c.data.get(y * 200 + x).copied().unwrap_or(-1.0);
|
||||
assert!(at(&full, 199, 50) > 0.25, "full resolution: {}", at(&full, 199, 50));
|
||||
assert!(at(&coarse, 199, 50) > 0.2, "the soft fade survives the coarse grid: {}", at(&coarse, 199, 50));
|
||||
for x in [150, 170, 180, 190, 199] {
|
||||
assert!((at(&coarse, x, 50) - at(&full, x, 50)).abs() < 0.05, "x {x}: coarse {} full {}", at(&coarse, x, 50), at(&full, x, 50));
|
||||
}
|
||||
assert!(at(&coarse, 100, 50) < 0.01, "far from the mask stays clear");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,7 +396,10 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
}
|
||||
for i in order {
|
||||
let m = step(i as f64 + offset);
|
||||
let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 };
|
||||
// Copy i's opacity moves 1 / Copies of the way from Start to End Opacity per
|
||||
// copy, so the last copy stops one step short of End Opacity (After Effects
|
||||
// 2026, case 709: 3 copies, End Opacity 40 give 100 %, 80 %, 60 %).
|
||||
let t = if copies > 0.0 { (i as f64 / copies) as f32 } else { 0.0 };
|
||||
let opk = so + (eo - so) * t;
|
||||
let partial = if i + 1 == n && copies.fract() > 0.0 { copies.fract() as f32 } else { 1.0 };
|
||||
for d in &base_draws {
|
||||
|
||||
@@ -150,6 +150,28 @@ fn operators_below_repeater_act_on_copies() {
|
||||
assert!((coverage(&img) - 3.0 * 24.0 * 24.0).abs() < 30.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
/// After Effects 2026 (case 709): a 400 × 200 rectangle with Fill, then a Repeater of 3 copies
|
||||
/// (Position 150, 60, Rotation 10, Scale 90 / 80, End Opacity 40) gives the copies 100 %, 80 %
|
||||
/// and 60 % opacity (EffectCraft gave 70 % and 40 %). The points are comp pixels where AE shows
|
||||
/// only the last copy, only the middle one, and both.
|
||||
#[test]
|
||||
fn repeater_copy_opacity_matches_after_effects() {
|
||||
let (p, cid) = shape_comp_sized(1920, 1080, |ids| {
|
||||
let mut rep = build::shape_repeater(ids, 3.0, [150.0, 60.0]);
|
||||
set(&mut rep, "transform/rotation", Value::Scalar(10.0));
|
||||
set(&mut rep, "transform/scale", Value::Vec2([90.0, 80.0]));
|
||||
set(&mut rep, "transform/endOpacity", Value::Scalar(40.0));
|
||||
let items = vec![build::shape_rect(ids, [400.0, 200.0], [0.0, 0.0], 0.0), build::shape_fill(ids, [0.0, 1.0, 0.0, 1.0]), rep];
|
||||
vec![build::shape_group(ids, "Group 1", items)]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
// AE's green over its red layer, 153, 153, 204, 235 and 255 of 255.
|
||||
for (x, y, want) in [(1360, 700, 0.6), (1300, 620, 0.6), (1220, 560, 0.8), (1180, 630, 0.92), (1100, 600, 1.0)] {
|
||||
let a = alpha(&img, x, y);
|
||||
assert!((a - want).abs() < 2.0 / 255.0, "({x}, {y}): {a} vs AE {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeater_then_merge_unites_copies() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
|
||||
@@ -16,6 +16,7 @@ mod frame_blend;
|
||||
mod guides;
|
||||
mod mask_blur;
|
||||
mod mask_expansion;
|
||||
mod mask_feather;
|
||||
mod motion_tile;
|
||||
mod nested;
|
||||
mod paint;
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//! A feathered mask blurs its own edge, also where the path lies outside the layer: the feather
|
||||
//! of a mask partly or wholly outside the layer reaches into it (After Effects), instead of the
|
||||
//! coverage stopping at the layer edge.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{ShapePath, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{BitDepth, Comp, FeatherFalloff, GroupKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, Node, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::render_frame;
|
||||
|
||||
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32) -> Layer {
|
||||
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
|
||||
build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None)
|
||||
}
|
||||
|
||||
fn add_mask(p: &mut Project, l: &mut Layer, rect: ShapePath, feather: [f64; 2], linear: bool) {
|
||||
let mut next = p.next_id;
|
||||
let mut m = build::mask(&mut Ids(&mut next), "Mask 1", rect, MaskMode::Add, [255, 255, 0]);
|
||||
p.next_id = next;
|
||||
m.get_mut("feather").unwrap().value = Value::Vec2(feather);
|
||||
if linear && let GroupKind::Mask { feather_falloff, .. } = &mut m.kind {
|
||||
*feather_falloff = FeatherFalloff::Linear;
|
||||
}
|
||||
l.props.sub_mut("masks").unwrap().children.push(Node::Group(m));
|
||||
}
|
||||
|
||||
/// Coverage at signed distance `d` inside an edge blurred by three box passes `w` pixels wide
|
||||
/// (the Smooth falloff: an Irwin-Hall n = 3 distribution function, support +-1.5 w).
|
||||
fn edge(d: f64, w: f64) -> f64 {
|
||||
let t = (d / w + 1.5).clamp(0.0, 3.0);
|
||||
if t < 1.0 {
|
||||
t * t * t / 6.0
|
||||
} else if t < 2.0 {
|
||||
(-2.0 * t * t * t + 9.0 * t * t - 9.0 * t + 3.0) / 6.0
|
||||
} else {
|
||||
1.0 - (3.0 - t).powi(3) / 6.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The box window of the Smooth falloff for `feather` pixels: `2 round(0.37 feather) + 1`.
|
||||
fn window(feather: f64) -> f64 {
|
||||
2.0 * (0.37 * feather).round() + 1.0
|
||||
}
|
||||
|
||||
/// Coverage at pixel centre `x` of the span `a..b` feathered by `feather` (one axis).
|
||||
fn span(x: f64, a: f64, b: f64, feather: f64) -> f64 {
|
||||
let w = window(feather);
|
||||
edge(x - a, w) + edge(b - x, w) - 1.0
|
||||
}
|
||||
|
||||
/// A white 200×100 solid (32 bpc) with one Add mask.
|
||||
fn white_with_mask(rect: ShapePath, feather: [f64; 2], linear: bool, scale: f64) -> effectcraft_raster::Image {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = BitDepth::Bpc32;
|
||||
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let mut l = solid(&mut p, &comp, [1.0; 3], 200, 100);
|
||||
add_mask(&mut p, &mut l, rect, feather, linear);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
render_frame(&p, cid, Tick::ZERO, scale)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feathered_mask_outside_a_solid_reaches_into_it() {
|
||||
// The mask spans x 210..260, right of the layer (x 0..200); feather 40 = three 31 px boxes.
|
||||
let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 1.0);
|
||||
for x in [150i64, 170, 180, 199] {
|
||||
let want = span(x as f64 + 0.5, 210.0, 260.0, 40.0) * span(50.5, -50.0, 150.0, 40.0);
|
||||
let got = img.get(x, 50)[3] as f64;
|
||||
assert!((got - want).abs() < 0.01, "x {x}: alpha {got}, the box kernel gives {want}");
|
||||
}
|
||||
assert!(img.get(199, 50)[3] > 0.25, "the feather reaches the layer edge");
|
||||
// Finite support: nothing beyond 1.5 windows (46.5 px) from the edge.
|
||||
assert!(img.get(160, 50)[3] == 0.0 && img.get(100, 50)[3] == 0.0);
|
||||
// Without feather the mask hides the layer.
|
||||
let hard = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [0.0, 0.0], false, 1.0);
|
||||
assert!(hard.data.iter().all(|p| p[3] == 0.0));
|
||||
// A linear falloff ramps over the feather width: 20 px either side of the edge at x 210.
|
||||
let lin = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], true, 1.0);
|
||||
let a = lin.get(199, 50)[3];
|
||||
assert!((a - 0.24).abs() < 0.04, "linear: {a}");
|
||||
assert!(lin.get(185, 50)[3] < 0.01);
|
||||
// At half resolution the same layer pixel gets the same coverage.
|
||||
let half = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 0.5);
|
||||
assert!((half.get(99, 25)[3] - img.get(198, 50)[3]).abs() < 0.02, "{} {}", half.get(99, 25)[3], img.get(198, 50)[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feathered_mask_larger_than_the_layer_keeps_the_layer_edges() {
|
||||
// The mask covers the layer and 100 px around it: the layer edges are inside the mask and
|
||||
// stay opaque (they used to fade, as if the layer edge were the mask edge).
|
||||
let img = white_with_mask(ShapePath::rect([100.0, 50.0], 400.0, 300.0), [40.0, 40.0], false, 1.0);
|
||||
for (x, y) in [(0i64, 0i64), (0, 50), (199, 99), (100, 0)] {
|
||||
assert!(img.get(x, y)[3] > 0.99, "({x}, {y}): {}", img.get(x, y)[3]);
|
||||
}
|
||||
// A mask edge inside the layer is feathered as before: half coverage on the edge.
|
||||
let inner = white_with_mask(ShapePath::rect([100.0, 50.0], 100.0, 400.0), [40.0, 40.0], false, 1.0);
|
||||
assert!((inner.get(50, 50)[3] - 0.5).abs() < 0.03, "{}", inner.get(50, 50)[3]);
|
||||
assert!(inner.get(100, 50)[3] > 0.98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_and_broken_feathers_stay_bounded() {
|
||||
for f in [1.0e5, 1.0e9, f64::INFINITY, f64::NAN, -5.0] {
|
||||
let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [f, f], false, 1.0);
|
||||
assert!(img.data.iter().all(|p| p[3].is_finite() && (0.0..=1.0).contains(&p[3])), "feather {f}");
|
||||
let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [f, 10.0], true, 1.0);
|
||||
assert!(img.data.iter().all(|p| p[3].is_finite() && (0.0..=1.0).contains(&p[3])), "linear feather {f}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Case 398 of the .aep corpus, built in EffectCraft's own model: a 1920×1080 comp at 8 bpc, a
|
||||
/// red solid, above it an adjustment layer of comp size with a Gradient Ramp at its defaults and
|
||||
/// one Add mask entirely below the layer (1920×200 centred at [960, 1250]) with feather 1000.
|
||||
fn case_398(with_mask: bool) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
assert_eq!(p.settings.bit_depth, BitDepth::Bpc8, "After Effects' default depth");
|
||||
let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(5.0));
|
||||
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080);
|
||||
let mut adj = solid(&mut p, &comp, [1.0; 3], 1920, 1080);
|
||||
adj.switches.adjustment = true;
|
||||
let spec = effectcraft_effects::find("ec.generate.gradientramp").unwrap();
|
||||
let mut next = p.next_id;
|
||||
let ramp = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]);
|
||||
p.next_id = next;
|
||||
adj.props.sub_mut("effects").unwrap().children.push(ramp.into());
|
||||
if with_mask {
|
||||
add_mask(&mut p, &mut adj, ShapePath::rect([960.0, 1250.0], 1920.0, 200.0), [1000.0, 1000.0], false);
|
||||
}
|
||||
let c = p.comp_mut(cid).unwrap();
|
||||
c.layers.push(adj);
|
||||
c.layers.push(red);
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn rgb8(img: &effectcraft_raster::Image, x: i64, y: i64) -> [i32; 3] {
|
||||
let px = img.get(x, y);
|
||||
[0, 1, 2].map(|c| (px[c].clamp(0.0, 1.0) * 255.0).round() as i32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_398_a_feathered_mask_below_an_adjustment_layer_reaches_into_it() {
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let (p, cid) = case_398(true);
|
||||
let img = render_frame(&p, cid, t, 1.0);
|
||||
// The ramp alone (no mask): what the adjustment layer mixes in.
|
||||
let (q, qid) = case_398(false);
|
||||
let ramp = render_frame(&q, qid, t, 1.0);
|
||||
// After Effects 2026, 8 bpc, the render of 398 at 1 s.
|
||||
let ae = [
|
||||
((960, 0), [255, 0, 0]),
|
||||
((960, 300), [254, 0, 0]),
|
||||
((960, 540), [250, 5, 5]),
|
||||
((960, 800), [248, 20, 20]),
|
||||
((960, 1000), [252, 40, 40]),
|
||||
((960, 1079), [255, 47, 47]),
|
||||
((100, 1079), [255, 29, 29]),
|
||||
];
|
||||
let mut report = Vec::new();
|
||||
for ((x, y), want_ae) in ae {
|
||||
let got = rgb8(&img, x, y);
|
||||
// 1. The coverage is the mask's Smooth falloff (three 741 px boxes for feather 1000),
|
||||
// blurred from the mask edge outside the layer (on the coarse grid); the result is the
|
||||
// red mixed toward the ramp by it.
|
||||
let c = span(x as f64 + 0.5, 0.0, 1920.0, 1000.0) * span(y as f64 + 0.5, 1150.0, 1350.0, 1000.0);
|
||||
let r = ramp.get(x, y);
|
||||
let want = [0, 1, 2].map(|k| {
|
||||
let red = if k == 0 { 1.0 } else { 0.0 };
|
||||
((red + (r[k] as f64 - red) * c) * 255.0).round() as i32
|
||||
});
|
||||
for k in 0..3 {
|
||||
assert!((got[k] - want[k]).abs() <= 2, "({x}, {y}): {got:?}, the box coverage {c:.4} gives {want:?}");
|
||||
}
|
||||
// 2. After Effects: within 2 levels per channel.
|
||||
for k in 0..3 {
|
||||
assert!((got[k] - want_ae[k]).abs() <= 2, "({x}, {y}): EffectCraft {got:?}, After Effects {want_ae:?}");
|
||||
}
|
||||
report.push(format!("({x},{y}) ec {got:?} ae {want_ae:?}"));
|
||||
}
|
||||
println!("398: {}", report.join("; "));
|
||||
// The mask covers nothing of the layer itself, but its feather does: the top stays (nearly) red.
|
||||
assert!(rgb8(&img, 960, 0)[0] >= 253 && rgb8(&img, 960, 0)[1] == 0);
|
||||
assert!(rgb8(&img, 960, 1079)[1] > 25, "the feather reaches the bottom edge");
|
||||
}
|
||||
|
||||
/// After Effects 2026 (sRGB, 32 bpc): a red 1920x1080 solid with a 1000x600 Add mask centred at
|
||||
/// [960, 540] (left edge at x 460), feather `f` on both axes; coverage along the row y 540 from
|
||||
/// x 160 to 760 in steps of 20 (8-bit values / 255).
|
||||
const AE_FEATHER_100: [(i64, f64); 11] =
|
||||
[(360, 0.0), (380, 0.016), (400, 0.059), (420, 0.153), (440, 0.31), (460, 0.506), (480, 0.698), (500, 0.855), (520, 0.945), (540, 0.988), (560, 1.0)];
|
||||
const AE_FEATHER_300: [(i64, f64); 31] = [
|
||||
(160, 0.0),
|
||||
(180, 0.004),
|
||||
(200, 0.008),
|
||||
(220, 0.012),
|
||||
(240, 0.024),
|
||||
(260, 0.035),
|
||||
(280, 0.055),
|
||||
(300, 0.078),
|
||||
(320, 0.11),
|
||||
(340, 0.149),
|
||||
(360, 0.192),
|
||||
(380, 0.247),
|
||||
(400, 0.306),
|
||||
(420, 0.369),
|
||||
(440, 0.435),
|
||||
(460, 0.502),
|
||||
(480, 0.569),
|
||||
(500, 0.635),
|
||||
(520, 0.698),
|
||||
(540, 0.757),
|
||||
(560, 0.808),
|
||||
(580, 0.851),
|
||||
(600, 0.89),
|
||||
(620, 0.922),
|
||||
(640, 0.945),
|
||||
(660, 0.965),
|
||||
(680, 0.976),
|
||||
(700, 0.988),
|
||||
(720, 0.992),
|
||||
(740, 0.996),
|
||||
(760, 1.0),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn the_smooth_feather_falloff_matches_after_effects() {
|
||||
for (f, ae) in [(100.0, &AE_FEATHER_100[..]), (300.0, &AE_FEATHER_300[..])] {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = BitDepth::Bpc32;
|
||||
let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080);
|
||||
add_mask(&mut p, &mut l, ShapePath::rect([960.0, 540.0], 1000.0, 600.0), [f, f], false);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
let mut worst = 0.0f64;
|
||||
for &(x, want) in ae {
|
||||
let got = img.get(x, 540)[3] as f64;
|
||||
// Tolerance 2/255 on the 8-bit After Effects values.
|
||||
assert!((got - want).abs() <= 2.0 / 255.0, "feather {f}, x {x}: {got:.4}, After Effects {want}");
|
||||
worst = worst.max((got - want).abs());
|
||||
}
|
||||
// The corner is the product of its two edges (a separable blur): 0.25 for both feathers.
|
||||
let corner = img.get(460, 240)[3] as f64;
|
||||
assert!((corner - 0.251).abs() <= 2.0 / 255.0, "feather {f}: corner {corner}");
|
||||
println!("feather {f}: largest deviation from After Effects {:.2}/255", worst * 255.0);
|
||||
}
|
||||
}
|
||||
@@ -241,6 +241,53 @@ fn path_tangent(ctx: &EvalCtx, layer: &Layer) -> Option<Vec3> {
|
||||
.filter(|k| **k <= ctx.time)
|
||||
.find_map(|&k| dir(pos(k) - pos(k - dt)))
|
||||
.or_else(|| keys.iter().filter(|k| **k > ctx.time).find_map(|&k| dir(pos(k + dt) - pos(k))))
|
||||
.or_else(|| {
|
||||
// An expression has no keys to fall back on: sample it instead.
|
||||
let expr = dims.iter().any(|m| tr.get(m).is_some_and(|p| p.has_expression()));
|
||||
expr.then(|| sampled_tangent(ctx, layer, &pos, dt, true).or_else(|| sampled_tangent(ctx, layer, &pos, dt, false))).flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// Samples spent on each phase of [`sampled_tangent`]: it runs per layer per frame.
|
||||
const MOTION_SEARCH_STEPS: u32 = 12;
|
||||
|
||||
/// Direction of the motion nearest to the context time, before it (`earlier`) or after it, for a
|
||||
/// position with no keyframes to fall back on (an expression). Steps 1, 2, 4, ... frames away
|
||||
/// (within the layer's in/out points) until the position differs from the current one, then
|
||||
/// bisects down to `dt` the moment the motion stopped (or started), so the heading kept is the
|
||||
/// one at the end of the motion whichever frame is shown.
|
||||
fn sampled_tangent(ctx: &EvalCtx, layer: &Layer, pos: &dyn Fn(effectcraft_time::Tick) -> Vec3, dt: effectcraft_time::Tick, earlier: bool) -> Option<Vec3> {
|
||||
let here = pos(ctx.time);
|
||||
let moved = |t| (pos(t) - here).length() > 1e-9;
|
||||
let limit = if earlier { layer.in_point.min(ctx.time) } else { layer.out_point.max(ctx.time) };
|
||||
let mut step = ctx.comp.frame_duration().max(dt);
|
||||
let (mut still, mut away) = (ctx.time, None);
|
||||
for _ in 0..MOTION_SEARCH_STEPS {
|
||||
let t = if earlier { (ctx.time - step).max(limit) } else { (ctx.time + step).min(limit) };
|
||||
if moved(t) {
|
||||
away = Some(t);
|
||||
break;
|
||||
}
|
||||
still = t;
|
||||
if t == limit {
|
||||
break;
|
||||
}
|
||||
step = step + step;
|
||||
}
|
||||
let mut away = away?;
|
||||
for _ in 0..MOTION_SEARCH_STEPS {
|
||||
if (still - away).abs() <= dt {
|
||||
break;
|
||||
}
|
||||
let mid = away + (still - away).mul_ratio(1, 2);
|
||||
if moved(mid) {
|
||||
away = mid;
|
||||
} else {
|
||||
still = mid;
|
||||
}
|
||||
}
|
||||
let d = if earlier { here - pos(away) } else { pos(away) - here };
|
||||
(d.length() > 1e-9 && d.length().is_finite()).then(|| d.normalize())
|
||||
}
|
||||
|
||||
/// Auto-Orient ▸ Orient Along Path for 2D layers: extra Z rotation in degrees.
|
||||
|
||||
@@ -8,6 +8,7 @@ use effectcraft_project::{AutoOrient, Comp, ItemId, ItemKind, Layer, LayerSource
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use super::active_camera;
|
||||
use super::camera::auto_orient_2d;
|
||||
use crate::EvalCtx;
|
||||
|
||||
fn setup() -> (Project, ItemId, Comp) {
|
||||
@@ -78,6 +79,69 @@ fn orient_along_path_keeps_the_last_heading_at_and_after_the_end() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1650: the same end-of-path flip when Position has no keyframes of its own and is driven by
|
||||
/// an expression (e.g. `pointOnPath()` fed by a keyframed slider on another layer): once the
|
||||
/// motion stops there are no Position keys to fall back on, and the heading snapped to 0 degrees.
|
||||
#[test]
|
||||
fn orient_along_path_keeps_the_last_heading_when_position_is_an_expression() {
|
||||
/// Stand-in for an expression: holds, moves 100 px left over 0.5..1 s, then holds again (like
|
||||
/// a slider going from 0% to 100%).
|
||||
struct LeftThenHold;
|
||||
impl crate::ExprHost for LeftThenHold {
|
||||
fn eval(&self, ctx: &EvalCtx, _: &Layer, prop: &effectcraft_project::Property, value: &Value) -> Result<Value, String> {
|
||||
if prop.match_id != "position" {
|
||||
return Ok(value.clone());
|
||||
}
|
||||
let s = ((ctx.time.seconds() - 0.5) * 2.0).clamp(0.0, 1.0);
|
||||
Ok(Value::Vec3([150.0 - 100.0 * s, 50.0, 0.0]))
|
||||
}
|
||||
}
|
||||
for three_d in [false, true] {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, three_d, [150.0, 50.0, 0.0]);
|
||||
l.auto_orient = AutoOrient::AlongPath;
|
||||
let pos = l.props.prop_mut("transform/position").unwrap();
|
||||
assert!(pos.keys.is_empty(), "the fixture must have no Position keyframes");
|
||||
pos.expr = Some(effectcraft_project::Expression { text: "left then hold".into(), enabled: true });
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let c = p.comp(cid).unwrap();
|
||||
for secs in [0.2, 0.75, 1.0, 1.0 + 1.0 / 30.0, 1.2, 1.9] {
|
||||
let ctx = EvalCtx { expr: Some(&LeftThenHold), ..EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(secs)) };
|
||||
let x_axis = ctx.world_matrix(&c.layers[0]).apply_vec(vec3(1.0, 0.0, 0.0));
|
||||
// Moving left (-x): the layer's x axis points left before the motion starts and after it stops.
|
||||
assert!((x_axis.x + 1.0).abs() < 1e-6 && x_axis.y.abs() < 1e-6 && x_axis.z.abs() < 1e-6, "3d={three_d} t={secs}: {x_axis:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1650 on a curved path: the heading kept once an expression stops moving is the one at the end
|
||||
/// of the motion, the same on every later frame, not whatever earlier point a coarse search lands on.
|
||||
#[test]
|
||||
fn orient_along_a_curved_expression_path_keeps_the_end_heading() {
|
||||
/// Quarter circle over 0..1 s: heading 0 degrees at the start, 90 degrees (down) at the end.
|
||||
struct Arc;
|
||||
impl crate::ExprHost for Arc {
|
||||
fn eval(&self, ctx: &EvalCtx, _: &Layer, prop: &effectcraft_project::Property, value: &Value) -> Result<Value, String> {
|
||||
if prop.match_id != "position" {
|
||||
return Ok(value.clone());
|
||||
}
|
||||
let a = ctx.time.seconds().clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2;
|
||||
Ok(Value::Vec3([100.0 * a.sin(), 100.0 - 100.0 * a.cos(), 0.0]))
|
||||
}
|
||||
}
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, false, [0.0, 0.0, 0.0]);
|
||||
l.auto_orient = AutoOrient::AlongPath;
|
||||
l.props.prop_mut("transform/position").unwrap().expr = Some(effectcraft_project::Expression { text: "arc".into(), enabled: true });
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let c = p.comp(cid).unwrap();
|
||||
for frame in [31, 33, 37, 45, 59] {
|
||||
let ctx = EvalCtx { expr: Some(&Arc), ..EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(frame as f64 / 30.0)) };
|
||||
let deg = auto_orient_2d(&ctx, &c.layers[0]);
|
||||
assert!((deg - 90.0).abs() < 0.5, "frame {frame}: {deg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_parented_to_a_camera_follow_its_rotation() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
|
||||
@@ -86,3 +86,27 @@ fn open_panels_open_again_at_the_next_launch() {
|
||||
let mut s = session(&store);
|
||||
assert_eq!(s.execute("window.restoreScriptPanels", json!({})).unwrap(), json!({"opened": [], "failed": []}));
|
||||
}
|
||||
|
||||
/// A panel in a subfolder (Window ▸ Motion Kit ▸ Animate.jsx) is `Motion Kit/Animate.jsx`: it opens
|
||||
/// titled after its file and opens again at the next launch.
|
||||
#[test]
|
||||
fn a_panel_in_a_subfolder_opens_and_opens_again_at_the_next_launch() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
store.write(&format!("{PANELS_DIR}/Motion Kit/Animate.jsx"), PANEL).unwrap();
|
||||
let mut s = session(&store);
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}");
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap();
|
||||
let w = s.script_ui.window(r["window"].as_u64().unwrap() as u32).unwrap();
|
||||
assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Animate"));
|
||||
assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]}));
|
||||
// A saved name that leaves the folder, or goes two folders deep, fails and is forgotten.
|
||||
store.write(OPEN_PANELS_FILE, &json!({"open": ["../Top.jsx", "Motion Kit/Animate.jsx", "a/b/c.jsx"]}).to_string()).unwrap();
|
||||
drop(s);
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("window.restoreScriptPanels", json!({})).unwrap();
|
||||
assert_eq!(r["opened"], json!(["Motion Kit/Animate.jsx"]));
|
||||
assert_eq!(r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect::<Vec<_>>(), ["../Top.jsx", "a/b/c.jsx"]);
|
||||
assert!(panel_window(&s, "Motion Kit/Animate.jsx").is_some());
|
||||
assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]}));
|
||||
}
|
||||
|
||||
@@ -326,9 +326,29 @@ fn dot_n(a: &[f64], b: &[f64]) -> f64 {
|
||||
/// Solve `A x = b` for a symmetric positive definite `A` (`n × n`, row-major) in place by
|
||||
/// Cholesky factorisation. Returns false when `A` is not positive definite.
|
||||
pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool {
|
||||
if n.checked_mul(n) != Some(a.len()) || b.len() != n {
|
||||
return false;
|
||||
}
|
||||
let mut band = n.saturating_sub(1);
|
||||
if b.iter().all(|v| v.is_finite()) {
|
||||
band = 0;
|
||||
'bandwidth: for i in (0..n).rev() {
|
||||
for j in 0..i.saturating_sub(band) {
|
||||
if a[i * n + j] != 0.0 {
|
||||
band = i - j;
|
||||
// Wide systems (including shared global parameters) use the dense path.
|
||||
if band >= n / 2 {
|
||||
band = n.saturating_sub(1);
|
||||
break 'bandwidth;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for j in 0..n {
|
||||
let mut d = a[j * n + j];
|
||||
for k in 0..j {
|
||||
for k in j.saturating_sub(band)..j {
|
||||
d -= a[j * n + k] * a[j * n + k];
|
||||
}
|
||||
if d <= 0.0 || !d.is_finite() {
|
||||
@@ -339,14 +359,24 @@ pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool {
|
||||
let (head, tail) = a.split_at_mut((j + 1) * n);
|
||||
let rowj = &head[j * n..j * n + j];
|
||||
// Rows below j (sequential: per-column parallel dispatch costs more than it saves).
|
||||
for row in tail.chunks_mut(n) {
|
||||
row[j] = (row[j] - dot_n(&row[..j], rowj)) / d;
|
||||
for (off, row) in tail.chunks_mut(n).take(band).enumerate() {
|
||||
// Removing full groups of four zero products preserves dot_n's lane order.
|
||||
let start = (j + 1 + off).saturating_sub(band) / 4 * 4;
|
||||
row[j] = (row[j] - dot_n(&row[start..j], &rowj[start..])) / d;
|
||||
}
|
||||
}
|
||||
// Forward: L y = b.
|
||||
for i in 0..n {
|
||||
let mut s = b[i];
|
||||
for k in 0..i {
|
||||
let start = i.saturating_sub(band);
|
||||
// Subtracting signed-zero products can change -0.0 to +0.0. Preserve those
|
||||
// operations when the accumulator is zero; nonzero values are unaffected.
|
||||
if s == 0.0 {
|
||||
for k in 0..start {
|
||||
s -= a[i * n + k] * b[k];
|
||||
}
|
||||
}
|
||||
for k in start..i {
|
||||
s -= a[i * n + k] * b[k];
|
||||
}
|
||||
b[i] = s / a[i * n + i];
|
||||
@@ -354,9 +384,15 @@ pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool {
|
||||
// Back: Lᵀ x = y.
|
||||
for i in (0..n).rev() {
|
||||
let mut s = b[i];
|
||||
for k in i + 1..n {
|
||||
let end = i.saturating_add(band).saturating_add(1).min(n);
|
||||
for k in i + 1..end {
|
||||
s -= a[k * n + i] * b[k];
|
||||
}
|
||||
if s == 0.0 {
|
||||
for k in end..n {
|
||||
s -= a[k * n + i] * b[k];
|
||||
}
|
||||
}
|
||||
b[i] = s / a[i * n + i];
|
||||
}
|
||||
b.iter().all(|v| v.is_finite())
|
||||
@@ -437,6 +473,119 @@ pub fn from_euler_xyz(e: V3) -> M3 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dense_cholesky_reference(a: &mut [f64], n: usize, b: &mut [f64]) -> bool {
|
||||
for j in 0..n {
|
||||
let mut d = a[j * n + j];
|
||||
for k in 0..j {
|
||||
d -= a[j * n + k] * a[j * n + k];
|
||||
}
|
||||
if d <= 0.0 || !d.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let d = d.sqrt();
|
||||
a[j * n + j] = d;
|
||||
let (head, tail) = a.split_at_mut((j + 1) * n);
|
||||
let rowj = &head[j * n..j * n + j];
|
||||
// Rows below j (sequential: per-column parallel dispatch costs more than it saves).
|
||||
for row in tail.chunks_mut(n) {
|
||||
row[j] = (row[j] - dot_n(&row[..j], rowj)) / d;
|
||||
}
|
||||
}
|
||||
// Forward: L y = b.
|
||||
for i in 0..n {
|
||||
let mut s = b[i];
|
||||
for k in 0..i {
|
||||
s -= a[i * n + k] * b[k];
|
||||
}
|
||||
b[i] = s / a[i * n + i];
|
||||
}
|
||||
// Back: Lᵀ x = y.
|
||||
for i in (0..n).rev() {
|
||||
let mut s = b[i];
|
||||
for k in i + 1..n {
|
||||
s -= a[k * n + i] * b[k];
|
||||
}
|
||||
b[i] = s / a[i * n + i];
|
||||
}
|
||||
b.iter().all(|v| v.is_finite())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banded_cholesky_is_bit_identical_to_dense() {
|
||||
for n in [1usize, 2, 3, 4, 5, 6, 7, 8, 13, 31, 65, 127] {
|
||||
for width in [0usize, 1, 2, 5, 11, n - 1] {
|
||||
for seed in 0..3 {
|
||||
for scale in [1.0, 1e-100, 1e100] {
|
||||
let mut a = vec![0.0; n * n];
|
||||
for i in 0..n {
|
||||
for j in 0..i {
|
||||
let value = if i - j <= width {
|
||||
(((i * 13 + j * 17 + seed) % 23) as f64 - 11.0) / 128.0
|
||||
} else if (i + j) % 2 == 0 {
|
||||
-0.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
a[i * n + j] = value;
|
||||
a[j * n + i] = value;
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
a[i * n + i] = a[i * n..(i + 1) * n].iter().map(|v| v.abs()).sum::<f64>() + 1.0;
|
||||
}
|
||||
for value in &mut a {
|
||||
*value *= scale;
|
||||
}
|
||||
let mut rhs: Vec<f64> = (0..n).map(|i| if i % 7 == 0 { -0.0 } else { ((i * 5 + seed) % 17) as f64 - 8.0 }).collect();
|
||||
let (mut reference_a, mut reference_rhs) = (a.clone(), rhs.clone());
|
||||
assert!(dense_cholesky_reference(&mut reference_a, n, &mut reference_rhs));
|
||||
assert!(cholesky_solve(&mut a, n, &mut rhs));
|
||||
assert_eq!(
|
||||
rhs.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
|
||||
reference_rhs.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
|
||||
"n={n} width={width} seed={seed} scale={scale}"
|
||||
);
|
||||
assert_eq!(
|
||||
a.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
|
||||
reference_a.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
|
||||
"factor n={n} width={width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banded_cholesky_preserves_underflow_and_signed_zero() {
|
||||
let n = 8;
|
||||
let mut a = vec![0.0; n * n];
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
a[i * n + j] = if i == j {
|
||||
1e300
|
||||
} else if (i + j) % 2 == 0 {
|
||||
-0.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
}
|
||||
let mut rhs = vec![-1e-300, 1e-300, -0.0, 0.0, -1e-300, 1e-300, 0.0, -0.0];
|
||||
let (mut original, mut expected) = (a.clone(), rhs.clone());
|
||||
assert!(dense_cholesky_reference(&mut original, n, &mut expected));
|
||||
assert!(cholesky_solve(&mut a, n, &mut rhs));
|
||||
assert_eq!(rhs.iter().map(|v| v.to_bits()).collect::<Vec<_>>(), expected.iter().map(|v| v.to_bits()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cholesky_rejects_invalid_dimensions_without_indexing() {
|
||||
assert!(!cholesky_solve(&mut [], usize::MAX, &mut []));
|
||||
assert!(!cholesky_solve(&mut [1.0], 2, &mut [1.0, 1.0]));
|
||||
assert!(!cholesky_solve(&mut [1.0], 1, &mut []));
|
||||
assert!(cholesky_solve(&mut [], 0, &mut []));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svd_reconstructs() {
|
||||
let a = [3.0, 1.0, 2.0, -1.0, 4.0, 0.5, 2.0, 2.0, 7.0, 0.1, -3.0, 1.0];
|
||||
|
||||
+157
-14
@@ -254,6 +254,42 @@ impl Default for LkOpts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bilinear taps shared by every pixel of a `(2r+1)²` window centred at `c`: the fractional
|
||||
/// offset is the same for the whole window, and the column/row indices are clamped like
|
||||
/// [`Plane::at`] (edges repeated). Row indices are pre-multiplied by the plane width.
|
||||
#[derive(Default)]
|
||||
struct Taps {
|
||||
xs: Vec<[usize; 2]>,
|
||||
ys: Vec<[usize; 2]>,
|
||||
t: [f32; 2],
|
||||
}
|
||||
|
||||
impl Taps {
|
||||
fn set(&mut self, p: &Plane, c: [f64; 2], r: i32) {
|
||||
debug_assert_eq!(p.ch, 1);
|
||||
let (fx, fy) = (c[0] - 0.5, c[1] - 0.5);
|
||||
let (x0, y0) = (fx.floor(), fy.floor());
|
||||
self.t = [(fx - x0) as f32, (fy - y0) as f32];
|
||||
const FAR: f64 = (1u64 << 40) as f64;
|
||||
let (xi, yi) = (x0.clamp(-FAR, FAR) as isize, y0.clamp(-FAR, FAR) as isize);
|
||||
let (wm, hm) = (p.w as isize - 1, p.h as isize - 1);
|
||||
let r = r as isize;
|
||||
self.xs.clear();
|
||||
self.ys.clear();
|
||||
self.xs.extend((-r..=r).map(|d| [(xi + d).clamp(0, wm) as usize, (xi + d + 1).clamp(0, wm) as usize]));
|
||||
self.ys.extend((-r..=r).map(|d| [(yi + d).clamp(0, hm) as usize * p.w, (yi + d + 1).clamp(0, hm) as usize * p.w]));
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Plane::sample`]'s bilinear form on pre-resolved taps.
|
||||
#[inline(always)]
|
||||
fn bilerp(d: &[f32], x: [usize; 2], y: [usize; 2], t: [f32; 2]) -> f32 {
|
||||
let (a, b, c, e) = (d[y[0] + x[0]], d[y[0] + x[1]], d[y[1] + x[0]], d[y[1] + x[1]]);
|
||||
let top = a + (b - a) * t[0];
|
||||
let bot = c + (e - c) * t[0];
|
||||
top + (bot - top) * t[1]
|
||||
}
|
||||
|
||||
fn lk_point(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: &LkOpts) -> Option<[f64; 2]> {
|
||||
let top = a.levels.len().min(b.levels.len()).checked_sub(1)?;
|
||||
let r = o.radius;
|
||||
@@ -263,7 +299,7 @@ fn lk_point(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: &
|
||||
let mut win = vec![0.0f32; ((2 * r + 1) * (2 * r + 1)) as usize];
|
||||
let mut wgx = win.clone();
|
||||
let mut wgy = win.clone();
|
||||
let mut v1 = [0.0f32];
|
||||
let mut taps = Taps::default();
|
||||
for l in (0..=top).rev() {
|
||||
let s = (1u32 << l) as f64;
|
||||
let (ia, ib) = (&a.levels[l], &b.levels[l]);
|
||||
@@ -274,15 +310,12 @@ fn lk_point(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: &
|
||||
}
|
||||
let (mut gxx, mut gxy, mut gyy) = (0.0f64, 0.0f64, 0.0f64);
|
||||
let mut k = 0;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (x, y) = (c[0] + dx as f64, c[1] + dy as f64);
|
||||
ia.sample(x, y, &mut v1);
|
||||
win[k] = v1[0];
|
||||
gx.sample(x, y, &mut v1);
|
||||
wgx[k] = v1[0];
|
||||
gy.sample(x, y, &mut v1);
|
||||
wgy[k] = v1[0];
|
||||
taps.set(ia, c, r);
|
||||
for &y in &taps.ys {
|
||||
for &x in &taps.xs {
|
||||
win[k] = bilerp(&ia.data, x, y, taps.t);
|
||||
wgx[k] = bilerp(&gx.data, x, y, taps.t);
|
||||
wgy[k] = bilerp(&gy.data, x, y, taps.t);
|
||||
let (ix, iy) = (wgx[k] as f64, wgy[k] as f64);
|
||||
gxx += ix * ix;
|
||||
gxy += ix * iy;
|
||||
@@ -300,10 +333,10 @@ fn lk_point(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: &
|
||||
for _ in 0..o.iterations {
|
||||
let (mut bx, mut by) = (0.0f64, 0.0f64);
|
||||
let mut k = 0;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
ib.sample(c[0] + g[0] + v[0] + dx as f64, c[1] + g[1] + v[1] + dy as f64, &mut v1);
|
||||
let diff = (win[k] - v1[0]) as f64;
|
||||
taps.set(ib, [c[0] + g[0] + v[0], c[1] + g[1] + v[1]], r);
|
||||
for &y in &taps.ys {
|
||||
for &x in &taps.xs {
|
||||
let diff = (win[k] - bilerp(&ib.data, x, y, taps.t)) as f64;
|
||||
bx += diff * wgx[k] as f64;
|
||||
by += diff * wgy[k] as f64;
|
||||
k += 1;
|
||||
@@ -397,6 +430,87 @@ mod tests {
|
||||
im
|
||||
}
|
||||
|
||||
/// The per-pixel `Plane::sample` implementation `lk_point` replaced, kept as the oracle.
|
||||
fn lk_point_ref(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: &LkOpts) -> Option<[f64; 2]> {
|
||||
let top = a.levels.len().min(b.levels.len()).checked_sub(1)?;
|
||||
let r = o.radius;
|
||||
let n = ((2 * r + 1) * (2 * r + 1)) as f64;
|
||||
let s0 = (1u32 << top) as f64;
|
||||
let mut g = [guess[0] / s0, guess[1] / s0];
|
||||
let mut win = vec![0.0f32; ((2 * r + 1) * (2 * r + 1)) as usize];
|
||||
let mut wgx = win.clone();
|
||||
let mut wgy = win.clone();
|
||||
let mut v1 = [0.0f32];
|
||||
for l in (0..=top).rev() {
|
||||
let s = (1u32 << l) as f64;
|
||||
let (ia, ib) = (&a.levels[l], &b.levels[l]);
|
||||
let (gx, gy) = &a.grads[l];
|
||||
let c = [p[0] / s, p[1] / s];
|
||||
if c[0] < -1.0 || c[1] < -1.0 || c[0] > ia.w as f64 + 1.0 || c[1] > ia.h as f64 + 1.0 {
|
||||
return None;
|
||||
}
|
||||
let (mut gxx, mut gxy, mut gyy) = (0.0f64, 0.0f64, 0.0f64);
|
||||
let mut k = 0;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (x, y) = (c[0] + dx as f64, c[1] + dy as f64);
|
||||
ia.sample(x, y, &mut v1);
|
||||
win[k] = v1[0];
|
||||
gx.sample(x, y, &mut v1);
|
||||
wgx[k] = v1[0];
|
||||
gy.sample(x, y, &mut v1);
|
||||
wgy[k] = v1[0];
|
||||
let (ix, iy) = (wgx[k] as f64, wgy[k] as f64);
|
||||
gxx += ix * ix;
|
||||
gxy += ix * iy;
|
||||
gyy += iy * iy;
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
let det = gxx * gyy - gxy * gxy;
|
||||
let tr = 0.5 * (gxx + gyy);
|
||||
let mineig = (tr - (0.25 * (gxx - gyy).powi(2) + gxy * gxy).sqrt()) / n;
|
||||
if mineig < o.min_eigen || det.abs() < 1e-18 {
|
||||
return None;
|
||||
}
|
||||
let mut v = [0.0f64; 2];
|
||||
for _ in 0..o.iterations {
|
||||
let (mut bx, mut by) = (0.0f64, 0.0f64);
|
||||
let mut k = 0;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
ib.sample(c[0] + g[0] + v[0] + dx as f64, c[1] + g[1] + v[1] + dy as f64, &mut v1);
|
||||
let diff = (win[k] - v1[0]) as f64;
|
||||
bx += diff * wgx[k] as f64;
|
||||
by += diff * wgy[k] as f64;
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
let nx = (gyy * bx - gxy * by) / det;
|
||||
let ny = (gxx * by - gxy * bx) / det;
|
||||
v[0] += nx;
|
||||
v[1] += ny;
|
||||
if !(v[0].is_finite() && v[1].is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if nx * nx + ny * ny < o.epsilon * o.epsilon {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if l > 0 {
|
||||
g = [2.0 * (g[0] + v[0]), 2.0 * (g[1] + v[1])];
|
||||
} else {
|
||||
g = [g[0] + v[0], g[1] + v[1]];
|
||||
}
|
||||
}
|
||||
let q = [p[0] + g[0], p[1] + g[1]];
|
||||
let l0 = &b.levels[0];
|
||||
if q[0] < 0.0 || q[1] < 0.0 || q[0] > l0.w as f64 || q[1] > l0.h as f64 {
|
||||
return None;
|
||||
}
|
||||
Some(q)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn klt_recovers_a_shift() {
|
||||
let a = GrayPyramid::from_image(&img(0.0, 0.0), [0.0; 2], 1, 3);
|
||||
@@ -411,6 +525,35 @@ mod tests {
|
||||
assert!(errs[errs.len() / 2] < 0.1, "median error {}", errs[errs.len() / 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_taps_match_per_pixel_sampling_bit_for_bit() {
|
||||
let a = GrayPyramid::from_image(&img(0.0, 0.0), [0.0; 2], 1, 3);
|
||||
let b = GrayPyramid::from_image(&img(6.3, -4.2), [0.0; 2], 1, 3);
|
||||
let mut pts = vec![];
|
||||
for y in (-3..125).step_by(3) {
|
||||
for x in (-3..165).step_by(3) {
|
||||
pts.push([x as f64 + 0.37 * ((x * 7 + y) % 3) as f64, y as f64 + 0.5 * ((x + y * 5) % 2) as f64]);
|
||||
}
|
||||
}
|
||||
pts.extend([[0.0, 0.0], [160.0, 120.0], [80.0, 60.0], [0.5, 0.5], [159.5, 119.5]]);
|
||||
let (mut tracked, mut lost) = (0, 0);
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
for o in [LkOpts::default(), LkOpts { radius: 3, iterations: 5, ..Default::default() }] {
|
||||
let g = [(i % 5) as f64 - 2.0, (i % 3) as f64 - 1.0];
|
||||
let (new, old) = (lk_point(&a, &b, *p, g, &o), lk_point_ref(&a, &b, *p, g, &o));
|
||||
match (new, old) {
|
||||
(Some(n), Some(o)) => {
|
||||
assert_eq!([n[0].to_bits(), n[1].to_bits()], [o[0].to_bits(), o[1].to_bits()], "{p:?}");
|
||||
tracked += 1;
|
||||
}
|
||||
(None, None) => lost += 1,
|
||||
(n, o) => panic!("{p:?}: {n:?} vs {o:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(tracked > 500 && lost > 0, "{tracked} tracked, {lost} lost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_respect_the_region_and_spacing() {
|
||||
let a = GrayPyramid::from_image(&img(0.0, 0.0), [0.0; 2], 1, 2);
|
||||
|
||||
+172
-14
@@ -201,7 +201,7 @@ fn ncc(plane: &Plane, p: &Patch, c: [f64; 2], a: [f64; 2]) -> f64 {
|
||||
}
|
||||
let ch = p.ch;
|
||||
let (mut s, mut s2, mut st) = (0.0f64, 0.0f64, 0.0f64);
|
||||
let identity = (a[0] - 1.0).abs() < 1e-9 && a[1].abs() < 1e-9;
|
||||
let identity = is_identity(a);
|
||||
let fx = c[0] - 0.5;
|
||||
let fy = c[1] - 0.5;
|
||||
let (bx, by) = (fx.floor(), fy.floor());
|
||||
@@ -256,12 +256,114 @@ fn ncc(plane: &Plane, p: &Patch, c: [f64; 2], a: [f64; 2]) -> f64 {
|
||||
(st / (var * p.ss).sqrt()).clamp(-1.0, 1.0)
|
||||
}
|
||||
|
||||
fn is_identity(a: [f64; 2]) -> bool {
|
||||
(a[0] - 1.0).abs() < 1e-9 && a[1].abs() < 1e-9
|
||||
}
|
||||
|
||||
/// The bilinear taps of every template pixel at `c + A u`. Candidates `c + (i, j)` with integer
|
||||
/// `(i, j)` give each pixel the same fractional offsets, so one table serves them all and a
|
||||
/// candidate is integer-indexed lerps (the arithmetic of `Plane::sample`) with one bounds check.
|
||||
struct Taps {
|
||||
/// Offset (in samples) of the top-left tap per template pixel from the one at `base`.
|
||||
off: Vec<isize>,
|
||||
w: Vec<[f32; 2]>,
|
||||
base: isize,
|
||||
/// Tap bounding box (x0, y0, x1, y1) in plane pixels at the reference, `+1` taps included.
|
||||
bb: [isize; 4],
|
||||
}
|
||||
|
||||
impl Taps {
|
||||
fn new(plane: &Plane, p: &Patch, c: [f64; 2], a: [f64; 2]) -> Option<Taps> {
|
||||
let n = ((2 * p.r[0] + 1) * (2 * p.r[1] + 1)) as usize;
|
||||
let (mut off, mut w) = (Vec::with_capacity(n), Vec::with_capacity(n));
|
||||
let (rx, ry) = ((c[0] - 0.5).floor(), (c[1] - 0.5).floor());
|
||||
if !(rx.abs() < 1e9 && ry.abs() < 1e9) {
|
||||
return None;
|
||||
}
|
||||
let (rx, ry) = (rx as isize, ry as isize);
|
||||
let mut bb = [isize::MAX, isize::MAX, isize::MIN, isize::MIN];
|
||||
for gy in -p.r[1]..=p.r[1] {
|
||||
for gx in -p.r[0]..=p.r[0] {
|
||||
let (ux, uy) = (gx as f64, gy as f64);
|
||||
let fx = c[0] + a[0] * ux - a[1] * uy - 0.5;
|
||||
let fy = c[1] + a[1] * ux + a[0] * uy - 0.5;
|
||||
let (x0, y0) = (fx.floor(), fy.floor());
|
||||
if !(x0.abs() < 1e9 && y0.abs() < 1e9) {
|
||||
return None;
|
||||
}
|
||||
let (dx, dy) = (x0 as isize - rx, y0 as isize - ry);
|
||||
bb = [bb[0].min(dx), bb[1].min(dy), bb[2].max(dx + 1), bb[3].max(dy + 1)];
|
||||
off.push((dy * plane.w as isize + dx) * plane.ch as isize);
|
||||
w.push([(fx - x0) as f32, (fy - y0) as f32]);
|
||||
}
|
||||
}
|
||||
let base = (ry * plane.w as isize + rx) * plane.ch as isize;
|
||||
Some(Taps { off, w, base, bb: [bb[0] + rx, bb[1] + ry, bb[2] + rx, bb[3] + ry] })
|
||||
}
|
||||
|
||||
/// The correlation at `c + (i, j)`, or `None` when a tap leaves the plane.
|
||||
fn ncc(&self, plane: &Plane, p: &Patch, i: isize, j: isize) -> Option<f64> {
|
||||
if self.bb[0] + i < 0 || self.bb[1] + j < 0 || self.bb[2] + i >= plane.w as isize || self.bb[3] + j >= plane.h as isize {
|
||||
return None;
|
||||
}
|
||||
let ch = p.ch;
|
||||
let row = plane.w * ch;
|
||||
let b = self.base + (j * plane.w as isize + i) * ch as isize;
|
||||
let d = &plane.data;
|
||||
let (mut s, mut s2, mut st) = (0.0f64, 0.0f64, 0.0f64);
|
||||
let mut k = 0;
|
||||
for (o, t) in self.off.iter().zip(&self.w) {
|
||||
let i00 = (b + o) as usize;
|
||||
let (i10, i01) = (i00 + ch, i00 + row);
|
||||
for cc in 0..ch {
|
||||
let top = d[i00 + cc] + (d[i10 + cc] - d[i00 + cc]) * t[0];
|
||||
let bot = d[i01 + cc] + (d[i01 + ch + cc] - d[i01 + cc]) * t[0];
|
||||
let x = (top + (bot - top) * t[1]) as f64;
|
||||
s += x;
|
||||
s2 += x * x;
|
||||
st += x * p.zm[k] as f64;
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
let n = p.zm.len() as f64;
|
||||
let var = s2 - s * s / n;
|
||||
if var <= 1e-12 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some((st / (var * p.ss).sqrt()).clamp(-1.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// `ncc` of one template at integer offsets from a reference centre, through a [`Taps`] table
|
||||
/// when the shape is not the identity (the identity has its own fast path in `ncc`).
|
||||
struct Scorer<'a> {
|
||||
plane: &'a Plane,
|
||||
p: &'a Patch,
|
||||
c: [f64; 2],
|
||||
a: [f64; 2],
|
||||
taps: Option<Taps>,
|
||||
}
|
||||
|
||||
impl<'a> Scorer<'a> {
|
||||
fn new(plane: &'a Plane, p: &'a Patch, c: [f64; 2], a: [f64; 2]) -> Scorer<'a> {
|
||||
let taps = if is_identity(a) || p.ss <= 1e-12 { None } else { Taps::new(plane, p, c, a) };
|
||||
Scorer { plane, p, c, a, taps }
|
||||
}
|
||||
|
||||
/// The correlation at the reference centre plus `(i, j)` pixels.
|
||||
fn at(&self, i: i32, j: i32) -> f64 {
|
||||
self.taps
|
||||
.as_ref()
|
||||
.and_then(|t| t.ncc(self.plane, self.p, i as isize, j as isize))
|
||||
.unwrap_or_else(|| ncc(self.plane, self.p, [self.c[0] + i as f64, self.c[1] + j as f64], self.a))
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-processed pixels around one point: a pyramid of a crop. Plane coordinates are image
|
||||
/// pixels minus `origin`, divided by `2^level`.
|
||||
struct Crop {
|
||||
origin: [f64; 2],
|
||||
levels: Vec<Plane>,
|
||||
grad0: (Plane, Plane),
|
||||
}
|
||||
|
||||
impl Crop {
|
||||
@@ -280,13 +382,12 @@ impl Crop {
|
||||
if opts.enhance {
|
||||
p = p.enhance();
|
||||
}
|
||||
let grad0 = p.gradients();
|
||||
let mut lv = vec![p];
|
||||
for _ in 0..levels {
|
||||
let d = lv.last().map(Plane::downsample).unwrap_or_default();
|
||||
lv.push(d);
|
||||
}
|
||||
Crop { origin: [x0, y0], levels: lv, grad0 }
|
||||
Crop { origin: [x0, y0], levels: lv }
|
||||
}
|
||||
fn to_level(&self, p: [f64; 2], k: usize) -> [f64; 2] {
|
||||
let s = (1u32 << k) as f64;
|
||||
@@ -298,6 +399,12 @@ impl Crop {
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest distance (in top-level pixels, per axis) between the best coarse match and the
|
||||
/// motion prediction for which the fine stage searches only ±1 pixel per level.
|
||||
const SMALL_MOTION: f64 = 1.0;
|
||||
/// Lowest coarse NCC score of the best match for which the fine stage may search only ±1.
|
||||
const RELIABLE_SCORE: f64 = 0.8;
|
||||
|
||||
/// Per-point tracking state.
|
||||
#[derive(Clone, Debug)]
|
||||
struct PointState {
|
||||
@@ -372,13 +479,15 @@ impl PointState {
|
||||
let (rx, ry) = ((range[0] / s).ceil() as i32, (range[1] / s).ceil() as i32);
|
||||
let plane = &crop.levels[top];
|
||||
let tpl = &self.templates[top];
|
||||
let scorer = Scorer::new(plane, tpl, ctop, self.shape);
|
||||
let shape = self.shape;
|
||||
let mut cands: Vec<(f64, [f64; 2])> = (-ry..=ry)
|
||||
.into_par_iter()
|
||||
.flat_map_iter(|j| {
|
||||
let scorer = &scorer;
|
||||
(-rx..=rx).map(move |i| {
|
||||
let c = [ctop[0] + i as f64, ctop[1] + j as f64];
|
||||
(ncc(plane, tpl, c, shape), c)
|
||||
(scorer.at(i, j), c)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -396,7 +505,10 @@ impl PointState {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fine: refine each seed down the pyramid in a 5 × 5 neighbourhood.
|
||||
// Fine: refine each seed down the pyramid. A good motion prediction plus a reliable coarse
|
||||
// match only needs a 3 × 3 neighbourhood per level; otherwise keep 5 × 5.
|
||||
let near = cands[0].0 >= RELIABLE_SCORE && seeds.first().is_some_and(|c| (c[0] - cp[0]).abs() <= SMALL_MOTION && (c[1] - cp[1]).abs() <= SMALL_MOTION);
|
||||
let rr: i32 = if near { 1 } else { 2 };
|
||||
let mut best = (f64::MIN, sc);
|
||||
for seed in seeds {
|
||||
let mut c = seed;
|
||||
@@ -405,14 +517,15 @@ impl PointState {
|
||||
c = [c[0] * 2.0, c[1] * 2.0];
|
||||
let (pl, tp) = (&crop.levels[k], &self.templates[k]);
|
||||
// Start from the projected match; neighbours must beat it (no drift on ties).
|
||||
let mut lb = (ncc(pl, tp, c, shape), c);
|
||||
for j in -2..=2 {
|
||||
for i in -2..=2 {
|
||||
let scorer = Scorer::new(pl, tp, c, shape);
|
||||
let mut lb = (scorer.at(0, 0), c);
|
||||
for j in -rr..=rr {
|
||||
for i in -rr..=rr {
|
||||
if i == 0 && j == 0 {
|
||||
continue;
|
||||
}
|
||||
let q = [c[0] + i as f64, c[1] + j as f64];
|
||||
let v = ncc(pl, tp, q, shape);
|
||||
let v = scorer.at(i, j);
|
||||
if v > lb.0 + 1e-9 {
|
||||
lb = (v, q);
|
||||
}
|
||||
@@ -431,15 +544,16 @@ impl PointState {
|
||||
// Sub-pixel (and shape) refinement.
|
||||
let (mut c, mut a) = (c0, shape);
|
||||
if opts.subpixel {
|
||||
let mut grad = Grad::around(&crop.levels[0], c0, shape, self.templates[0].r);
|
||||
let mut ok = false;
|
||||
if opts.track_shape
|
||||
&& let Some((cr, ar)) = lk_refine(&crop, &self.templates[0], c0, shape, true)
|
||||
&& let Some((cr, ar)) = lk_refine(&crop, &mut grad, &self.templates[0], c0, shape, true)
|
||||
{
|
||||
(c, a) = (cr, ar);
|
||||
ok = true;
|
||||
}
|
||||
if !ok {
|
||||
if let Some((cr, _)) = lk_refine(&crop, &self.templates[0], c0, shape, false) {
|
||||
if let Some((cr, _)) = lk_refine(&crop, &mut grad, &self.templates[0], c0, shape, false) {
|
||||
c = cr;
|
||||
} else {
|
||||
c = parabolic(&crop.levels[0], &self.templates[0], c0, shape);
|
||||
@@ -506,12 +620,52 @@ fn parabolic(plane: &Plane, p: &Patch, c: [f64; 2], a: [f64; 2]) -> [f64; 2] {
|
||||
[c[0] + off(f(-1.0, 0.0), f(1.0, 0.0)), c[1] + off(f(0.0, -1.0), f(0.0, 1.0))]
|
||||
}
|
||||
|
||||
/// Level-0 gradients of a crop, computed only where `lk_refine` reads them: valid on the pixel
|
||||
/// rectangle `valid` (x0, y0, x1, y1, inclusive) and zero elsewhere.
|
||||
struct Grad {
|
||||
gx: Plane,
|
||||
gy: Plane,
|
||||
valid: [isize; 4],
|
||||
}
|
||||
|
||||
impl Grad {
|
||||
/// Extra pixels around the first window of taps, for the drift of the refinement. A window that
|
||||
/// still runs out is replaced by the full gradients.
|
||||
const MARGIN: isize = 6;
|
||||
|
||||
fn full(plane: &Plane) -> Grad {
|
||||
let (gx, gy) = plane.gradients();
|
||||
Grad { gx, gy, valid: [isize::MIN, isize::MIN, isize::MAX, isize::MAX] }
|
||||
}
|
||||
|
||||
fn around(plane: &Plane, c: [f64; 2], a: [f64; 2], r: [i32; 2]) -> Grad {
|
||||
let [x0, y0, x1, y1] = Grad::taps(c, a, r);
|
||||
let m = Grad::MARGIN;
|
||||
let clip = |v: isize, hi: usize| v.clamp(0, (hi as isize - 1).max(0)) as usize;
|
||||
let rect = [clip(x0 - m, plane.w), clip(y0 - m, plane.h), clip(x1 + m, plane.w), clip(y1 + m, plane.h)];
|
||||
let (gx, gy) = plane.gradients_in(rect);
|
||||
Grad { gx, gy, valid: rect.map(|v| v as isize) }
|
||||
}
|
||||
|
||||
/// The pixels read by sampling the template window at `c` with shape `a` (bilinear taps).
|
||||
fn taps(c: [f64; 2], a: [f64; 2], r: [i32; 2]) -> [isize; 4] {
|
||||
let ex = (a[0].abs() * r[0] as f64 + a[1].abs() * r[1] as f64) * (1.0 + 1e-9) + 1e-9;
|
||||
let ey = (a[1].abs() * r[0] as f64 + a[0].abs() * r[1] as f64) * (1.0 + 1e-9) + 1e-9;
|
||||
let px = |v: f64| v.clamp(-1e9, 1e9).floor() as isize;
|
||||
[px(c[0] - ex - 0.5), px(c[1] - ey - 0.5), px(c[0] + ex - 0.5) + 1, px(c[1] + ey - 0.5) + 1]
|
||||
}
|
||||
|
||||
fn covers(&self, c: [f64; 2], a: [f64; 2], r: [i32; 2]) -> bool {
|
||||
let t = Grad::taps(c, a, r);
|
||||
t[0] >= self.valid[0] && t[1] >= self.valid[1] && t[2] <= self.valid[2] && t[3] <= self.valid[3]
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward-additive Lucas–Kanade (Gauss–Newton) alignment of the level-0 template with gain and
|
||||
/// bias: minimise Σ (I(c + A u) − g T(u) − b)² over the centre `c` and, with `shape`, the
|
||||
/// similarity `A`. Returns `None` when it diverges.
|
||||
fn lk_refine(crop: &Crop, p: &Patch, c0: [f64; 2], a0: [f64; 2], shape: bool) -> Option<([f64; 2], [f64; 2])> {
|
||||
fn lk_refine(crop: &Crop, grad: &mut Grad, p: &Patch, c0: [f64; 2], a0: [f64; 2], shape: bool) -> Option<([f64; 2], [f64; 2])> {
|
||||
let plane = &crop.levels[0];
|
||||
let (gxp, gyp) = &crop.grad0;
|
||||
let ch = p.ch;
|
||||
let (mut c, mut a) = (c0, a0);
|
||||
let mut g = 1.0f64;
|
||||
@@ -546,6 +700,10 @@ fn lk_refine(crop: &Crop, p: &Patch, c0: [f64; 2], a0: [f64; 2], shape: bool) ->
|
||||
let np = if shape { 6 } else { 4 };
|
||||
let (mut v, mut vx, mut vy) = ([0.0f32; 3], [0.0f32; 3], [0.0f32; 3]);
|
||||
for _ in 0..30 {
|
||||
if !grad.covers(c, a, p.r) {
|
||||
*grad = Grad::full(plane);
|
||||
}
|
||||
let (gxp, gyp) = (&grad.gx, &grad.gy);
|
||||
let mut h = [[0.0f64; 6]; 6];
|
||||
let mut rhs = [0.0f64; 6];
|
||||
let mut k = 0;
|
||||
|
||||
@@ -191,15 +191,32 @@ impl Plane {
|
||||
|
||||
/// Central-difference gradients `(d/dx, d/dy)`.
|
||||
pub fn gradients(&self) -> (Plane, Plane) {
|
||||
self.gradients_in([0, 0, self.w.saturating_sub(1), self.h.saturating_sub(1)])
|
||||
}
|
||||
|
||||
/// The gradients of the pixels in `[x0, y0, x1, y1]` (inclusive, within the plane); all other
|
||||
/// pixels of the returned planes stay zero.
|
||||
pub fn gradients_in(&self, [x0, y0, x1, y1]: [usize; 4]) -> (Plane, Plane) {
|
||||
let mut gx = Plane::new(self.w, self.h, self.ch);
|
||||
let mut gy = Plane::new(self.w, self.h, self.ch);
|
||||
for y in 0..self.h {
|
||||
for x in 0..self.w {
|
||||
for c in 0..self.ch {
|
||||
let (xi, yi) = (x as isize, y as isize);
|
||||
let i = self.idx(x, y) + c;
|
||||
gx.data[i] = 0.5 * (self.at(xi + 1, yi, c) - self.at(xi - 1, yi, c));
|
||||
gy.data[i] = 0.5 * (self.at(xi, yi + 1, c) - self.at(xi, yi - 1, c));
|
||||
if self.w == 0 || self.h == 0 {
|
||||
return (gx, gy);
|
||||
}
|
||||
let (ch, row) = (self.ch, self.w * self.ch);
|
||||
for y in y0..=y1.min(self.h - 1) {
|
||||
for x in x0..=x1.min(self.w - 1) {
|
||||
let i = self.idx(x, y);
|
||||
if x > 0 && y > 0 && x + 1 < self.w && y + 1 < self.h {
|
||||
for c in i..i + ch {
|
||||
gx.data[c] = 0.5 * (self.data[c + ch] - self.data[c - ch]);
|
||||
gy.data[c] = 0.5 * (self.data[c + row] - self.data[c - row]);
|
||||
}
|
||||
} else {
|
||||
for c in 0..ch {
|
||||
let (xi, yi) = (x as isize, y as isize);
|
||||
gx.data[i + c] = 0.5 * (self.at(xi + 1, yi, c) - self.at(xi - 1, yi, c));
|
||||
gy.data[i + c] = 0.5 * (self.at(xi, yi + 1, c) - self.at(xi, yi - 1, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,4 +257,27 @@ mod tests {
|
||||
assert_eq!(Plane::from_image(&img, Channel::Saturation, 0, 0, 2, 2).data[0], 1.0);
|
||||
assert_eq!(Channel::from_name("luminance"), Some(Channel::Luminance));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_window_and_interior_path_match_the_clamped_definition() {
|
||||
let mut p = Plane::new(11, 9, 3);
|
||||
for (i, v) in p.data.iter_mut().enumerate() {
|
||||
*v = ((i * 7919) % 101) as f32 / 37.0;
|
||||
}
|
||||
let (gx, gy) = p.gradients();
|
||||
let rect = [2, 1, 8, 6];
|
||||
let (wx, wy) = p.gradients_in(rect);
|
||||
for y in 0..p.h {
|
||||
for x in 0..p.w {
|
||||
for c in 0..3 {
|
||||
let (xi, yi, i) = (x as isize, y as isize, p.idx(x, y) + c);
|
||||
assert_eq!(gx.data[i].to_bits(), (0.5 * (p.at(xi + 1, yi, c) - p.at(xi - 1, yi, c))).to_bits());
|
||||
assert_eq!(gy.data[i].to_bits(), (0.5 * (p.at(xi, yi + 1, c) - p.at(xi, yi - 1, c))).to_bits());
|
||||
let inside = (rect[0]..=rect[2]).contains(&x) && (rect[1]..=rect[3]).contains(&y);
|
||||
assert_eq!(wx.data[i], if inside { gx.data[i] } else { 0.0 });
|
||||
assert_eq!(wy.data[i], if inside { gy.data[i] } else { 0.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+137
-55
@@ -339,38 +339,76 @@ pub fn rgb_of(img: &Image) -> Vec<[f32; 3]> {
|
||||
img.data.par_iter().map(|p| if p[3] > 1e-6 { [p[0] / p[3], p[1] / p[3], p[2] / p[3]] } else { [0.0; 3] }).collect()
|
||||
}
|
||||
|
||||
/// Pixels covered by `strokes` (of the given kinds) at `scale`.
|
||||
pub fn rasterize(strokes: &[&Stroke], kinds: &[StrokeKind], scale: f64, w: usize, h: usize) -> Vec<bool> {
|
||||
let mut m = vec![false; w * h];
|
||||
for s in strokes.iter().filter(|s| kinds.contains(&s.kind)) {
|
||||
let r = (s.radius * scale).max(0.5);
|
||||
let pts: Vec<[f64; 2]> = s.points.iter().map(|p| [p[0] * scale, p[1] * scale]).collect();
|
||||
let segs: Vec<([f64; 2], [f64; 2])> = if pts.len() == 1 { vec![(pts[0], pts[0])] } else { pts.windows(2).map(|w| (w[0], w[1])).collect() };
|
||||
for (a, b) in segs {
|
||||
let x0 = (a[0].min(b[0]) - r).floor().max(0.0) as usize;
|
||||
let x1 = ((a[0].max(b[0]) + r).ceil().max(0.0) as usize).min(w);
|
||||
let y0 = (a[1].min(b[1]) - r).floor().max(0.0) as usize;
|
||||
let y1 = ((a[1].max(b[1]) + r).ceil().max(0.0) as usize).min(h);
|
||||
let d = [b[0] - a[0], b[1] - a[1]];
|
||||
let l2 = d[0] * d[0] + d[1] * d[1];
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let p = [x as f64 + 0.5, y as f64 + 0.5];
|
||||
let t = if l2 > 0.0 { (((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let q = [a[0] + d[0] * t, a[1] + d[1] * t];
|
||||
if (p[0] - q[0]).hypot(p[1] - q[1]) <= r {
|
||||
m[y * w + x] = true;
|
||||
}
|
||||
/// A stroke's radius and segments at `scale`.
|
||||
fn segments(s: &Stroke, scale: f64) -> (f64, Vec<([f64; 2], [f64; 2])>) {
|
||||
let pts: Vec<[f64; 2]> = s.points.iter().map(|p| [p[0] * scale, p[1] * scale]).collect();
|
||||
let segs = match pts.as_slice() {
|
||||
[p] => vec![(*p, *p)],
|
||||
_ => pts.windows(2).map(|w| (w[0], w[1])).collect(),
|
||||
};
|
||||
((s.radius * scale).max(0.5), segs)
|
||||
}
|
||||
|
||||
/// Distance from `p` to the segment `a`–`b`.
|
||||
fn segment_distance(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
let d = [b[0] - a[0], b[1] - a[1]];
|
||||
let l2 = d[0] * d[0] + d[1] * d[1];
|
||||
let t = if l2 > 0.0 { (((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(p[0] - a[0] - d[0] * t).hypot(p[1] - a[1] - d[1] * t)
|
||||
}
|
||||
|
||||
/// Call `paint` with every pixel `s` covers at `scale`.
|
||||
fn sweep(s: &Stroke, scale: f64, w: usize, h: usize, mut paint: impl FnMut(usize)) {
|
||||
let (r, segs) = segments(s, scale);
|
||||
for (a, b) in segs {
|
||||
let x0 = (a[0].min(b[0]) - r).floor().max(0.0) as usize;
|
||||
let x1 = ((a[0].max(b[0]) + r).ceil().max(0.0) as usize).min(w);
|
||||
let y0 = (a[1].min(b[1]) - r).floor().max(0.0) as usize;
|
||||
let y1 = ((a[1].max(b[1]) + r).ceil().max(0.0) as usize).min(h);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
if segment_distance([x as f64 + 0.5, y as f64 + 0.5], a, b) <= r {
|
||||
paint(y * w + x);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels covered by `strokes` (of the given kinds) at `scale`.
|
||||
pub fn rasterize(strokes: &[&Stroke], kinds: &[StrokeKind], scale: f64, w: usize, h: usize) -> Vec<bool> {
|
||||
let mut m = vec![false; w * h];
|
||||
for s in strokes.iter().filter(|s| kinds.contains(&s.kind)) {
|
||||
sweep(s, scale, w, h, |i| m[i] = true);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Hard constraints: 1 = foreground, 2 = background, 0 = free.
|
||||
const HARD_FG: u8 = 1;
|
||||
const HARD_BG: u8 = 2;
|
||||
/// What a pair of opposing strokes (add / remove) leaves on each pixel.
|
||||
const ON: u8 = 1;
|
||||
const OFF: u8 = 2;
|
||||
|
||||
/// [`ON`] where an `on` stroke covers a pixel, [`OFF`] where an `off` stroke does, 0 elsewhere.
|
||||
/// Strokes apply in drawing order, so where they overlap the last one painted wins: a stroke
|
||||
/// corrects the ones before it.
|
||||
pub fn resolve(strokes: &[&Stroke], on: StrokeKind, off: StrokeKind, scale: f64, w: usize, h: usize) -> Vec<u8> {
|
||||
let mut m = vec![0; w * h];
|
||||
for s in strokes {
|
||||
let v = if s.kind == on {
|
||||
ON
|
||||
} else if s.kind == off {
|
||||
OFF
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
sweep(s, scale, w, h, |i| m[i] = v);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Hard constraints: foreground, background, else free.
|
||||
const HARD_FG: u8 = ON;
|
||||
const HARD_BG: u8 = OFF;
|
||||
|
||||
/// Graph-cut energy weights.
|
||||
const GAMMA: f64 = 50.0;
|
||||
@@ -662,21 +700,9 @@ pub fn segment(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &S
|
||||
return FrameSeg::empty(w, h);
|
||||
}
|
||||
let rgb = rgb_of(img);
|
||||
let fgs = rasterize(strokes, &[StrokeKind::Fg], opts.scale, w, h);
|
||||
let bgs = rasterize(strokes, &[StrokeKind::Bg], opts.scale, w, h);
|
||||
let hard: Vec<u8> = fgs
|
||||
.iter()
|
||||
.zip(&bgs)
|
||||
.map(|(f, b)| {
|
||||
if *b {
|
||||
HARD_BG
|
||||
} else if *f {
|
||||
HARD_FG
|
||||
} else {
|
||||
0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let hard = resolve(strokes, StrokeKind::Fg, StrokeKind::Bg, opts.scale, w, h);
|
||||
let fgs: Vec<bool> = hard.iter().map(|v| *v == HARD_FG).collect();
|
||||
let bgs: Vec<bool> = hard.iter().map(|v| *v == HARD_BG).collect();
|
||||
// A warped matte is uncertain by about the search radius: blur it. A model's matte is not.
|
||||
let soft_prior: Option<Vec<f32>> =
|
||||
prior.map(|p| if p.weight > PRIOR { p.soft.clone() } else { matting::gauss(&p.soft, w, h, (opts.search_radius * opts.scale / 4.0).max(1.0)) });
|
||||
@@ -788,8 +814,7 @@ fn core_of(soft: &[f32], w: usize, h: usize, r: f64) -> Vec<bool> {
|
||||
fn finish(_img: &Image, rgb: &[[f32; 3]], labels: Vec<u8>, strokes: &[&Stroke], warped_refine: Option<&[u8]>, opts: &SegOpts, refine_radius: f64) -> FrameSeg {
|
||||
let (w, h) = (_img.width as usize, _img.height as usize);
|
||||
let (fg, bg) = fit_models(rgb, &labels);
|
||||
let add = rasterize(strokes, &[StrokeKind::Refine], opts.scale, w, h);
|
||||
let erase = rasterize(strokes, &[StrokeKind::RefineErase], opts.scale, w, h);
|
||||
let painted = resolve(strokes, StrokeKind::Refine, StrokeKind::RefineErase, opts.scale, w, h);
|
||||
let near: Option<Vec<f32>> = warped_refine.filter(|r| r.iter().any(|v| *v != 0)).map(|_| matting::signed_distance(&labels, w, h));
|
||||
let rr = (refine_radius * opts.scale + 2.0) as f32;
|
||||
let refine: Vec<u8> = (0..w * h)
|
||||
@@ -798,7 +823,7 @@ fn finish(_img: &Image, rgb: &[[f32; 3]], labels: Vec<u8>, strokes: &[&Stroke],
|
||||
(Some(r), Some(sd)) => r[i] != 0 && sd[i].abs() <= rr,
|
||||
_ => false,
|
||||
};
|
||||
((carried || add[i]) && !erase[i]) as u8
|
||||
((carried && painted[i] != OFF) || painted[i] == ON) as u8
|
||||
})
|
||||
.collect();
|
||||
FrameSeg { w, h, matte: labels, refine, fg, bg }
|
||||
@@ -877,21 +902,66 @@ fn points_along(s: &Stroke, scale: f64, max: usize) -> Vec<[f32; 2]> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A model's prompt from the strokes: points along the foreground and background strokes, and
|
||||
/// the box of a [`StrokeKind::Box`] stroke (matte pixels).
|
||||
pub fn stroke_prompt(strokes: &[&Stroke], scale: f64) -> Prompt {
|
||||
/// A model's prompt from the strokes: points along the foreground and background strokes, but
|
||||
/// not where a later stroke of the other kind painted over them (`hard`, from [`resolve`]), and
|
||||
/// the box of a [`StrokeKind::Box`] stroke (matte pixels). A stroke painted over at every point
|
||||
/// sampled along it, but not everywhere, is prompted from the middle of what is left of it.
|
||||
pub fn stroke_prompt(strokes: &[&Stroke], hard: &[u8], scale: f64, w: usize, h: usize) -> Prompt {
|
||||
let mut points = vec![];
|
||||
for (kind, fg) in [(StrokeKind::Fg, true), (StrokeKind::Bg, false)] {
|
||||
for (kind, own, other) in [(StrokeKind::Fg, HARD_FG, HARD_BG), (StrokeKind::Bg, HARD_BG, HARD_FG)] {
|
||||
let ss: Vec<&&Stroke> = strokes.iter().filter(|s| s.kind == kind).collect();
|
||||
let per = (16 / ss.len().max(1)).max(1);
|
||||
for s in ss {
|
||||
points.extend(points_along(s, scale, per).into_iter().map(|p| (p, fg)));
|
||||
let along = points_along(s, scale, per);
|
||||
let mut kept: Vec<[f32; 2]> = along.iter().copied().filter(|p| owner(hard, w, h, *p) != Some(other)).collect();
|
||||
if kept.is_empty() && !along.is_empty() {
|
||||
kept.extend(deepest_owned(s, hard, own, scale, w, h));
|
||||
}
|
||||
points.extend(kept.into_iter().map(|p| (p, kind == StrokeKind::Fg)));
|
||||
}
|
||||
}
|
||||
let bbox = strokes.iter().filter(|s| s.kind == StrokeKind::Box).find_map(|s| box_of(s, scale));
|
||||
Prompt { points, bbox, ..Default::default() }
|
||||
}
|
||||
|
||||
/// The value of `hard` under the point `p` (`None` outside the frame).
|
||||
fn owner(hard: &[u8], w: usize, h: usize, p: [f32; 2]) -> Option<u8> {
|
||||
if !(p[0] >= 0.0 && p[1] >= 0.0) {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = (p[0] as usize, p[1] as usize);
|
||||
if x >= w || y >= h {
|
||||
return None;
|
||||
}
|
||||
hard.get(y * w + x).copied()
|
||||
}
|
||||
|
||||
/// The centre of the pixel deepest inside what `s` still owns (where `hard` is `own` under it),
|
||||
/// if anything.
|
||||
fn deepest_owned(s: &Stroke, hard: &[u8], own: u8, scale: f64, w: usize, h: usize) -> Option<[f32; 2]> {
|
||||
let mut owned = vec![];
|
||||
sweep(s, scale, w, h, |i| {
|
||||
if hard.get(i) == Some(&own) {
|
||||
owned.push((i % w, i / w));
|
||||
}
|
||||
});
|
||||
let (x0, x1) = (owned.iter().map(|p| p.0).min()?, owned.iter().map(|p| p.0).max()?);
|
||||
let (y0, y1) = (owned.iter().map(|p| p.1).min()?, owned.iter().map(|p| p.1).max()?);
|
||||
// One pixel of margin all round, so depth is measured to the nearest pixel it doesn't own.
|
||||
let (cw, ch) = (x1 - x0 + 3, y1 - y0 + 3);
|
||||
let at = |(x, y): (usize, usize)| (y - y0 + 1) * cw + (x - x0 + 1);
|
||||
let mut free = vec![true; cw * ch];
|
||||
for p in &owned {
|
||||
if let Some(v) = free.get_mut(at(*p)) {
|
||||
*v = false;
|
||||
}
|
||||
}
|
||||
let depth = matting::distance(&free, cw, ch);
|
||||
let d = |p: &(usize, usize)| depth.get(at(*p)).copied().unwrap_or(0.0);
|
||||
let (x, y) = owned.iter().copied().max_by(|a, b| d(a).total_cmp(&d(b)))?;
|
||||
Some([x as f32 + 0.5, y as f32 + 0.5])
|
||||
}
|
||||
|
||||
/// The box `[x0, y0, x1, y1]` (matte pixels) of a box stroke: its first two points as opposite
|
||||
/// corners; `None` when they are missing, not finite or the box is empty.
|
||||
fn box_of(s: &Stroke, scale: f64) -> Option<[f32; 4]> {
|
||||
@@ -901,8 +971,18 @@ fn box_of(s: &Stroke, scale: f64) -> Option<[f32; 4]> {
|
||||
}
|
||||
|
||||
/// Add a prior matte to a prompt: its box (a little larger; unless the prompt has one), up to
|
||||
/// four points spread through its core, and the matte itself.
|
||||
fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], w: usize, h: usize) -> bool {
|
||||
/// four points spread through its core, and the matte itself, all with this frame's strokes
|
||||
/// (`hard`, from [`resolve`]) painted over it.
|
||||
fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], hard: &[u8], w: usize, h: usize) -> bool {
|
||||
let soft: Vec<f32> = soft
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| match hard.get(i) {
|
||||
Some(&HARD_FG) => 1.0,
|
||||
Some(&HARD_BG) => 0.0,
|
||||
_ => *v,
|
||||
})
|
||||
.collect();
|
||||
let bin: Vec<u8> = soft.iter().map(|v| (*v >= 0.5) as u8).collect();
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
for (i, v) in bin.iter().enumerate() {
|
||||
@@ -932,7 +1012,7 @@ fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], w: usize, h: usize) -> bo
|
||||
}
|
||||
}
|
||||
prompt.points.extend(picks.into_iter().map(|i| ([(i % w) as f32 + 0.5, (i / w) as f32 + 0.5], true)));
|
||||
prompt.mask = Some(soft.to_vec());
|
||||
prompt.mask = Some(soft);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -941,9 +1021,10 @@ fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], w: usize, h: usize) -> bo
|
||||
pub fn segment_with(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &SegOpts, refine_radius: f64, model: Option<&dyn MaskModel>) -> FrameSeg {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let Some(m) = model.filter(|_| w > 0 && h > 0) else { return segment(img, strokes, prior, opts, refine_radius) };
|
||||
let mut prompt = stroke_prompt(strokes, opts.scale);
|
||||
let hard = resolve(strokes, StrokeKind::Fg, StrokeKind::Bg, opts.scale, w, h);
|
||||
let mut prompt = stroke_prompt(strokes, &hard, opts.scale, w, h);
|
||||
if let Some(p) = prior {
|
||||
add_prior_prompt(&mut prompt, &p.soft, w, h);
|
||||
add_prior_prompt(&mut prompt, &p.soft, &hard, w, h);
|
||||
}
|
||||
if !prompt.points.iter().any(|(_, fg)| *fg) {
|
||||
return segment(img, strokes, prior, opts, refine_radius);
|
||||
@@ -973,8 +1054,9 @@ pub fn propagate_with(
|
||||
let (w, h) = (next_img.width as usize, next_img.height as usize);
|
||||
let Some(m) = model.filter(|_| w > 0 && h > 0) else { return propagate(prev_img, prev, next_img, strokes, opts, refine_radius) };
|
||||
let prior = warp_prior(prev_img, prev, next_img, opts);
|
||||
let mut prompt = stroke_prompt(strokes, opts.scale);
|
||||
if !add_prior_prompt(&mut prompt, &prior.soft, w, h) {
|
||||
let hard = resolve(strokes, StrokeKind::Fg, StrokeKind::Bg, opts.scale, w, h);
|
||||
let mut prompt = stroke_prompt(strokes, &hard, opts.scale, w, h);
|
||||
if !add_prior_prompt(&mut prompt, &prior.soft, &hard, w, h) {
|
||||
return propagate(prev_img, prev, next_img, strokes, opts, refine_radius);
|
||||
}
|
||||
match m.segment(&rgb_of(next_img), w, h, &prompt) {
|
||||
|
||||
@@ -182,6 +182,28 @@ fn a_correction_stroke_fixes_a_bad_frame() {
|
||||
assert!(iou(&next.matte, >2) > 0.93);
|
||||
}
|
||||
|
||||
/// A stroke corrects the ones painted before it: foreground over an earlier background stroke
|
||||
/// restores the area, and background over that removes it again.
|
||||
#[test]
|
||||
fn later_strokes_override_earlier_ones() {
|
||||
let c = center(0);
|
||||
let (img, gt) = frame_at(c);
|
||||
let spot = [c[0], c[1] + 20.0];
|
||||
let i = spot[1] as usize * W as usize + spot[0] as usize;
|
||||
let red = line(StrokeKind::Bg, 0, [spot[0] - 6.0, spot[1]], [spot[0] + 6.0, spot[1]], 6.0);
|
||||
let green = Stroke { kind: StrokeKind::Fg, ..red.clone() };
|
||||
let seg = |st: &[Stroke]| segment(&img, &st.iter().collect::<Vec<_>>(), None, &SegOpts::default(), 0.0);
|
||||
let mut st = base_strokes(0);
|
||||
st.push(red.clone());
|
||||
assert_eq!(seg(&st).matte[i], 0, "background over the disk removes it");
|
||||
st.push(green);
|
||||
let restored = seg(&st);
|
||||
assert_eq!(restored.matte[i], 1, "foreground over the background stroke restores it");
|
||||
assert!(iou(&restored.matte, >) > 0.95, "IoU {}", iou(&restored.matte, >));
|
||||
st.push(red);
|
||||
assert_eq!(seg(&st).matte[i], 0, "background again removes it again");
|
||||
}
|
||||
|
||||
/// A soft-edged disk (alpha ramp over `width` pixels) over a background.
|
||||
fn soft_frame(c: [f64; 2], width: f64, fg: [f32; 3], bg: [f32; 3]) -> (Image, Vec<f32>) {
|
||||
let mut img = Image::new(W, H);
|
||||
@@ -265,6 +287,31 @@ fn refine_edge_recovers_a_soft_edge_and_decontaminates() {
|
||||
assert!(a2.iter().zip(&seg.matte).all(|(x, y)| (*x - *y as f32).abs() < 1e-6));
|
||||
}
|
||||
|
||||
/// Refine Edge strokes correct each other the same way: refine over an earlier erase restores
|
||||
/// the band there.
|
||||
#[test]
|
||||
fn later_refine_strokes_override_earlier_erases() {
|
||||
let c = [100.0, 75.0];
|
||||
let (img, _) = soft_frame(c, 10.0, [0.9, 0.25, 0.1], [0.1, 0.3, 0.9]);
|
||||
let spot = [c[0] + R, c[1]];
|
||||
let i = spot[1] as usize * W as usize + spot[0] as usize;
|
||||
let erase = Stroke { kind: StrokeKind::RefineErase, frame: 0, radius: 6.0, points: vec![spot] };
|
||||
let refine = Stroke { kind: StrokeKind::Refine, ..erase.clone() };
|
||||
let band = |st: &[Stroke]| segment(&img, &st.iter().collect::<Vec<_>>(), None, &SegOpts::default(), 9.0).refine[i];
|
||||
let mut st = vec![
|
||||
line(StrokeKind::Fg, 0, [c[0] - 15.0, c[1]], [c[0] + 15.0, c[1]], 5.0),
|
||||
line(StrokeKind::Bg, 0, [c[0] - 60.0, c[1] - 55.0], [c[0] + 60.0, c[1] - 55.0], 5.0),
|
||||
ring(0, c, R, 9.0),
|
||||
];
|
||||
assert_eq!(band(&st), 1);
|
||||
st.push(erase.clone());
|
||||
assert_eq!(band(&st), 0, "erase removes the band");
|
||||
st.push(refine);
|
||||
assert_eq!(band(&st), 1, "refine over the erase restores it");
|
||||
st.push(erase);
|
||||
assert_eq!(band(&st), 0, "erase again removes it again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduce_chatter_and_motion_blur_use_the_neighbours() {
|
||||
let (w, h) = (40usize, 20usize);
|
||||
@@ -373,9 +420,8 @@ impl effectcraft_segment::MaskModel for FakeModel {
|
||||
}
|
||||
|
||||
/// A grey bar on a two-tone background whose halves match the bar's halves: colour alone can't
|
||||
/// find its edge, a model can.
|
||||
#[test]
|
||||
fn a_model_prior_shapes_the_cut_and_bad_models_are_ignored() {
|
||||
/// find its edge, a model can. The frame, its truth and the bar's rectangle.
|
||||
fn bar_frame() -> (Image, Vec<u8>, [usize; 4]) {
|
||||
let (w, h) = (160usize, 100usize);
|
||||
let rect = [40, 30, 120, 70];
|
||||
let mut img = Image::new(w as u32, h as u32);
|
||||
@@ -390,6 +436,12 @@ fn a_model_prior_shapes_the_cut_and_bad_models_are_ignored() {
|
||||
gt[y * w + x] = inside as u8;
|
||||
}
|
||||
}
|
||||
(img, gt, rect)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_model_prior_shapes_the_cut_and_bad_models_are_ignored() {
|
||||
let (img, gt, rect) = bar_frame();
|
||||
let st = [line(StrokeKind::Fg, 0, [50.0, 50.0], [110.0, 50.0], 4.0)];
|
||||
let refs: Vec<&Stroke> = st.iter().collect();
|
||||
let opts = SegOpts::default();
|
||||
@@ -450,18 +502,80 @@ fn mobilesam_segments_and_propagates_the_disk() {
|
||||
fn box_stroke_is_the_model_box_prompt() {
|
||||
let fg = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 2.0, points: vec![[50.0, 40.0]] };
|
||||
let bx = Stroke { kind: StrokeKind::Box, frame: 0, radius: 0.0, points: vec![[80.0, 60.0], [20.0, 10.0]] };
|
||||
let p = stroke_prompt(&[&fg, &bx], 0.5);
|
||||
let p = stroke_prompt(&[&fg, &bx], &[], 0.5, 0, 0);
|
||||
assert_eq!(p.bbox, Some([10.0, 5.0, 40.0, 30.0]));
|
||||
assert_eq!(p.points, vec![([25.0, 20.0], true)]);
|
||||
// Degenerate or non-finite boxes are no prompt.
|
||||
for pts in [vec![[20.0, 10.0]], vec![[20.0, 10.0], [20.0, 50.0]], vec![[f64::NAN, 10.0], [80.0, 60.0]]] {
|
||||
let b = Stroke { points: pts, ..bx.clone() };
|
||||
assert_eq!(stroke_prompt(&[&fg, &b], 1.0).bbox, None);
|
||||
assert_eq!(stroke_prompt(&[&fg, &b], &[], 1.0, 0, 0).bbox, None);
|
||||
}
|
||||
assert!(!StrokeKind::Box.is_segmentation());
|
||||
assert!(rasterize(&[&bx], &[StrokeKind::Fg, StrokeKind::Bg], 1.0, 100, 80).iter().all(|v| !v));
|
||||
let mut q = stroke_prompt(&[&fg, &bx], 1.0);
|
||||
let mut q = stroke_prompt(&[&fg, &bx], &[], 1.0, 0, 0);
|
||||
let soft: Vec<f32> = (0..100 * 80).map(|i| ((i % 100) < 10) as u8 as f32).collect();
|
||||
assert!(add_prior_prompt(&mut q, &soft, 100, 80));
|
||||
assert!(add_prior_prompt(&mut q, &soft, &[], 100, 80));
|
||||
assert_eq!(q.bbox, Some([20.0, 10.0, 80.0, 60.0]));
|
||||
}
|
||||
|
||||
/// A model is never prompted against the latest correction: stroke points that a later stroke
|
||||
/// of the other kind painted over are dropped, and the propagated matte's points and mask leave
|
||||
/// out what this frame's background strokes removed.
|
||||
#[test]
|
||||
fn model_prompts_follow_the_latest_stroke() {
|
||||
let green = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 4.0, points: vec![[50.0, 40.0]] };
|
||||
let red = Stroke { kind: StrokeKind::Bg, ..green.clone() };
|
||||
let (w, h) = (100, 80);
|
||||
let prompt = |st: &[&Stroke]| stroke_prompt(st, &resolve(st, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h), 1.0, w, h).points;
|
||||
assert_eq!(prompt(&[&green, &red]), vec![([50.0, 40.0], false)]);
|
||||
assert_eq!(prompt(&[&green, &red, &green]), vec![([50.0, 40.0], true); 2]);
|
||||
let soft: Vec<f32> = (0..w * h).map(|i| ((i % w) < 50) as u8 as f32).collect();
|
||||
let erase = Stroke { kind: StrokeKind::Bg, frame: 0, radius: 15.0, points: vec![[25.0, 40.0]] };
|
||||
let hard = resolve(&[&erase], StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h);
|
||||
let mut q = Prompt::default();
|
||||
assert!(add_prior_prompt(&mut q, &soft, &hard, w, h));
|
||||
assert!(!q.points.is_empty());
|
||||
assert!(q.points.iter().all(|(p, fg)| *fg && owner(&hard, w, h, *p) != Some(HARD_BG)), "{:?}", q.points);
|
||||
let mask = q.mask.unwrap();
|
||||
assert!(hard.iter().zip(&mask).all(|(hd, m)| *hd != HARD_BG || *m == 0.0));
|
||||
}
|
||||
|
||||
/// A stand-in model that counts its calls; its foreground is a fixed rectangle.
|
||||
struct CountingModel([usize; 4], std::sync::atomic::AtomicUsize);
|
||||
|
||||
impl effectcraft_segment::MaskModel for CountingModel {
|
||||
fn info(&self) -> &'static effectcraft_segment::ModelInfo {
|
||||
&effectcraft_segment::MOBILE_SAM
|
||||
}
|
||||
fn segment(&self, _: &[[f32; 3]], w: usize, h: usize, _: &Prompt) -> Result<Vec<f32>, String> {
|
||||
self.1.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let [x0, y0, x1, y1] = self.0;
|
||||
Ok((0..w * h).map(|i| ((i % w) >= x0 && (i % w) < x1 && (i / w) >= y0 && (i / w) < y1) as u8 as f32 * 0.98 + 0.01).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// A small background correction in the middle of a large foreground dot removes the only point
|
||||
/// sampled along the dot, but most of the dot is still foreground: the model is still prompted
|
||||
/// with it, from a point inside what is left. A dot painted over completely prompts nothing.
|
||||
#[test]
|
||||
fn a_correction_keeps_the_model_prompted_with_what_is_left() {
|
||||
let (img, gt, rect) = bar_frame();
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let dot = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 15.0, points: vec![[80.0, 50.0]] };
|
||||
let tap = Stroke { kind: StrokeKind::Bg, radius: 2.0, ..dot.clone() };
|
||||
let refs = [&dot, &tap];
|
||||
let model = CountingModel(rect, Default::default());
|
||||
let seg = segment_with(&img, &refs, None, &SegOpts::default(), 0.0, Some(&model));
|
||||
assert_eq!(model.1.load(std::sync::atomic::Ordering::Relaxed), 1, "the model was prompted");
|
||||
assert!(iou(&seg.matte, >) > 0.95, "IoU {}", iou(&seg.matte, >));
|
||||
let hard = resolve(&refs, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h);
|
||||
let fg: Vec<[f32; 2]> = stroke_prompt(&refs, &hard, 1.0, w, h).points.into_iter().filter(|p| p.1).map(|p| p.0).collect();
|
||||
let [q] = fg.as_slice() else { panic!("one foreground point, got {fg:?}") };
|
||||
let r = (q[0] - 80.0).hypot(q[1] - 50.0);
|
||||
assert!(r > 5.0 && r < 13.0, "{q:?} sits inside what is left of the dot");
|
||||
assert_eq!(hard[q[1] as usize * w + q[0] as usize], HARD_FG);
|
||||
let cover = Stroke { radius: 16.0, ..tap };
|
||||
let refs = [&dot, &cover];
|
||||
let hard = resolve(&refs, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h);
|
||||
assert!(stroke_prompt(&refs, &hard, 1.0, w, h).points.iter().all(|p| !p.1));
|
||||
}
|
||||
|
||||
@@ -93,6 +93,55 @@ fn dist(a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
fn bits(v: &Option<([f64; 2], [f64; 2])>) -> Option<[u64; 4]> {
|
||||
v.map(|(c, a)| [c[0].to_bits(), c[1].to_bits(), a[0].to_bits(), a[1].to_bits()])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scorer_matches_ncc_for_integer_offsets() {
|
||||
let pose = Pose { c: [60.0, 50.0], rot: 0.0, scale: 1.0 };
|
||||
let img = frame(120, 100, pose, 20.0, 0.02, 1, None);
|
||||
let crop = Crop::new(&Frame::new(&img), &TrackOptions::default(), [60.0, 50.0], [40.0, 30.0], 1);
|
||||
for (lvl, c, a) in [(0, [30.3, 25.9], [0.97, 0.21]), (0, [12.0, 9.5], [1.2, -0.3]), (1, [20.4, 14.2], [0.8, 0.0]), (0, [30.3, 25.9], [1.0, 0.0])] {
|
||||
let plane = &crop.levels[lvl];
|
||||
let r = [7, 5];
|
||||
let patch = Patch::sample(plane, c, a, r);
|
||||
let sc = Scorer::new(plane, &patch, [c[0] + 0.3, c[1] - 0.45], a);
|
||||
for j in -9..=9 {
|
||||
for i in -9..=9 {
|
||||
let q = [c[0] + 0.3 + i as f64, c[1] - 0.45 + j as f64];
|
||||
assert_eq!(sc.at(i, j).to_bits(), ncc(plane, &patch, q, a).to_bits(), "level {lvl} {a:?} ({i}, {j})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lk_refine_with_a_gradient_window_equals_the_full_gradients() {
|
||||
let pose = Pose { c: [100.0, 90.0], rot: 5.0, scale: 1.05 };
|
||||
let (img0, img1) = (frame(200, 180, pose, 30.0, 0.02, 1, None), frame(200, 180, Pose { c: [103.2, 88.1], rot: 6.5, scale: 1.08 }, 30.0, 0.02, 2, None));
|
||||
let (opts, r) = (TrackOptions::default(), [24, 24]);
|
||||
let (c0, a0) = ([100.0, 90.0], [1.0, 0.0]);
|
||||
let tpl = Crop::new(&Frame::new(&img0), &opts, c0, [60.0, 60.0], 0);
|
||||
let crop = Crop::new(&Frame::new(&img1), &opts, c0, [60.0, 60.0], 0);
|
||||
let plane = &crop.levels[0];
|
||||
let patch = Patch::sample(&tpl.levels[0], tpl.to_level(c0, 0), a0, r);
|
||||
let mid = crop.to_level(c0, 0);
|
||||
// Starts within the window, and far enough off that the iterations leave it (even near the edge).
|
||||
for (d, edge) in [([0.0, 0.0], false), ([1.3, -0.8], false), ([4.0, 5.0], false), ([9.0, -9.0], false), ([0.0, 0.0], true)] {
|
||||
let start = if edge { [4.0, 4.0] } else { [mid[0] + d[0], mid[1] + d[1]] };
|
||||
for shape in [true, false] {
|
||||
let (mut full, mut win) = (Grad::full(plane), Grad::around(plane, start, a0, r));
|
||||
let x = lk_refine(&crop, &mut full, &patch, start, a0, shape);
|
||||
let y = lk_refine(&crop, &mut win, &patch, start, a0, shape);
|
||||
assert_eq!(bits(&x), bits(&y), "{d:?} edge {edge} shape {shape}");
|
||||
// A window that misses the taps altogether is replaced by the full gradients.
|
||||
let mut off = Grad::around(plane, [start[0] + 15.0, start[1] + 15.0], a0, r);
|
||||
assert_eq!(bits(&x), bits(&lk_refine(&crop, &mut off, &patch, start, a0, shape)), "{d:?} edge {edge} shape {shape}: missed window");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_translation_path_subpixel() {
|
||||
let path = |f: usize| {
|
||||
@@ -114,6 +163,27 @@ fn recovers_translation_path_subpixel() {
|
||||
assert!(worst < 0.25, "worst error {worst} px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_after_sudden_jumps_the_prediction_misses() {
|
||||
// Alternating large steps defeat the velocity prediction, so the fine stage must keep its
|
||||
// wider neighbourhood; smooth frames in between use the narrow one.
|
||||
let path = |f: usize| {
|
||||
let t = f as f64;
|
||||
let jump = [0.0, 7.0, -5.0, 9.0, -8.0, 6.0, -7.0, 8.0];
|
||||
let j = jump[f % jump.len()];
|
||||
Pose { c: [120.0 + 1.5 * t + j, 100.0 + 1.1 * t - 0.6 * j], rot: 0.0, scale: 1.0 }
|
||||
};
|
||||
let f0 = frame(360, 260, path(0), 40.0, 0.02, 0, None);
|
||||
let mut tr = Tracker::new(TrackOptions::default(), &[spec(path(0).c, 31.0, 91.0)], &Frame::new(&f0));
|
||||
for f in 1..24 {
|
||||
let img = frame(360, 260, path(f), 40.0, 0.02, f as u32, None);
|
||||
let r = tr.step(&Frame::new(&img))[0];
|
||||
let e = dist(r.center, path(f).c);
|
||||
assert!(e < 0.3, "frame {f}: error {e} px");
|
||||
assert_eq!(r.status, Status::Tracked);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_offset_and_channels() {
|
||||
let pose = |f: usize| Pose { c: [120.0 + 3.3 * f as f64, 100.0 - 1.7 * f as f64], rot: 0.0, scale: 1.0 };
|
||||
|
||||
@@ -40,11 +40,16 @@ impl EffectcraftApp {
|
||||
restore_max,
|
||||
audio_frame: (!plan.video).then_some(plan.first),
|
||||
audio_held: false,
|
||||
starting: false,
|
||||
starting_since: now,
|
||||
first_cached: None,
|
||||
shown_at: Default::default(),
|
||||
};
|
||||
if !self.playback.caching {
|
||||
if plan.video {
|
||||
self.playback.audio_held = self.audio_plays();
|
||||
let audible = self.audio_plays();
|
||||
self.playback.audio_held = audible;
|
||||
self.playback.starting = audible;
|
||||
} else {
|
||||
self.start_audio(fr.tick_of(plan.first));
|
||||
}
|
||||
@@ -104,6 +109,7 @@ impl EffectcraftApp {
|
||||
let was = std::mem::replace(&mut self.playback.playing, false);
|
||||
self.audio = None;
|
||||
self.playback.caching = false;
|
||||
self.playback.starting = false;
|
||||
if !was {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,29 @@ use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio,
|
||||
/// [`EffectcraftApp::editing`]).
|
||||
const EDIT_QUIET: f64 = 0.3;
|
||||
|
||||
/// Longest a preview with sound holds its first frame, once that frame is cached, waiting for the
|
||||
/// frames ahead (seconds, #947). A frame ahead that never gets cached mustn't hold it for ever:
|
||||
/// the sound starts from the first frame anyway, and the preview goes on as a stalled one does.
|
||||
const STARTUP_WAIT: f64 = 2.0;
|
||||
|
||||
/// Longest a preview with sound holds its first frame in all (seconds, #947). The first frame
|
||||
/// itself can take a while (a heavy comp, a busy machine), and [`STARTUP_WAIT`] counts only from
|
||||
/// when it is cached; but one that never gets cached (larger than the RAM preview, which the size
|
||||
/// estimate can't always tell) mustn't hold it for ever. The preview then goes on as a stalled
|
||||
/// one does: its sound starts once the frames are cached.
|
||||
const STARTUP_LIMIT: f64 = 10.0;
|
||||
|
||||
/// How many frames from the first a preview with sound waits for before it starts (#947): the
|
||||
/// prefetch's `ahead`, but no more than its `window` requests from the current frame (offsets
|
||||
/// `0..window`), and no more than fit in the RAM preview `budget` (90 %, as for the prefetch) at
|
||||
/// `frame_bytes`, the size frames really render at; at least the current frame, which the viewer
|
||||
/// always requests. Waiting for a frame that is never requested, or that can't stay cached with
|
||||
/// the others, would hold the first frame for ever.
|
||||
fn startup_frames(ahead: i64, window: i64, budget: usize, frame_bytes: usize) -> i64 {
|
||||
let fit = (budget as f64 * 0.9 / frame_bytes.max(1) as f64) as i64;
|
||||
ahead.min(window).min(fit).max(1)
|
||||
}
|
||||
|
||||
impl EffectcraftApp {
|
||||
/// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold
|
||||
/// and keep rendering ahead (the green cache bar fills, then playback runs in real time).
|
||||
@@ -53,6 +76,9 @@ impl EffectcraftApp {
|
||||
return;
|
||||
}
|
||||
let fit = self.frames_that_fit(&c, scale);
|
||||
if self.hold_first_frame(&pass, fit) {
|
||||
return;
|
||||
}
|
||||
self.start_held_audio(&pass, fit);
|
||||
if self.advance_audio_clock(&pass, step) {
|
||||
return;
|
||||
@@ -179,8 +205,54 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the
|
||||
/// prefetch window when the silent preview keeps real time, else the rest of the preview.
|
||||
/// Starting a preview with sound: hold the first frame until the frames ahead are cached, then
|
||||
/// start the sound from that same frame (see `Playback::starting`). Without an output device
|
||||
/// the picture plays on its own clock from it. How many frames ahead: only ones
|
||||
/// `prefetch_playback` requests and that fit in the RAM preview at the size they really render
|
||||
/// (an Extended Viewer frame is larger than the comp), so the wait always ends. Returns true
|
||||
/// while it holds the first frame.
|
||||
fn hold_first_frame(&mut self, pass: &PlaybackPass<'_>, fit: i64) -> bool {
|
||||
let PlaybackPass { ctx, comp, scale, now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass;
|
||||
if !self.playback.starting {
|
||||
return false;
|
||||
}
|
||||
let Some(p) = plan else { return false };
|
||||
self.playback.start_wall = now;
|
||||
self.playback.start_frame = cur;
|
||||
// `prefetch_playback` requests every uncached frame of offsets 0..window from here.
|
||||
let window = (ahead * 3).min(fit - 1);
|
||||
let need = self.frames.get(&FrameKey { frame: cur, ..series }).map(|img| {
|
||||
let [w, h] = img.size();
|
||||
startup_frames(ahead, window, self.frames.budget(), w.saturating_mul(h).saturating_mul(4))
|
||||
});
|
||||
if need.is_some() && self.playback.first_cached.is_none() {
|
||||
self.playback.first_cached = Some(now);
|
||||
}
|
||||
// Don't wait for frames the RAM preview can't hold (not even one frame fits), for the
|
||||
// frames ahead longer than STARTUP_WAIT once the first is in, nor longer than
|
||||
// STARTUP_LIMIT in all (a frame that never gets cached).
|
||||
let give_up = (self.frames.budget() as f64) < self.frame_bytes(comp, scale)
|
||||
|| self.playback.first_cached.is_some_and(|t| now - t >= STARTUP_WAIT)
|
||||
|| now - self.playback.starting_since >= STARTUP_LIMIT;
|
||||
if !give_up && !need.is_some_and(|n| self.cached_ahead(&p, &series, cur, n)) {
|
||||
self.playback.waiting = true;
|
||||
ctx.request_repaint();
|
||||
return true;
|
||||
}
|
||||
self.playback.starting = false;
|
||||
self.playback.waiting = false;
|
||||
// Not on a frame that isn't cached: the stall below would stop the sound at once. The
|
||||
// preview goes on as a stalled one does, and the sound starts once frames are cached.
|
||||
if need.is_some() {
|
||||
self.playback.audio_held = false;
|
||||
self.start_audio(fr.tick_of(cur));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Held sound starts once the frames ahead are cached again after a stall (see
|
||||
/// `Playback::audio_held`): the prefetch window when the silent preview keeps real time, else
|
||||
/// the rest of the preview.
|
||||
fn start_held_audio(&mut self, pass: &PlaybackPass<'_>, fit: i64) {
|
||||
let PlaybackPass { now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass;
|
||||
if !self.playback.audio_held {
|
||||
@@ -365,9 +437,13 @@ impl EffectcraftApp {
|
||||
}
|
||||
|
||||
fn frames_that_fit(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> i64 {
|
||||
((self.frames.budget() as f64 * 0.9 / self.frame_bytes(comp, scale)) as i64).max(1)
|
||||
}
|
||||
|
||||
/// Bytes of one comp-sized viewer frame at `scale` (Extended Viewer frames are larger).
|
||||
fn frame_bytes(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> f64 {
|
||||
let side = |n: u32| (n as f64 * scale).ceil().max(1.0);
|
||||
let frame = side(comp.width) * side(comp.height) * 4.0;
|
||||
((self.frames.budget() as f64 * 0.9 / frame) as i64).max(1)
|
||||
side(comp.width) * side(comp.height) * 4.0
|
||||
}
|
||||
|
||||
fn frames_parallelism(&self) -> usize {
|
||||
@@ -379,3 +455,28 @@ impl EffectcraftApp {
|
||||
self.frames.parallelism()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_tests {
|
||||
use super::startup_frames;
|
||||
|
||||
/// The frames a preview with sound waits for are always requested and fit together (#947).
|
||||
#[test]
|
||||
fn startup_waits_only_for_frames_it_requests_and_can_keep() {
|
||||
// A 64×36 frame: 9216 bytes. A 46 080-byte budget fits 4 of them (90 %); the prefetch
|
||||
// window then requests offsets 0..3, so the start needs those 3, not 4 it never asks for.
|
||||
let frame = 64 * 36 * 4;
|
||||
let fit = 4;
|
||||
let window = (32i64 * 3).min(fit - 1);
|
||||
assert_eq!(startup_frames(32, window, 46_080, frame), 3);
|
||||
// A roomy budget: the prefetch's `ahead` frames.
|
||||
assert_eq!(startup_frames(8, 24, 100_000_000, frame), 8);
|
||||
// Extended Viewer: a 160×120 comp renders 320×248 frames. 952 320 bytes fit 11 comp-sized
|
||||
// frames but only 2 real ones (90 %): wait for 2, not 4.
|
||||
assert_eq!(startup_frames(4, 11, 952_320, 320 * 248 * 4), 2);
|
||||
// Nothing fits, or no window: still the current frame, never 0.
|
||||
assert_eq!(startup_frames(8, 0, 0, frame), 1);
|
||||
// A zero frame size counts as 1 byte: no division by zero.
|
||||
assert_eq!(startup_frames(8, 24, 10, 0), 8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Pro
|
||||
use effectcraft_engine::render::disk_cache::{self, DiskCache};
|
||||
use effectcraft_engine::render::{CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_gpu::{DisplayFrame, Gpu};
|
||||
use effectcraft_gpu::{DisplayFrame, FrameError, Gpu};
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// Comp identities (and the project snapshots behind them) the RAM preview keeps frames of.
|
||||
@@ -317,23 +317,6 @@ pub fn comp_content(project: &Project, comp: ItemId) -> u64 {
|
||||
comp_content_with(project, comp, project, |_, c| c)
|
||||
}
|
||||
|
||||
/// Whether two versions of a comp draw the same pixels. Markers only count when an expression
|
||||
/// could read them (`markers_count`).
|
||||
fn same_pixels(a: &Comp, b: &Comp, markers_count: bool) -> bool {
|
||||
if markers_count { a.same_pixels(b) } else { a.same_pixels_ignoring_markers(b) }
|
||||
}
|
||||
|
||||
/// Whether a layer anywhere in the project has an expression, which can read markers.
|
||||
fn project_has_expressions(project: &Project) -> bool {
|
||||
fn has_expression(g: &PropGroup) -> bool {
|
||||
g.children.iter().any(|c| match c {
|
||||
Node::Prop(p) => p.expr.is_some(),
|
||||
Node::Group(sub) => has_expression(sub),
|
||||
})
|
||||
}
|
||||
project.items.values().any(|i| matches!(&i.kind, ItemKind::Comp(c) if c.layers.iter().any(|l| has_expression(&l.props))))
|
||||
}
|
||||
|
||||
/// [`comp_content`] with each comp's version selected for both its sources and its address.
|
||||
/// Sources removed by undo are read from `base`.
|
||||
fn comp_content_with<'a>(project: &'a Project, comp: ItemId, base: &'a Project, version: impl Fn(ItemId, &'a Arc<Comp>) -> &'a Arc<Comp>) -> u64 {
|
||||
@@ -762,12 +745,12 @@ impl Frames {
|
||||
fn identity(&self, project: &Arc<Project>, comp: ItemId) -> Identity {
|
||||
let plain = comp_content(project, comp);
|
||||
let Ok(mut bases) = self.bases.lock() else { return (plain, None) };
|
||||
// Markers can only change pixels through expressions, which read them.
|
||||
let markers_count = project_has_expressions(project);
|
||||
// Markers are pixels only to the expressions that read them (#1083).
|
||||
let markers = project.reads_markers();
|
||||
if let Some((base_id, base)) = bases.get(&comp.0)
|
||||
&& *base_id != plain
|
||||
&& comp_content_with(project, comp, project, |id, c| match base.item(id).map(|i| &i.kind) {
|
||||
Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => b,
|
||||
Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => b,
|
||||
_ => c,
|
||||
}) == *base_id
|
||||
{
|
||||
@@ -779,7 +762,7 @@ impl Frames {
|
||||
if let Some((base_id, base)) = bases.get(&comp.0)
|
||||
&& let (Some(old), Some(new)) = (base.comp(comp), project.comp(comp))
|
||||
&& comp_content_with(project, comp, base, |id, c| match base.item(id).map(|i| &i.kind) {
|
||||
Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => b,
|
||||
Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => b,
|
||||
_ => c,
|
||||
}) == *base_id
|
||||
&& let Some((s, e)) = new.changed_span(old)
|
||||
@@ -1318,11 +1301,16 @@ impl Worker {
|
||||
|
||||
/// A failed scoped attempt retries on CPU. Retired contexts cannot recover by reducing
|
||||
/// their budget; only a healthy device's allocation failure uses the upstream backoff.
|
||||
fn gpu_attempt_failed(&self, gpu: &Gpu, e: &str) {
|
||||
fn gpu_attempt_failed(&self, gpu: &Gpu, e: &FrameError) {
|
||||
if gpu.context().check_health().is_err() {
|
||||
log::warn!("the GPU context is retired ({e}); the frame renders on the CPU");
|
||||
return;
|
||||
}
|
||||
// Not memory pressure: keeping fewer GPU frames wouldn't help (#1319).
|
||||
if let FrameError::Validation(_) = e {
|
||||
log::warn!("the GPU rejected the frame ({e}); it renders on the CPU, the next frame tries the GPU again");
|
||||
return;
|
||||
}
|
||||
log::warn!("the GPU ran out of memory ({e}); the frame renders on the CPU and fewer GPU frames are kept");
|
||||
if let Ok(mut c) = self.cache.lock() {
|
||||
c.gpu_out_of_memory();
|
||||
|
||||
@@ -895,33 +895,59 @@ fn adding_a_source_does_not_hide_changes_to_existing_dependencies() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adding, moving or deleting a marker (Num* while a preview plays) keeps the cached frames:
|
||||
/// the sound would otherwise stop at the next frame that has to be rendered again (#1083).
|
||||
/// Only expressions that read markers make them part of the pixels.
|
||||
#[test]
|
||||
fn markers_keep_the_frames() {
|
||||
fn markers_keep_the_frames_unless_an_expression_reads_them() {
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
let mut s = Session::default();
|
||||
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
|
||||
let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp"));
|
||||
let solid = id(s.execute("layer.newSolid", json!({"comp": outer.0, "color": "#ff0000"})).unwrap(), "layer");
|
||||
let inner = ItemId(id(s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "duration": 2})).unwrap(), "comp"));
|
||||
let solid = id(s.execute("layer.newSolid", json!({"comp": inner.0, "color": "#ff0000"})).unwrap(), "layer");
|
||||
let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 2})).unwrap(), "comp"));
|
||||
id(s.execute("layer.addItem", json!({"comp": outer.0, "item": inner.0})).unwrap(), "layer");
|
||||
let f = Frames::default();
|
||||
let at = |s: &Session| f.content_of(&s.project, s.revision, outer);
|
||||
let o0 = at(&s);
|
||||
s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap();
|
||||
assert_eq!(at(&s), o0, "a marker changes no pixels, so it keeps the frames");
|
||||
let at = |s: &Session, c: ItemId| f.content_of(&s.project, s.revision, c);
|
||||
let (o0, i0) = (at(&s, outer), at(&s, inner));
|
||||
s.execute("comp.addMarker", json!({"comp": inner.0, "time": 0.5, "comment": "beat"})).unwrap();
|
||||
s.execute("comp.addMarker", json!({"comp": outer.0, "time": 1.0})).unwrap();
|
||||
s.execute("comp.addMarker", json!({"comp": outer.0, "time": 1.5})).unwrap();
|
||||
assert_eq!((at(&s, outer), at(&s, inner)), (o0, i0), "comp markers");
|
||||
s.execute("markers.set", json!({"comp": inner.0, "layer": solid, "new": true, "time": 0.25})).unwrap();
|
||||
assert_eq!((at(&s, outer), at(&s, inner)), (o0, i0), "layer marker");
|
||||
// An expression reading markers makes them count again.
|
||||
s.execute("prop.setExpression", json!({"comp": inner.0, "layer": solid, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap();
|
||||
let (o1, i1) = (at(&s, outer), at(&s, inner));
|
||||
assert_ne!(o1, o0);
|
||||
s.execute("comp.addMarker", json!({"comp": inner.0, "time": 1.0})).unwrap();
|
||||
assert_ne!(at(&s, inner), i1, "marker read by an expression");
|
||||
assert_ne!(at(&s, outer), o1, "marker read by an expression in a nested comp");
|
||||
}
|
||||
|
||||
/// A protected time marker with a duration sets the comp's timing (Responsive Design), so it
|
||||
/// changes the frames even though plain markers do not; and any expression, which can read
|
||||
/// markers, makes every marker count.
|
||||
#[test]
|
||||
fn markers_count_when_an_expression_can_read_them() {
|
||||
fn timing_markers_and_expressions_still_change_the_frames() {
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
let mut s = Session::default();
|
||||
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
|
||||
let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp"));
|
||||
let solid = id(s.execute("layer.newSolid", json!({"comp": outer.0, "color": "#ff0000"})).unwrap(), "layer");
|
||||
s.execute("prop.setExpression", json!({"comp": outer.0, "layer": solid, "path": "transform/opacity", "expression": "1"})).unwrap();
|
||||
let f = Frames::default();
|
||||
let at = |s: &Session| f.content_of(&s.project, s.revision, outer);
|
||||
let o0 = at(&s);
|
||||
s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap();
|
||||
assert_ne!(at(&s), o0, "an expression can read the marker, so the frames change");
|
||||
assert_eq!(at(&s), o0, "a plain marker changes no pixels, so it keeps the frames");
|
||||
s.execute("comp.addMarker", json!({"comp": outer.0, "time": 0.25})).unwrap();
|
||||
s.execute("markers.set", json!({"comp": outer.0, "index": 0, "duration": 0.25, "protected": true})).unwrap();
|
||||
let o1 = at(&s);
|
||||
assert_ne!(o1, o0, "a protected time marker with a duration sets the timing");
|
||||
s.execute("prop.setExpression", json!({"comp": outer.0, "layer": solid, "path": "transform/opacity", "expression": "1"})).unwrap();
|
||||
let o2 = at(&s);
|
||||
s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.75})).unwrap();
|
||||
assert_ne!(at(&s), o2, "an expression can read the marker, so the frames change");
|
||||
}
|
||||
|
||||
@@ -451,31 +451,313 @@ const TRADITIONAL_CHINESE: &[Row] = &[];
|
||||
/// Japanese (`ja`): reserved for the Japanese data point on #821, so both languages share this
|
||||
/// one catalog instead of inventing a second mechanism. A translator fills one row per effect,
|
||||
/// ids from [`SIMPLIFIED_CHINESE`]; the names Adobe's Japanese interface leaves in English (the
|
||||
/// `CC` series, the `VR` set, the `OCIO` set, `Cryptomatte`, `EXtractoR`, `IDentifier`,
|
||||
/// `Key Light`, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects) repeat that
|
||||
/// English name here rather than being left out.
|
||||
/// `CC` series, `Cryptomatte`, `EXtractoR`, `IDentifier` and `Key Light`) repeat that English name
|
||||
/// here rather than being left out. An effect with no row draws its English name anyway, which is
|
||||
/// what the `OCIO` set, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects do.
|
||||
///
|
||||
/// The rows below were read from the installed After Effects 2026 (26.5) Japanese interface
|
||||
/// (its generated Effect menu and its Effects & Presets panel).
|
||||
/// The rows below come from AE 2026's Japanese interface where it was read directly (its Effect menu
|
||||
/// and its Effects & Presets panel) and from Adobe's own Japanese help where the panel was not to
|
||||
/// hand. A published glossary is used to cross-check, never as the only source: it normalises the
|
||||
/// typography (half-width parentheses where AE draws full-width) and still carries CS6-era names for
|
||||
/// the `CC` series, which AE 2026 leaves in English.
|
||||
const JAPANESE_EFFECTS: &[Row] = &[
|
||||
("ec.3d.channelextract", "3D Channel Extract", "3Dチャンネル抽出"),
|
||||
("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"),
|
||||
("ec.3d.depthmatte", "Depth Matte", "デプスマット"),
|
||||
("ec.3d.depthoffield", "Depth of Field", "被写界深度"),
|
||||
("ec.3d.extractor", "EXtractoR", "EXtractoR"),
|
||||
("ec.3d.fog3d", "Fog 3D", "フォグ3D"),
|
||||
("ec.3d.identifier", "IDentifier", "IDentifier"),
|
||||
("ec.3d.idmatte", "ID Matte", "IDマット"),
|
||||
("ec.audio.backwards", "Backwards", "リバース"),
|
||||
("ec.audio.basstreble", "Bass & Treble", "バス&トレブル"),
|
||||
("ec.audio.compressor", "Compressor", "コンプレッサー"),
|
||||
("ec.audio.delay", "Delay", "ディレイ"),
|
||||
("ec.audio.distortion", "Distortion", "歪み"),
|
||||
("ec.audio.flangechorus", "Flange & Chorus", "フランジ&コーラス"),
|
||||
("ec.audio.gate", "Gate", "ゲート"),
|
||||
("ec.audio.highlowpass", "High-Low Pass", "ハイパス/ローパス"),
|
||||
("ec.audio.modulator", "Modulator", "モジュレーター"),
|
||||
("ec.audio.parametriceq", "Parametric EQ", "パラメトリックEQ"),
|
||||
("ec.audio.reverb", "Reverb", "リバーブ"),
|
||||
("ec.audio.stereomixer", "Stereo Mixer", "ステレオミキサー"),
|
||||
("ec.audio.tone", "Tone", "トーン"),
|
||||
("ec.blur.bilateral", "Bilateral Blur", "ブラー(バイラテラル)"),
|
||||
("ec.blur.cameralens", "Camera Lens Blur", "ブラー(カメラレンズ)"),
|
||||
("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "カメラぶれ除去"),
|
||||
("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"),
|
||||
("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"),
|
||||
("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"),
|
||||
("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"),
|
||||
("ec.blur.channel", "Channel Blur", "ブラー(チャンネル)"),
|
||||
("ec.blur.compound", "Compound Blur", "ブラー(合成)"),
|
||||
("ec.blur.directional", "Directional Blur", "ブラー(方向)"),
|
||||
("ec.blur.fastbox", "Fast Box Blur", "高速ボックスブラー"),
|
||||
("ec.blur.gaussian", "Gaussian Blur", "ブラー(ガウス)"),
|
||||
("ec.blur.radial", "Radial Blur", "ブラー(放射状)"),
|
||||
("ec.blur.reduceflicker", "Reduce Interlace Flicker", "インターレースのちらつき削減"),
|
||||
("ec.blur.sharpen", "Sharpen", "シャープ"),
|
||||
("ec.blur.smart", "Smart Blur", "ブラー(詳細)"),
|
||||
("ec.blur.unsharp", "Unsharp Mask", "アンシャープマスク"),
|
||||
("ec.channel.arithmetic", "Arithmetic", "アリスマチック"),
|
||||
("ec.channel.blend", "Blend", "ブレンド"),
|
||||
("ec.channel.calculations", "Calculations", "計算"),
|
||||
("ec.channel.cccomposite", "CC Composite", "CC Composite"),
|
||||
("ec.channel.combiner", "Channel Combiner", "チャンネルコンバイナー"),
|
||||
("ec.channel.compoundarithmetic", "Compound Arithmetic", "合成アリスマチック"),
|
||||
("ec.channel.invert", "Invert", "反転"),
|
||||
("ec.channel.minimax", "Minimax", "最大/最小"),
|
||||
("ec.channel.removecolormatting", "Remove Color Matting", "カラーマット削除"),
|
||||
("ec.channel.setchannels", "Set Channels", "チャンネル設定"),
|
||||
("ec.channel.setmatte", "Set Matte", "マット設定"),
|
||||
("ec.channel.shiftchannels", "Shift Channels", "チャンネルシフト"),
|
||||
("ec.channel.solidcomposite", "Solid Composite", "単色合成"),
|
||||
("ec.color.autocolor", "Auto Color", "自動カラー補正"),
|
||||
("ec.color.autocontrast", "Auto Contrast", "自動コントラスト"),
|
||||
("ec.color.autolevels", "Auto Levels", "自動レベル補正"),
|
||||
("ec.color.blackwhite", "Black & White", "白黒"),
|
||||
("ec.color.brightnesscontrast", "Brightness & Contrast", "輝度&コントラスト"),
|
||||
("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"),
|
||||
("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"),
|
||||
("ec.color.cckernel", "CC Kernel", "CC Kernel"),
|
||||
("ec.color.cctoner", "CC Toner", "CC Toner"),
|
||||
("ec.color.changecolor", "Change Color", "色を変更"),
|
||||
("ec.color.changetocolor", "Change to Color", "他のカラーへ変更"),
|
||||
("ec.color.channelmixer", "Channel Mixer", "チャンネルミキサー"),
|
||||
("ec.color.colorama", "Colorama", "コロラマ"),
|
||||
("ec.color.colorbalance", "Color Balance", "カラーバランス"),
|
||||
("ec.color.colorbalancehls", "Color Balance (HLS)", "カラーバランス(HLS)"),
|
||||
("ec.color.colorlink", "Color Link", "カラーリンク"),
|
||||
("ec.color.colorstabilizer", "Color Stabilizer", "カラースタビライザー"),
|
||||
("ec.color.curves", "Curves", "トーンカーブ"),
|
||||
("ec.color.equalize", "Equalize", "平均化(イコライズ)"),
|
||||
("ec.color.exposure", "Exposure", "露光量"),
|
||||
("ec.color.gammapedestalgain", "Gamma/Pedestal/Gain", "ガンマ/ペデスタル/ゲイン"),
|
||||
("ec.color.huesaturation", "Hue/Saturation", "色相/彩度"),
|
||||
("ec.color.leavecolor", "Leave Color", "色抜き"),
|
||||
("ec.color.levels", "Levels", "レベル補正"),
|
||||
("ec.color.levelsic", "Levels (Individual Controls)", "レベル(個々の制御)"),
|
||||
("ec.color.lumetri", "Lumetri Color", "Lumetri カラー"),
|
||||
("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL 変換"),
|
||||
("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO 色空間変換"),
|
||||
("ec.color.ociodisplay", "OCIO Display Transform", "OCIO 表示変換"),
|
||||
("ec.color.ociofile", "OCIO File Transform", "OCIO ファイル変換"),
|
||||
("ec.color.ociolook", "OCIO Look Transform", "OCIO Look トランスフォーム"),
|
||||
("ec.color.photofilter", "Photo Filter", "レンズフィルター"),
|
||||
("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS不定マップ"),
|
||||
("ec.color.selectivecolor", "Selective Color", "特定色域の選択"),
|
||||
("ec.color.shadowhighlight", "Shadow/Highlight", "シャドウ・ハイライト"),
|
||||
("ec.color.tint", "Tint", "色かぶり補正"),
|
||||
("ec.color.tritone", "Tritone", "トライトーン"),
|
||||
("ec.color.vibrance", "Vibrance", "自然な彩度"),
|
||||
("ec.color.videolimiter", "Video Limiter", "ビデオリミッター"),
|
||||
("ec.control.angle", "Angle Control", "角度制御"),
|
||||
("ec.control.checkbox", "Checkbox Control", "チェックボックス制御"),
|
||||
("ec.control.color", "Color Control", "カラー制御"),
|
||||
("ec.control.dropdown", "Dropdown Menu Control", "ドロップダウンメニュー制御"),
|
||||
("ec.control.layer", "Layer Control", "レイヤー制御"),
|
||||
("ec.control.point", "Point Control", "ポイント制御"),
|
||||
("ec.control.point3d", "3D Point Control", "3D ポイント制御"),
|
||||
("ec.control.slider", "Slider Control", "スライダー制御"),
|
||||
("ec.distort.bezierwarp", "Bezier Warp", "ベジェワープ"),
|
||||
("ec.distort.bulge", "Bulge", "バルジ"),
|
||||
("ec.distort.ccbender", "CC Bender", "CC Bender"),
|
||||
("ec.distort.ccbendit", "CC Bend It", "CC Bend It"),
|
||||
("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"),
|
||||
("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"),
|
||||
("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"),
|
||||
("ec.distort.cclens", "CC Lens", "CC Lens"),
|
||||
("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"),
|
||||
("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"),
|
||||
("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"),
|
||||
("ec.distort.ccslant", "CC Slant", "CC Slant"),
|
||||
("ec.distort.ccsmear", "CC Smear", "CC Smear"),
|
||||
("ec.distort.ccsplit", "CC Split", "CC Split"),
|
||||
("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"),
|
||||
("ec.distort.cctiler", "CC Tiler", "CC Tiler"),
|
||||
("ec.distort.cornerpin", "Corner Pin", "コーナーピン"),
|
||||
("ec.distort.displacementmap", "Displacement Map", "ディスプレイスメントマップ"),
|
||||
("ec.distort.liquify", "Liquify", "ゆがみ"),
|
||||
("ec.distort.magnify", "Magnify", "ズーム"),
|
||||
("ec.distort.meshwarp", "Mesh Warp", "メッシュワープ"),
|
||||
("ec.distort.mirror", "Mirror", "ミラー"),
|
||||
("ec.distort.offset", "Offset", "オフセット"),
|
||||
("ec.distort.opticscompensation", "Optics Compensation", "レンズ補正"),
|
||||
("ec.distort.polar", "Polar Coordinates", "極座標"),
|
||||
("ec.distort.reshape", "Reshape", "リシェープ"),
|
||||
("ec.distort.ripple", "Ripple", "波紋"),
|
||||
("ec.distort.rollingshutterrepair", "Rolling Shutter Repair", "ローリングシャッターの修復"),
|
||||
("ec.distort.smear", "Smear", "にじみ"),
|
||||
("ec.distort.spherize", "Spherize", "球面"),
|
||||
("ec.distort.transform", "Transform", "トランスフォーム"),
|
||||
("ec.distort.turbulentdisplace", "Turbulent Displace", "タービュレントディスプレイス"),
|
||||
("ec.distort.twirl", "Twirl", "回転"),
|
||||
("ec.distort.twirllegacy", "Twirl (Legacy)", "Twirl (Legacy)"),
|
||||
("ec.distort.upscale", "Detail-preserving Upscale", "アップスケール(詳細を保持)"),
|
||||
("ec.distort.warp", "Warp", "ワープ"),
|
||||
("ec.distort.warpstabilizer", "Warp Stabilizer", "ワープスタビライザー"),
|
||||
("ec.distort.wavewarp", "Wave Warp", "波形ワープ"),
|
||||
("ec.generate.advancedlightning", "Advanced Lightning", "稲妻(高度)"),
|
||||
("ec.generate.audiospectrum", "Audio Spectrum", "オーディオスペクトラム"),
|
||||
("ec.generate.audiowaveform", "Audio Waveform", "オーディオウェーブフォーム"),
|
||||
("ec.generate.beam", "Beam", "レーザー"),
|
||||
("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"),
|
||||
("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"),
|
||||
("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"),
|
||||
("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"),
|
||||
("ec.generate.ccthreads", "CC Threads", "CC Threads"),
|
||||
("ec.generate.cellpattern", "Cell Pattern", "セルパターン"),
|
||||
("ec.generate.checkerboard", "Checkerboard", "チェッカーボード"),
|
||||
("ec.generate.circle", "Circle", "円"),
|
||||
("ec.generate.ellipse", "Ellipse", "楕円"),
|
||||
("ec.generate.eyedropperfill", "Eyedropper Fill", "スポイト塗り"),
|
||||
("ec.generate.fill", "Fill", "塗り"),
|
||||
("ec.generate.fourcolor", "4-Color Gradient", "4色グラデーション"),
|
||||
("ec.generate.fractal", "Fractal", "フラクタル"),
|
||||
("ec.generate.gradientramp", "Gradient Ramp", "グラデーション"),
|
||||
("ec.generate.grid", "Grid", "グリッド"),
|
||||
("ec.generate.lensflare", "Lens Flare", "レンズフレア"),
|
||||
("ec.generate.paintbucket", "Paint Bucket", "塗りつぶし"),
|
||||
("ec.generate.radiowaves", "Radio Waves", "電波"),
|
||||
("ec.generate.scribble", "Scribble", "落書き"),
|
||||
("ec.generate.stroke", "Stroke", "線"),
|
||||
("ec.generate.vegas", "Vegas", "ベガス"),
|
||||
("ec.generate.writeon", "Write-on", "ブラシアニメーション"),
|
||||
("ec.key.advancedspill", "Advanced Spill Suppressor", "Advanced Spill Suppressor"),
|
||||
("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"),
|
||||
("ec.key.colordifference", "Color Difference Key", "カラー差キー"),
|
||||
("ec.key.colorkey", "Color Key", "カラーキー"),
|
||||
("ec.key.colorrange", "Color Range", "色範囲"),
|
||||
("ec.key.differencematte", "Difference Matte", "異なるマット"),
|
||||
("ec.key.extract", "Extract", "抽出"),
|
||||
("ec.key.innerouter", "Inner/Outer Key", "インナー/アウターキー"),
|
||||
("ec.key.keycleaner", "Key Cleaner", "Key Cleaner"),
|
||||
("ec.key.linearcolor", "Linear Color Key", "リニアカラーキー"),
|
||||
("ec.key.luma", "Luma Key", "ルミナンスキー"),
|
||||
("ec.key.spill", "Spill Suppressor", "スピルサプレッション"),
|
||||
("ec.obsolete.basic3d", "Basic 3D", "基本 3D"),
|
||||
("ec.keying.keylight", "Key Light", "Keylight (1.2)"),
|
||||
("ec.matte.mattechoker", "Matte Choker", "マットチョーク"),
|
||||
("ec.matte.refinehard", "Refine Hard Matte", "ハードマットを調整"),
|
||||
("ec.matte.refinesoft", "Refine Soft Matte", "ソフトマットを調整"),
|
||||
("ec.matte.simplechoker", "Simple Choker", "チョーク"),
|
||||
("ec.noise.addgrain", "Add Grain", "グレイン(追加)"),
|
||||
("ec.noise.curlnoise", "Curl Noise", "カールノイズ"),
|
||||
("ec.noise.dustscratches", "Dust & Scratches", "ダスト&スクラッチ"),
|
||||
("ec.noise.fractal", "Fractal Noise", "フラクタルノイズ"),
|
||||
("ec.noise.matchgrain", "Match Grain", "グレイン(マッチ)"),
|
||||
("ec.noise.median", "Median", "ミディアン"),
|
||||
("ec.noise.medianlegacy", "Median (Legacy)", "ミディアン(レガシー)"),
|
||||
("ec.noise.noise", "Noise", "ノイズ"),
|
||||
("ec.noise.noisealpha", "Noise Alpha", "ノイズアルファ"),
|
||||
("ec.noise.noisehls", "Noise HLS", "ノイズHLS"),
|
||||
("ec.noise.noisehlsauto", "Noise HLS Auto", "ノイズHLSオート"),
|
||||
("ec.noise.removegrain", "Remove Grain", "グレイン(除去)"),
|
||||
("ec.noise.turbulent", "Turbulent Noise", "タービュレントノイズ"),
|
||||
("ec.obsolete.basic3d", "Basic 3D", "基本3D"),
|
||||
("ec.obsolete.basictext", "Basic Text", "基本テキスト"),
|
||||
("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "ブラー(ガウス)(レガシー)"),
|
||||
("ec.obsolete.lightning", "Lightning", "稲妻"),
|
||||
("ec.obsolete.mochashape", "Mocha shape", "mocha shape"),
|
||||
("ec.obsolete.pathtext", "Path Text", "パステキスト"),
|
||||
("ec.perspective.3dglasses", "3D Glasses", "3Dメガネ"),
|
||||
("ec.perspective.bevelalpha", "Bevel Alpha", "ベベルアルファ"),
|
||||
("ec.perspective.beveledges", "Bevel Edges", "ベベルエッジ"),
|
||||
("ec.perspective.cameratracker", "3D Camera Tracker", "3D カメラトラッカー"),
|
||||
("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"),
|
||||
("ec.perspective.ccenvironment", "CC Environment", "CC Environment"),
|
||||
("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"),
|
||||
("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"),
|
||||
("ec.perspective.dropshadow", "Drop Shadow", "ドロップシャドウ"),
|
||||
("ec.perspective.radialshadow", "Radial Shadow", "放射状シャドウ"),
|
||||
("ec.sim.carddance", "Card Dance", "カードダンス"),
|
||||
("ec.sim.caustics", "Caustics", "コースティック"),
|
||||
("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"),
|
||||
("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"),
|
||||
("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"),
|
||||
("ec.sim.cchair", "CC Hair", "CC Hair"),
|
||||
("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"),
|
||||
("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"),
|
||||
("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"),
|
||||
("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"),
|
||||
("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"),
|
||||
("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"),
|
||||
("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"),
|
||||
("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"),
|
||||
("ec.sim.foam", "Foam", "泡"),
|
||||
("ec.sim.particleplayground", "Particle Playground", "パーティクルプレイグラウンド"),
|
||||
("ec.sim.shatter", "Shatter", "シャター"),
|
||||
("ec.sim.waveworld", "Wave World", "ウェーブワールド"),
|
||||
("ec.stylize.brushstrokes", "Brush Strokes", "ブラシストローク"),
|
||||
("ec.stylize.cartoon", "Cartoon", "カートゥーン"),
|
||||
("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"),
|
||||
("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"),
|
||||
("ec.stylize.ccglass", "CC Glass", "CC Glass"),
|
||||
("ec.stylize.cchextile", "CC HexTile", "CC HexTile"),
|
||||
("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"),
|
||||
("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"),
|
||||
("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"),
|
||||
("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"),
|
||||
("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"),
|
||||
("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"),
|
||||
("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"),
|
||||
("ec.stylize.coloremboss", "Color Emboss", "カラーエンボス"),
|
||||
("ec.stylize.emboss", "Emboss", "エンボス"),
|
||||
("ec.stylize.findedges", "Find Edges", "輪郭検出"),
|
||||
("ec.stylize.glow", "Glow", "グロー"),
|
||||
("ec.stylize.mosaic", "Mosaic", "モザイク"),
|
||||
("ec.stylize.motiontile", "Motion Tile", "モーションタイル"),
|
||||
("ec.stylize.posterize", "Posterize", "ポスタリゼーション"),
|
||||
("ec.stylize.roughenedges", "Roughen Edges", "ラフエッジ"),
|
||||
("ec.stylize.scatter", "Scatter", "拡散"),
|
||||
("ec.stylize.strobe", "Strobe Light", "ストロボ"),
|
||||
("ec.stylize.texturize", "Texturize", "テクスチャ"),
|
||||
("ec.stylize.threshold", "Threshold", "しきい値"),
|
||||
("ec.text.numbers", "Numbers", "番号"),
|
||||
("ec.text.timecode", "Timecode", "タイムコード"),
|
||||
("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"),
|
||||
("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"),
|
||||
("ec.time.echo", "Echo", "エコー"),
|
||||
("ec.time.pixelmotionblur", "Pixel Motion Blur", "ピクセルモーションブラー"),
|
||||
("ec.time.posterizetime", "Posterize Time", "ポスタリゼーション時間"),
|
||||
("ec.time.timedifference", "Time Difference", "時間差"),
|
||||
("ec.time.timedisplacement", "Time Displacement", "時間置き換え"),
|
||||
("ec.time.timewarp", "Timewarp", "タイムワープ"),
|
||||
("ec.transition.blockdissolve", "Block Dissolve", "ブロックディゾルブ"),
|
||||
("ec.transition.cardwipe", "Card Wipe", "カードワイプ"),
|
||||
("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"),
|
||||
("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"),
|
||||
("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"),
|
||||
("ec.transition.ccjaws", "CC Jaws", "CC Jaws"),
|
||||
("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"),
|
||||
("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"),
|
||||
("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"),
|
||||
("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"),
|
||||
("ec.transition.cctwister", "CC Twister", "CC Twister"),
|
||||
("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"),
|
||||
("ec.transition.gradientwipe", "Gradient Wipe", "グラデーションワイプ"),
|
||||
("ec.transition.iriswipe", "Iris Wipe", "アイリスワイプ"),
|
||||
("ec.transition.linearwipe", "Linear Wipe", "リニアワイプ"),
|
||||
("ec.transition.radialwipe", "Radial Wipe", "放射状ワイプ"),
|
||||
("ec.transition.venetian", "Venetian Blinds", "ブラインド"),
|
||||
("ec.utility.applylut", "Apply Color LUT", "カラー LUT を適用"),
|
||||
("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"),
|
||||
("ec.utility.cineon", "Cineon Converter", "Cineon コンバーター"),
|
||||
("ec.utility.colorprofileconverter", "Color Profile Converter", "カラープロファイルコンバーター"),
|
||||
("ec.utility.growbounds", "Grow Bounds", "範囲拡張"),
|
||||
("ec.utility.hdrcompander", "HDR Compander", "HDR コンパンダー"),
|
||||
("ec.utility.hdrcompression", "HDR Highlight Compression", "HDR ハイライト圧縮"),
|
||||
("ec.vr.blur", "VR Blur", "VR ブラー"),
|
||||
("ec.vr.chromaticaberrations", "VR Chromatic Aberrations", "VR 色収差"),
|
||||
("ec.vr.colorgradients", "VR Color Gradients", "VR カラーグラデーション"),
|
||||
("ec.vr.converter", "VR Converter", "VR コンバーター"),
|
||||
("ec.vr.denoise", "VR De-Noise", "VR ノイズ除去"),
|
||||
("ec.vr.digitalglitch", "VR Digital Glitch", "VR デジタルグリッチ"),
|
||||
("ec.vr.fractalnoise", "VR Fractal Noise", "VR フラクタルノイズ"),
|
||||
("ec.vr.glow", "VR Glow", "VR グロー"),
|
||||
("ec.vr.planetosphere", "VR Plane to Sphere", "VR 平面として投影"),
|
||||
("ec.vr.rotatesphere", "VR Rotate Sphere", "VR 回転(球)"),
|
||||
("ec.vr.sharpen", "VR Sharpen", "VR シャープ"),
|
||||
("ec.vr.spheretoplane", "VR Sphere to Plane", "VR 球体から平面"),
|
||||
];
|
||||
|
||||
/// Traditional Chinese (`zh-hant`) category labels, keyed by the registry's English category.
|
||||
|
||||
@@ -3353,6 +3353,305 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
("⚠ {} is used by:", "⚠ {} é usado por:"),
|
||||
("Total Offset", "Deslocamento total"),
|
||||
("Per Layer", "Por camada"),
|
||||
("Action-safe", "Área segura de ação"),
|
||||
("Add to favorites", "Adicionar aos favoritos"),
|
||||
("All Footage", "Todas as mídias"),
|
||||
("All Keyframes", "Todos os keyframes"),
|
||||
("Allow Scripts to Write Files and Access Network", "Permitir que scripts gravem arquivos e acessem a rede"),
|
||||
("Append Bit Depth to File Name", "Anexar profundidade de bits ao nome do arquivo"),
|
||||
("Application Scripting", "Scripting do aplicativo"),
|
||||
("Ask User", "Perguntar ao usuário"),
|
||||
("Audio Hardware", "Hardware de áudio"),
|
||||
("Audio Output Mapping", "Mapeamento de saída de áudio"),
|
||||
("Auto-Save Location", "Local do salvamento automático"),
|
||||
("Automatic", "Automático"),
|
||||
("Automatically Reload Footage", "Recarregar mídias automaticamente"),
|
||||
("Automatically Save Projects", "Salvar projetos automaticamente"),
|
||||
("Brightness", "Brilho"),
|
||||
("Cache Budgets", "Orçamentos de cache"),
|
||||
("Conformed Audio Folder", "Pasta de áudio conformado"),
|
||||
("Create Layers at Composition Start Time", "Criar camadas no tempo inicial da composição"),
|
||||
("Create Split Layers Above Original Layer", "Criar camadas divididas acima da camada original"),
|
||||
("Custom", "Personalizado"),
|
||||
("Custom Location", "Local personalizado"),
|
||||
("Cycle Mask Colors", "Alternar cores das máscaras"),
|
||||
("Dark Theme", "Tema escuro"),
|
||||
("Dashed Lines", "Linhas tracejadas"),
|
||||
("Default 3D Renderer", "Renderizador 3D padrão"),
|
||||
("Default Drag Import As", "Importação padrão ao arrastar como"),
|
||||
("Default Output Folder", "Pasta de saída padrão"),
|
||||
("Default Spatial Interpolation to Linear", "Definir interpolação espacial padrão como linear"),
|
||||
("Disable Video Output When in Background", "Desativar saída de vídeo em segundo plano"),
|
||||
("Disk Cache Folder", "Pasta de cache do disco"),
|
||||
("Display Color Space", "Espaço de cores da exibição"),
|
||||
("Dots", "Pontos"),
|
||||
("Duration (Seconds)", "Duração (segundos)"),
|
||||
(
|
||||
"EffectCraft always uses its own colour picker (the same on every platform and the web).",
|
||||
"O EffectCraft sempre usa o próprio seletor de cores (o mesmo em todas as plataformas e na web).",
|
||||
),
|
||||
("Empty Disk Cache", "Esvaziar cache do disco"),
|
||||
("Enable Disk Cache", "Ativar cache do disco"),
|
||||
("Enable JavaScript Debugger", "Ativar depurador JavaScript"),
|
||||
("Enable Video Preview Output", "Ativar saída de pré-visualização de vídeo"),
|
||||
("Expression Pick Whip Writes Compact English", "Pick whip de expressão grava inglês compacto"),
|
||||
(
|
||||
"Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.",
|
||||
"As expressões usam o mecanismo JavaScript. Erros de script informam o arquivo e a linha no Console de scripts; não há depurador passo a passo.",
|
||||
),
|
||||
("Face Model", "Modelo de rosto"),
|
||||
("Face Tracking", "Rastreamento facial"),
|
||||
(
|
||||
"Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.",
|
||||
"O rastreamento facial no painel Rastreador (Somente contorno e recursos detalhados) usa o modelo escolhido aqui. Os modelos são de código aberto, baixados somente quando você solicita, e verificados com o SHA-256 publicado.",
|
||||
),
|
||||
("Fast Previews (Draft 3D, Faster Effects)", "Pré-visualizações rápidas (3D em rascunho, efeitos mais rápidos)"),
|
||||
("Files per Segment", "Arquivos por segmento"),
|
||||
("Fit", "Ajustar"),
|
||||
("Fit up to 100%", "Ajustar até 100%"),
|
||||
("Floating Window", "Janela flutuante"),
|
||||
("Footage Frame Cache", "Cache de quadros de mídia"),
|
||||
("GPU Information...", "Informações da GPU..."),
|
||||
("Gridline Every", "Grade a cada"),
|
||||
("Hardware Accelerate Composition, Layer and Footage Panels", "Aceleração por hardware dos painéis de composição, camada e mídia"),
|
||||
("Import Photoshop File", "Importar arquivo do Photoshop"),
|
||||
("Interpret Unlabeled Alpha As", "Interpretar alfa não rotulado como"),
|
||||
("Keyframes", "Keyframes"),
|
||||
("Labels and Colors", "Rótulos e cores"),
|
||||
("Layer Cache", "Cache de camada"),
|
||||
("Length of Composition", "Duração da composição"),
|
||||
("Levels of Undo", "Níveis de desfazer"),
|
||||
("Light Theme", "Tema claro"),
|
||||
("Match System", "Seguir o sistema"),
|
||||
("Maximum Disk Cache Size", "Tamanho máximo do cache do disco"),
|
||||
("Maximum Project Versions", "Máximo de versões do projeto"),
|
||||
("Media Cache", "Cache de mídia"),
|
||||
("Menu Bar", "Barra de menus"),
|
||||
("Minutes", "Minutos"),
|
||||
("Mirror on Computer Monitor", "Espelhar no monitor do computador"),
|
||||
("Move Panel to New Window", "Mover painel para nova janela"),
|
||||
("New Composition", "Nova composição"),
|
||||
("New Project", "Novo projeto"),
|
||||
("New Project Loads Template", "Novo projeto carrega modelo"),
|
||||
("Next to Project", "Ao lado do projeto"),
|
||||
("No More Than (Keyframes)", "No máximo (keyframes)"),
|
||||
("No More Than (Seconds)", "No máximo (segundos)"),
|
||||
("No Motion Path", "Sem caminho de movimento"),
|
||||
("Non-Sequence Footage", "Mídia que não é sequência"),
|
||||
("Number of Recent Fonts to Display", "Número de fontes recentes a exibir"),
|
||||
("Off (Final Quality)", "Desativado (qualidade final)"),
|
||||
("Offer to Open the Latest Auto-Save After a Crash", "Oferecer para abrir o salvamento automático mais recente após uma falha"),
|
||||
("OpenGL (compatibility)", "OpenGL (compatibilidade)"),
|
||||
("Panels are always drawn by the GPU (wgpu).", "Os painéis são sempre desenhados pela GPU (wgpu)."),
|
||||
("Preserve Constant Vertex and Feather Point Count when Editing Masks", "Preservar o número constante de vértices e pontos de feather ao editar máscaras"),
|
||||
("Preview (RAM) Cache", "Cache de pré-visualização (RAM)"),
|
||||
("RAM Reserved for Other Applications", "RAM reservada para outros aplicativos"),
|
||||
("RGB Straight", "RGB direto"),
|
||||
("Realtime Shadows in Draft", "Sombras em tempo real no rascunho"),
|
||||
("Recent Projects Shown", "Projetos recentes exibidos"),
|
||||
("Red", "Vermelho"),
|
||||
("Reduce Cache Size When System Is Low on Memory", "Reduzir o tamanho do cache quando o sistema estiver com pouca memória"),
|
||||
("Remove from favorites", "Remover dos favoritos"),
|
||||
("Repair", "Reparo"),
|
||||
("Report Missing Frames", "Relatar quadros ausentes"),
|
||||
("Reset Keyboard Shortcuts", "Redefinir atalhos de teclado"),
|
||||
("Reset Settings", "Redefinir configurações"),
|
||||
(
|
||||
"Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.",
|
||||
"Roto Brush 2.0 e 3.0 usam o modelo escolhido aqui (a versão 1.0 sempre usa o mecanismo clássico). Os modelos são de código aberto, baixados somente quando você solicita, e verificados com o SHA-256 publicado.",
|
||||
),
|
||||
("Safe Margins", "Margens de segurança"),
|
||||
("Save Every", "Salvar a cada"),
|
||||
("Save Now", "Salvar agora"),
|
||||
("Save When Starting Render Queue", "Salvar ao iniciar a fila de renderização"),
|
||||
("Segment Movie Files", "Segmentar arquivos de filme"),
|
||||
("Segment Sequences", "Segmentar sequências"),
|
||||
("Segment Size", "Tamanho do segmento"),
|
||||
("Segmentation Model", "Modelo de segmentação"),
|
||||
("Sequence Footage", "Mídia em sequência"),
|
||||
("Show 3D Reference Axes", "Mostrar eixos de referência 3D"),
|
||||
("Show Expression Error Banner", "Mostrar faixa de erro de expressão"),
|
||||
("Show Font Names in English", "Mostrar nomes de fontes em inglês"),
|
||||
("Show Font Preview", "Mostrar pré-visualização de fonte"),
|
||||
("Show Home Screen When Launching", "Mostrar tela inicial ao iniciar"),
|
||||
("Show Home Screen When Opening a Project", "Mostrar tela inicial ao abrir um projeto"),
|
||||
("Show Tool Tips", "Mostrar dicas de ferramenta"),
|
||||
("Startup & Repair", "Inicialização e reparo"),
|
||||
("Still Footage", "Mídia estática"),
|
||||
("Stored, but not used by EffectCraft yet", "Armazenado, mas ainda não usado pelo EffectCraft"),
|
||||
("Subdivisions", "Subdivisões"),
|
||||
("Switches Affect Nested Comps", "Interruptores afetam composições aninhadas"),
|
||||
("Synchronize Time of All Related Items", "Sincronizar o tempo de todos os itens relacionados"),
|
||||
("Template Project", "Projeto de modelo"),
|
||||
("Title-safe", "Área segura de título"),
|
||||
("Use In-Window Menu Bar on macOS", "Usar barra de menus na janela no macOS"),
|
||||
("Use System Color Picker", "Usar seletor de cores do sistema"),
|
||||
("Video Device", "Dispositivo de vídeo"),
|
||||
("Video Output During Playback", "Saída de vídeo durante a reprodução"),
|
||||
("Warn User When Executing Files", "Avisar o usuário ao executar arquivos"),
|
||||
("Window Graphics", "Gráficos da janela"),
|
||||
(
|
||||
"Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.",
|
||||
"Gráficos da janela vale a partir do próximo início. Quando o driver gráfico interrompe o EffectCraft na abertura da janela, o próximo início muda para OpenGL.",
|
||||
),
|
||||
("Wireframe", "Wireframe"),
|
||||
("Active", "Em uso"),
|
||||
("NAME", "NOME"),
|
||||
("Affect all layers that use this solid", "Afetar todas as camadas que usam este sólido"),
|
||||
("EffectCraft home page", "Página inicial do EffectCraft"),
|
||||
("Only this layer uses this solid.", "Somente esta camada usa este sólido."),
|
||||
("KIND", "TIPO"),
|
||||
("Blue", "Azul"),
|
||||
("Green", "Verde"),
|
||||
("Unfreeze", "Descongelar"),
|
||||
("Export", "Exportar"),
|
||||
("Auto-complete", "Preenchimento automático"),
|
||||
("Bracket Matching", "Correspondência de colchetes"),
|
||||
("Font Size", "Tamanho da fonte"),
|
||||
("Line Numbers", "Números de linha"),
|
||||
("Scripting & Expressions", "Scripts e expressões"),
|
||||
("Syntax Highlighting", "Realce de sintaxe"),
|
||||
("Word Wrap", "Quebra automática de linha"),
|
||||
("16 bits per channel", "16 bits por canal"),
|
||||
("32 bits per channel (float)", "32 bits por canal (ponto flutuante)"),
|
||||
("8 bits per channel", "8 bits por canal"),
|
||||
("Adobe-style built-in", "Integrado (estilo Adobe)"),
|
||||
("Composition - Retain Layer Sizes", "Composição - Manter tamanhos de camada"),
|
||||
("Guess", "Adivinhar"),
|
||||
("Import Image Sequence", "Importar sequência de imagens"),
|
||||
("Import PDF / Illustrator File", "Importar arquivo PDF / Illustrator"),
|
||||
("Mercury Software Only", "Apenas software Mercury"),
|
||||
("OCIO (built-in ACES config)", "OCIO (config do ACES integrado)"),
|
||||
("Still Duration", "Duração da imagem fixa"),
|
||||
("Straight", "Direto"),
|
||||
("Tone map", "Mapeamento tonal"),
|
||||
("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (padrão)"),
|
||||
(
|
||||
"Click above the bar to add an opacity stop, below it to add a color stop.",
|
||||
"Clique acima da barra para adicionar uma parada de opacidade, abaixo dela para adicionar uma parada de cor.",
|
||||
),
|
||||
("Location:", "Local:"),
|
||||
(
|
||||
"These adjacent keyframes cannot interpolate because their stop counts differ.",
|
||||
"Estes keyframes adjacentes não podem ser interpolados porque os seus números de paradas diferem.",
|
||||
),
|
||||
("After Effects project", "Projeto do After Effects"),
|
||||
("Create a new project, or open the demo project to look around.", "Crie um novo projeto ou abra o projeto de demonstração para explorar."),
|
||||
("Filter recent files", "Filtrar arquivos recentes"),
|
||||
("Import Footage…", "Importar mídia…"),
|
||||
("Just now", "Agora mesmo"),
|
||||
("Lottie animation", "Animação Lottie"),
|
||||
("Motion graphics and visual effects · version {}", "Motion graphics e efeitos visuais · versão {}"),
|
||||
("No recent files match the filter", "Nenhum arquivo recente corresponde ao filtro"),
|
||||
("OPENED", "ABERTO"),
|
||||
("Open Demo Project", "Abrir projeto de demonstração"),
|
||||
("Open Project…", "Abrir projeto…"),
|
||||
("SIZE", "TAMANHO"),
|
||||
("Try another name or folder.", "Tente outro nome ou pasta."),
|
||||
("Yesterday", "Ontem"),
|
||||
("Your recent files will show here", "Seus arquivos recentes aparecerão aqui"),
|
||||
("{} day ago", "há {} dia"),
|
||||
("{} days ago", "há {} dias"),
|
||||
("{} hour ago", "há {} hora"),
|
||||
("{} hours ago", "há {} horas"),
|
||||
("{} minute ago", "há {} minuto"),
|
||||
("{} minutes ago", "há {} minutos"),
|
||||
("Show Rendering Progress in Info Panel and Flowchart", "Mostrar progresso de renderização no painel Informações e no fluxograma"),
|
||||
("Current Settings", "Configurações atuais"),
|
||||
("Move Panel to Main Window", "Mover painel para a janela principal"),
|
||||
("Disable Per-character 3D", "Desativar 3D por caractere"),
|
||||
("Enable Per-character 3D", "Ativar 3D por caractere"),
|
||||
("Opus Bitrate: {} kbps", "Taxa de bits do Opus: {} kbps"),
|
||||
("Quarter", "Um quarto"),
|
||||
("Region of Interest", "Região de interesse"),
|
||||
("Third", "Um terço"),
|
||||
("Boundary", "Contorno"),
|
||||
("Labels", "Rótulos"),
|
||||
("You can't undo this.", "Você não pode desfazer isto."),
|
||||
("Latin", "Latino"),
|
||||
("South Asian and Middle Eastern", "Sul-asiático e do Oriente Médio"),
|
||||
("Text Engine", "Motor de texto"),
|
||||
("Variable fonts only", "Apenas fontes variáveis"),
|
||||
("Expressions Editor", "Editor de expressões"),
|
||||
("Faster", "Mais rápido"),
|
||||
("Grids & Guides", "Grades e guias"),
|
||||
("More Accurate", "Mais preciso"),
|
||||
("Path Point and Handle Size", "Tamanho dos pontos de caminho e das alças"),
|
||||
("Show Internal Wireframes", "Mostrar wireframes internos"),
|
||||
("Viewer Zoom Quality", "Qualidade de zoom do visualizador"),
|
||||
("Motion Path", "Caminho de movimento"),
|
||||
("3D Reference Axes", "Eixos de referência 3D"),
|
||||
("Choose grid and guide options", "Escolher opções de grade e guias"),
|
||||
("Colorize", "Colorir"),
|
||||
("Custom...", "Personalizado..."),
|
||||
("Proportional Grid", "Grade proporcional"),
|
||||
("Reset Exposure", "Redefinir exposição"),
|
||||
("Show Channel and Color Management Settings", "Mostrar configurações de canal e de gerenciamento de cores"),
|
||||
("Show Snapshot (hold)", "Mostrar instantâneo (manter pressionado)"),
|
||||
("Take Snapshot", "Capturar instantâneo"),
|
||||
("Toggle Mask and Shape Path Visibility", "Alternar visibilidade das máscaras e dos caminhos de forma"),
|
||||
("Toggle Transparency Grid", "Alternar grade de transparência"),
|
||||
("Database and Cache Folder", "Pasta de banco de dados e de cache"),
|
||||
("Adaptive Resolution", "Resolução adaptável"),
|
||||
("Adaptive Resolution Limit", "Limite de resolução adaptável"),
|
||||
("Alpha", "Alfa"),
|
||||
("Appearance", "Aparência"),
|
||||
("Appearance Mode", "Modo de aparência"),
|
||||
("Appearance Mode: {}", "Modo de aparência: {}"),
|
||||
("Appearance Settings…", "Configurações de aparência…"),
|
||||
("Audio", "Áudio"),
|
||||
("Auto-Save", "Auto-salvar"),
|
||||
("Cache Frames When Idle", "Armazenar quadros em cache quando ocioso"),
|
||||
("Dark", "Escuro"),
|
||||
("Default", "Padrão"),
|
||||
("Disk", "Disco"),
|
||||
("Disk Cache", "Cache do disco"),
|
||||
("Display", "Exibição"),
|
||||
("Draft", "Rascunho"),
|
||||
("EffectCraft on GitHub", "EffectCraft no GitHub"),
|
||||
("EffectCraft project", "Projeto do EffectCraft"),
|
||||
("Extended Viewer", "Visualizador estendido"),
|
||||
("Fast Draft", "Rascunho rápido"),
|
||||
("Footage", "Mídia"),
|
||||
("Freeze", "Congelar"),
|
||||
("Full", "Integral"),
|
||||
("Full Screen", "Tela inteira"),
|
||||
("General", "Geral"),
|
||||
("Gradient Editor", "Editor de gradiente"),
|
||||
("Half", "Metade"),
|
||||
("Horizontal", "Horizontal"),
|
||||
("Ignore", "Ignorar"),
|
||||
("Join the ArtCraft Discord", "Entre no Discord da ArtCraft"),
|
||||
("Language", "Idioma"),
|
||||
("Left", "Esquerda"),
|
||||
("Light", "Claro"),
|
||||
("Lines", "Linhas"),
|
||||
("Memory & CPU", "Memória e CPU"),
|
||||
("Mercury GPU Acceleration ({})", "Aceleração de GPU Mercury ({})"),
|
||||
("Off", "Desativado"),
|
||||
("Options", "Opções"),
|
||||
("Overlay", "Sobrepor"),
|
||||
("Premultiplied", "Pré-multiplicado"),
|
||||
("Preview Sample Rate", "Taxa de amostragem de pré-visualização"),
|
||||
("Previews", "Pré-visualizações"),
|
||||
("Project", "Projeto"),
|
||||
("RGB", "RGB"),
|
||||
("Right", "Direita"),
|
||||
("Roto Brush", "Roto Brush"),
|
||||
("Seconds", "Segundos"),
|
||||
("Stop", "Parar"),
|
||||
("Style", "Estilo"),
|
||||
("Sync with System", "Sincronizar com o sistema"),
|
||||
("Templates", "Modelos"),
|
||||
("Track", "Rastrear"),
|
||||
("UI Scale", "Escala da interface"),
|
||||
("Use Gradients", "Usar gradientes"),
|
||||
("Use Label Color for Layer Handles and Paths", "Usar a cor do rótulo nas alças de camada e nos caminhos"),
|
||||
("Use Label Color for Related Tabs", "Usar a cor do rótulo nas abas relacionadas"),
|
||||
("Vertical", "Vertical"),
|
||||
("Video", "Vídeo"),
|
||||
("channel", "canal"),
|
||||
("files", "arquivos"),
|
||||
];
|
||||
|
||||
/// Czech (cs): original translations covering every row in the Chinese panel catalogs.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user