Merge main into #1328; i18n/ui.rs: keep both new pt-BR rows (Click to sample color, Export Scene to Blender...), add the pl row for main's new Polish catalog

This commit is contained in:
bflatastic committed 2026-10-30 09:22:35 +07:00
commit 0ddcc676bb
503 files changed
+33722 -2888

No files matched your search

+187 -8
View File
@@ -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};
@@ -28,13 +30,21 @@ pub struct Confinement {
write: Vec<PathBuf>,
}
/// Commands that run code, act outside the project or open other applications.
/// Commands that run code, act outside the project or open other applications, besides the
/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]).
const REFUSED: &[&str] = &[
"script.run",
"file.runScript",
"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,10 +74,29 @@ 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])] = &[
("camera.exportBlender", &["path"]),
("file.save", &["path"]),
("file.saveAs", &["path"]),
("file.saveCopy", &["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.
@@ -135,16 +167,26 @@ impl Confinement {
/// Check engine command `id` with `params` before it runs. Returns what must still be looked
/// up and passed to [`Confinement::check_lookup`] for commands that write implicitly.
pub fn check_command(&self, id: &str, params: &Value) -> Result<Option<Lookup>> {
if REFUSED.contains(&id) {
if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.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,115 @@ 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)
|| effectcraft_engine::commands::scripts::RUNS_SCRIPTS.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);
}
}
+132
View File
@@ -491,6 +491,8 @@ fn bridge_forwards_to_control_channel() {
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "B", "width": 64, "height": 64}}));
assert_eq!(call_json(&mut s, "get_comp", json!({}))["name"], "B");
let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.newSolid", "compact": true}));
assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}");
let (c, err) = call(&mut s, "render_frame", json!({}));
assert!(!err, "{c:?}");
@@ -738,6 +740,40 @@ fn every_engine_command_is_reachable_over_mcp() {
}
}
/// `compact: true` shrinks the listings for MCP clients' tool-result limits (#2020): commands with
/// only `id` and `label`, effects with only `id`, `name` and `category`.
#[test]
fn compact_listings_keep_only_ids_and_names() {
let mut s = server();
let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.new", "compact": true}));
assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}");
let fx = call_json(&mut s, "list_effects", json!({"filter": "gaussian", "compact": true}));
assert!(fx.as_array().unwrap().contains(&json!({"id": "ec.blur.gaussian", "name": "Gaussian Blur", "category": "Blur & Sharpen"})), "{fx}");
// Each compact listing is the full one with the same arguments, entry for entry; `compact`
// wins over `schemas`.
let check = |s: &mut McpServer, tool: &str, args: Value, keys: &[&str]| {
let full = call_json(s, tool, args.clone());
let want: Value = full.as_array().unwrap().iter().map(|e| Value::Object(keys.iter().map(|&k| (k.to_string(), e[k].clone())).collect())).collect();
let mut compact = args.clone();
compact["compact"] = json!(true);
assert_eq!(call_json(s, tool, compact), want, "{tool} {args}");
};
for args in [json!({}), json!({"filter": "layer.new"}), json!({"enabled_only": true}), json!({"schemas": true})] {
check(&mut s, "list_commands", args.clone(), &["id", "label"]);
check(&mut s, "command_list", args, &["id", "label"]);
}
check(&mut s, "list_effects", json!({}), &["id", "name", "category"]);
check(&mut s, "list_effects", json!({"filter": "gaussian"}), &["id", "name", "category"]);
// `compact: false` is the default listing, byte for byte; `compact: true` is a fraction of it.
let text = |s: &mut McpServer, tool: &str, args: Value| call(s, tool, args).0[0]["text"].as_str().unwrap().to_string();
for (tool, ratio) in [("list_commands", 2), ("list_effects", 10)] {
let full = text(&mut s, tool, json!({}));
assert_eq!(text(&mut s, tool, json!({"compact": false})), full, "{tool}");
let compact = text(&mut s, tool, json!({"compact": true})).len();
assert!(compact * ratio < full.len(), "{tool}: {compact} of {} bytes", full.len());
}
}
#[test]
fn high_level_tools_add_effect_and_report_state() {
let mut s = server();
@@ -1376,7 +1412,103 @@ fn automation_roots_confine_file_access() {
// Scripts can read and write anything: refused.
denied(&mut s, "run_script", json!({"code": "1 + 1"}));
denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}}));
for (id, params) in [
("file.runStartupScripts", json!({})),
("file.runShutdownScripts", json!({})),
// ScriptUI panels run their script when they open.
("window.scriptPanel", json!({"name": "Layer Tools.jsx"})),
("window.restoreScriptPanels", json!({})),
] {
let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params}));
assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]}));
assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
}
// Writes to the files the agent named: none outside.
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);
}
#[test]
fn blender_export_requires_an_absolute_path_in_a_write_root() {
use crate::confine::Confinement;
let base = std::env::temp_dir().join(format!("ec-blender-confine-{}", std::process::id()));
let (read, write) = (base.join("read"), base.join("write"));
for dir in [&read, &write] {
std::fs::create_dir_all(dir).unwrap();
}
let confinement = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap();
let check = |path: &std::path::Path| confinement.check_command("camera.exportBlender", &json!({"path": path}));
assert_eq!(check(&write.join("scene.py")).unwrap(), None);
for path in [read.join("scene.py"), base.join("outside.py"), write.join("../read/scene.py")] {
assert!(check(&path).is_err(), "must deny {}", path.display());
}
for path in ["scene.py", "./scene.py", "write/scene.py", "../scene.py"] {
let error = check(std::path::Path::new(path)).unwrap_err().to_string();
assert!(error.contains("give an absolute path"), "{path}: {error}");
}
let read_only = Confinement::new(std::slice::from_ref(&write), &[]).unwrap();
assert!(read_only.check_command("camera.exportBlender", &json!({"path": write.join("scene.py")})).is_err());
for path in [json!(read.join("scene.py")), json!("scene.py"), json!("$1.path")] {
let batch = json!({"steps": [{"command": "camera.exportBlender", "params": {"path": path}}]});
assert!(confinement.check_command("engine.batch", &batch).is_err());
}
std::fs::remove_dir_all(&base).unwrap();
}
+29 -9
View File
@@ -175,16 +175,21 @@ fn params_of(v: Option<&Value>) -> Result<Value> {
// ------------------------------------------------------------------ engine tools
/// `list_commands` tool: the engine command list, minus empty fields; with `compact`, only `id` and `label`.
fn list_commands(b: &mut Backend, a: &Value) -> Result<Reply> {
let schemas = get(a, "schemas").and_then(Value::as_bool).unwrap_or(false).then_some(json!(true));
let compact = get(a, "compact").and_then(Value::as_bool).unwrap_or(false);
let schemas = (!compact && get(a, "schemas").and_then(Value::as_bool).unwrap_or(false)).then_some(json!(true));
let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only")), ("schemas", schemas.as_ref())]))?;
// Drop empty fields to keep the listing compact.
let list: Vec<Value> = r
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.map(|mut c| {
if compact {
return json!({"id": c["id"], "label": c["label"]});
}
// Drop empty fields.
if let Some(o) = c.as_object_mut() {
o.retain(|_, v| !(v.is_null() || v.as_array().is_some_and(Vec::is_empty) || v.as_str() == Some("{}")));
}
@@ -205,8 +210,13 @@ fn describe_command(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("command.describe", json!({"command": id}))?)
}
/// `list_effects` tool: the effect registry; with `compact`, only `id`, `name` and `category`.
fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?)
let mut r = b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?;
if get(a, "compact").and_then(Value::as_bool).unwrap_or(false) {
r = r.as_array().into_iter().flatten().map(|e| json!({"id": e["id"], "name": e["name"], "category": e["category"]})).collect();
}
json_reply(r)
}
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
@@ -542,14 +552,15 @@ fn control(b: &mut Backend, a: &Value) -> Result<Reply> {
static TOOLS: &[ToolDef] = &[
ToolDef {
name: "list_commands",
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).",
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.",
bridge_only: false,
schema: || {
schema(
json!({
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."},
"compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."}
}),
&[],
)
@@ -696,9 +707,17 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "list_effects",
description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text). Apply with add_effect.",
description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text); `compact: true` lists only ids, names and categories. Apply with add_effect.",
bridge_only: false,
schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]),
schema: || {
schema(
json!({
"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."},
"compact": {"type": "boolean", "description": "Return only each effect's `id`, `name` and `category`."}
}),
&[],
)
},
run: list_effects,
},
ToolDef {
@@ -911,14 +930,15 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "command_list",
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).",
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.",
bridge_only: false,
schema: || {
schema(
json!({
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."},
"compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."}
}),
&[],
)
+1 -1
View File
@@ -35,7 +35,7 @@ fn import_footage(qa: &mut Qa) -> (Value, Value) {
// Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸
// Photoshop Layers) from a small comp.
let seq = write_sequence(qa);
let imp = qa.exec("file.import", json!({"paths": [seq]}));
let imp = qa.exec("file.import", json!({"paths": [seq], "sequence": true}));
let seq_item = imp["items"][0].clone();
let project = qa.tool("get_project", json!({}));
let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone();
+1 -1
View File
@@ -52,7 +52,7 @@ fn shake_offset(f: u32) -> (i64, i64) {
/// Import a sequence at 12 fps and make a comp of it; returns (comp, layer).
fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) {
let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone();
let item = qa.exec("file.import", json!({"paths": [first], "sequence": true}))["items"][0].clone();
qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12}));
let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone();
+4 -2
View File
@@ -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
+110 -8
View File
@@ -3,7 +3,7 @@
//! processes, so the reconstruction built here is exactly what a decoder produces; the syntax
//! is written afterwards by `writer.rs`.
use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, predict_inter, predict_intra, subpel_plane};
use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, half_planes, predict_inter, predict_intra};
use crate::tables::{
AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM,
NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3,
@@ -174,6 +174,8 @@ pub(crate) struct Decider<'a> {
ac_q: f32,
dc_qi: i64,
ac_qi: i64,
/// Smallest magnitude that quantises to a non-zero level, `[dc, ac][intra]`.
dead_zone: [[f32; 2]; 2],
lambda: f64,
lambda_sad: f64,
/// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8).
@@ -183,6 +185,9 @@ pub(crate) struct Decider<'a> {
}
const MODE_BITS_INTRA: f64 = 3.0;
/// Dead-zone quantiser rounding offsets (below one half) for inter and intra blocks.
const INTER_RND: f32 = 0.30;
const INTRA_RND: f32 = 0.38;
impl<'a> Decider<'a> {
pub fn new(g: &'a FrameGeom, bd: u32, qidx: u8, intra_only: bool, src: &'a [Plane; 3], refr: Option<&'a RefFrame>) -> Self {
@@ -209,10 +214,11 @@ impl<'a> Decider<'a> {
ac_q: ac_qi as f32,
dc_qi,
ac_qi,
dead_zone: [dc_qi, ac_qi].map(|q| [INTER_RND, INTRA_RND].map(|rnd| zero_threshold(q as f32, rnd))),
lambda,
lambda_sad: lambda.sqrt(),
half: match refr {
Some(rf) => [(8, 0), (0, 8), (8, 8)].map(|(fx, fy)| subpel_plane(&rf.planes[0], g.width, g.height, fx, fy, bd)),
Some(rf) => half_planes(&rf.planes[0], g.width, g.height, bd),
None => Default::default(),
},
scratch: None,
@@ -577,8 +583,9 @@ impl<'a> Decider<'a> {
let (dy, dx) = (m[0] >> 3, m[1] >> 3);
dx >= lim_x.0 && dx <= lim_x.1 && dy >= lim_y.0 && dy <= lim_y.1
};
let lg = log2_table();
let mv_bits = |m: [i32; 2]| -> f64 {
let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * ((d.abs() / 8) as f64 + 1.0).log2() };
let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) };
comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1])
};
let cost =
@@ -863,12 +870,18 @@ impl<'a> Decider<'a> {
}
forward_2d(res, coef, n, tx_type);
// dead-zone quantiser: rounding offset below one half
let rnd = if intra { 0.38f32 } else { 0.30f32 };
let rnd = if intra { INTRA_RND } else { INTER_RND };
let [dc_thr, ac_thr] = [self.dead_zone[0][intra as usize], self.dead_zone[1][intra as usize]];
levels.fill(0);
let mut nonzero = false;
for (k, (&cf, l)) in coef.iter().zip(levels.iter_mut()).enumerate() {
let q = if k == 0 { self.dc_q } else { self.ac_q };
let a = (cf.abs() / q + rnd).floor() as i32;
let (q, thr) = if k == 0 { (self.dc_q, dc_thr) } else { (self.ac_q, ac_thr) };
// dead zone: below the exact threshold the level is zero, no division needed
if cf.abs() < thr {
continue;
}
// the operand is >= 0, so truncation equals floor
let a = (cf.abs() / q + rnd) as i32;
if a != 0 {
*l = if cf < 0.0 { -a.min(1 << 16) } else { a.min(1 << 16) };
nonzero = true;
@@ -913,6 +926,33 @@ impl<'a> Decider<'a> {
}
}
/// Smallest `x >= 0` with `(x / q + rnd) as i32 >= 1` in f32 arithmetic. The expression is
/// monotone in `x`, so a binary search over the bit patterns finds the exact boundary.
fn zero_threshold(q: f32, rnd: f32) -> f32 {
let (mut lo, mut hi) = (0u32, f32::MAX.to_bits());
while lo < hi {
let mid = lo + (hi - lo) / 2;
if (f32::from_bits(mid) / q + rnd) as i32 >= 1 {
hi = mid;
} else {
lo = mid + 1;
}
}
f32::from_bits(lo)
}
const LOG2_TABLE_LEN: usize = 1025;
/// `log2(i)` for `i < LOG2_TABLE_LEN`, computed with the same `f64::log2` call as the direct form.
fn log2_table() -> &'static [f64; LOG2_TABLE_LEN] {
static TABLE: std::sync::OnceLock<[f64; LOG2_TABLE_LEN]> = std::sync::OnceLock::new();
TABLE.get_or_init(|| std::array::from_fn(|i| (i as f64).log2()))
}
fn log2_of(table: &[f64; LOG2_TABLE_LEN], v: usize) -> f64 {
if v < LOG2_TABLE_LEN { table[v] } else { (v as f64).log2() }
}
/// Unnormalised Walsh-Hadamard transform of 4 or 8 values, in place.
fn hadamard(v: &mut [i32]) {
let n = v.len();
@@ -941,13 +981,14 @@ fn estimate_bits(levels: &[i32], tx_sz: usize) -> f64 {
if eob == 0 {
return 1.0;
}
let mut bits = 3.0 + (eob as f64).log2();
let lg = log2_table();
let mut bits = 3.0 + log2_of(lg, eob);
for &pos in &scan[..eob] {
let a = levels[pos as usize].unsigned_abs();
bits += match a {
0 => 0.7,
1 => 2.5,
_ => 3.5 + 1.6 * (a as f64).log2(),
_ => 3.5 + 1.6 * log2_of(lg, a as usize),
};
}
bits
@@ -961,3 +1002,64 @@ pub(crate) fn scan_for(tx_sz: usize) -> &'static [u16] {
_ => &DEFAULT_SCAN_32X32,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tables::{AC_QLOOKUP, DC_QLOOKUP};
/// The quantiser as it was before the dead-zone threshold and the truncation.
fn quant_ref(cf: f32, q: f32, rnd: f32) -> i32 {
(cf.abs() / q + rnd).floor() as i32
}
fn quant_new(cf: f32, q: f32, rnd: f32, thr: f32) -> i32 {
if cf.abs() < thr {
return 0;
}
(cf.abs() / q + rnd) as i32
}
#[test]
fn dead_zone_threshold_is_exact() {
let mut state = 0x1234_5678_9abc_def0u64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
for table in AC_QLOOKUP.iter().chain(DC_QLOOKUP.iter()) {
for &qi in table.iter() {
let q = qi as f32;
for rnd in [INTER_RND, INTRA_RND] {
let thr = zero_threshold(q, rnd);
// the boundary itself and its neighbours
let b = thr.to_bits();
for bits in b.saturating_sub(3)..=b + 3 {
let cf = f32::from_bits(bits);
assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr), "q {q} rnd {rnd} cf {cf}");
assert_eq!(quant_ref(-cf, q, rnd), quant_new(-cf, q, rnd, thr));
}
for _ in 0..200 {
let cf = f32::from_bits((next() >> 32) as u32 & 0x7fff_ffff);
if cf.is_nan() {
continue;
}
assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr));
let cf = (next() % 100_000) as f32 / 100.0;
assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr));
}
}
}
}
}
#[test]
fn log2_table_matches_direct_log2() {
let t = log2_table();
for v in 1..3000usize {
assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}");
}
}
}
+147 -24
View File
@@ -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)
}
+76 -1
View File
@@ -297,7 +297,8 @@ pub(crate) fn predict_inter(refp: &Plane, block: &InterBlock, pred: &mut [u16])
/// The visible `w x h` area of a reference plane interpolated at the sub-sample phase
/// (`fx`, `fy`) (1/16 units) with the EIGHTTAP filter, as `predict_inter` computes it for
/// blocks wider and taller than 4. Used for motion search.
/// blocks wider and taller than 4. The reference for `half_planes`, which the encoder uses.
#[cfg(test)]
pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usize, bit_depth: u32) -> Vec<u16> {
let kh = &SUBPEL_FILTERS[EIGHTTAP][fx];
let kv = &SUBPEL_FILTERS[EIGHTTAP][fy];
@@ -327,6 +328,66 @@ pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usiz
out
}
/// The three half-sample planes (8, 0), (0, 8), (8, 8) of `subpel_plane` in one pass: the
/// horizontal half-sample pass is shared by (8, 0) and (8, 8), and the phase-0 kernels reduce to
/// exact shifts (Round2( 128 * v, 11 ) = Round2( v, 4 ), Round2( 16 * s, 11 ) = Round2( s, 7 )),
/// so the result equals three `subpel_plane` calls bit for bit.
pub(crate) fn half_planes(refp: &Plane, w: usize, h: usize, bit_depth: u32) -> [Vec<u16>; 3] {
let k = &SUBPEL_FILTERS[EIGHTTAP][8];
let max = (1i32 << bit_depth) - 1;
let (lx, ly) = (w as i32 - 1, h as i32 - 1);
// horizontal pass at phase 8: Round2( sum, 3 )
let mut hp = vec![0i32; w * h];
for y in 0..h {
let row = &refp.row(y)[..w];
let out = &mut hp[y * w..y * w + w];
let tap = |x: i32| -> i32 {
let mut s = 0i32;
for t in 0..8 {
s += k[t] as i32 * row[(x + t as i32 - 3).clamp(0, lx) as usize] as i32;
}
round2(s, 3)
};
if w >= 8 {
for x in 0..3 {
out[x] = tap(x as i32);
}
for (x, o) in out.iter_mut().enumerate().take(w - 4).skip(3) {
let seg = &row[x - 3..x + 5];
let mut s = 0i32;
for t in 0..8 {
s += k[t] as i32 * seg[t] as i32;
}
*o = round2(s, 3);
}
for x in w - 4..w {
out[x] = tap(x as i32);
}
} else {
for (x, o) in out.iter_mut().enumerate() {
*o = tap(x as i32);
}
}
}
let p80: Vec<u16> = hp.iter().map(|&v| round2(v, 4).clamp(0, max) as u16).collect();
let mut p08 = vec![0u16; w * h];
let mut p88 = vec![0u16; w * h];
for y in 0..h {
let rows: [usize; 8] = std::array::from_fn(|t| (y as i32 + t as i32 - 3).clamp(0, ly) as usize);
let (o08, o88) = (&mut p08[y * w..y * w + w], &mut p88[y * w..y * w + w]);
for x in 0..w {
let (mut a, mut b) = (0i32, 0i32);
for t in 0..8 {
a += k[t] as i32 * refp.data[rows[t] * refp.stride + x] as i32;
b += k[t] as i32 * hp[rows[t] * w + x];
}
o08[x] = round2(a, 7).clamp(0, max) as u16;
o88[x] = round2(b, 11).clamp(0, max) as u16;
}
}
[p80, p08, p88]
}
#[cfg(test)]
mod tests {
use super::*;
@@ -361,6 +422,20 @@ mod tests {
}
}
#[test]
fn half_planes_match_subpel_plane() {
for (w, h) in [(37usize, 29usize), (5, 3), (1, 1), (64, 64)] {
let mut p = Plane::new(64, 64);
for (i, v) in p.data.iter_mut().enumerate() {
*v = ((i * 7919) % 1024) as u16;
}
let hp = half_planes(&p, w, h, 10);
for (i, (fx, fy)) in [(8, 0), (0, 8), (8, 8)].into_iter().enumerate() {
assert_eq!(hp[i], subpel_plane(&p, w, h, fx, fy, 10), "{w}x{h} phase ({fx}, {fy})");
}
}
}
#[test]
fn phase_zero_filters_are_impulses() {
for f in SUBPEL_FILTERS.iter() {
+88 -9
View File
@@ -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);
+17
View File
@@ -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();
+17 -1
View File
@@ -97,7 +97,9 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let denom = if l > 0.5 { 2.0 - max - min } else { max + min };
// Over-range (HDR) or negative inputs can make the denominator zero or negative.
let s = if denom > 0.0 { d / denom } else { 1.0 };
let h = if max == r {
(((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0)
} else if max == g {
@@ -262,6 +264,20 @@ impl Label {
mod tests {
use super::*;
#[test]
fn hsl_round_trip_is_finite_for_out_of_range_colours() {
for (r, g, b) in [(1.5, 0.5, 0.5), (2.0, 0.1, 0.3), (-0.5, 0.2, 0.9)] {
let (h, s, l) = rgb_to_hsl(r, g, b);
assert!(h.is_finite() && s.is_finite() && l.is_finite(), "{h} {s} {l}");
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
assert!(r2.is_finite() && g2.is_finite() && b2.is_finite(), "{r2} {g2} {b2}");
}
// A 32 bpc colour whose denominator stays positive still round-trips exactly.
let (h, s, l) = rgb_to_hsl(1.2, 0.5, 0.5);
let (r, g, b) = hsl_to_rgb(h, s, l);
assert!((r - 1.2).abs() < 1e-5 && (g - 0.5).abs() < 1e-5 && (b - 0.5).abs() < 1e-5, "{r} {g} {b}");
}
#[test]
fn hsv_to_rgb_tiny_negative_hue_is_red() {
let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0);
+97
View File
@@ -0,0 +1,97 @@
//! Bounded parsed-JSON caches. Keep the content key with the owned source text so repeated
//! renders do not rehash long analyses. Compare contents on lookup: evaluated parameter
//! strings can be cloned, edited in place, or allocated again at an old address.
use std::sync::{Arc, Mutex, OnceLock};
struct Entry<T> {
json: Box<str>,
key: u64,
value: Arc<T>,
}
pub(crate) struct AnalysisCache<T> {
entries: OnceLock<Mutex<Vec<Entry<T>>>>,
}
pub(crate) fn fnv(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
impl<T> AnalysisCache<T> {
pub(crate) const fn new() -> Self {
Self { entries: OnceLock::new() }
}
pub(crate) fn get(&self, json: &str, parse: impl FnOnce(&str) -> Option<T>) -> Option<(u64, Arc<T>)> {
let entries = self.entries.get_or_init(Default::default);
{
let m = entries.lock().unwrap_or_else(|e| e.into_inner());
if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) {
return Some((e.key, e.value.clone()));
}
}
let value = Arc::new(parse(json)?);
let key = fnv(json.as_bytes());
let mut m = entries.lock().unwrap_or_else(|e| e.into_inner());
// Another render may have filled the entry while this one parsed it.
if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) {
return Some((e.key, e.value.clone()));
}
// Preserve the existing nine-entry bound and clear-on-overflow policy.
if m.len() > 8 {
m.clear();
}
m.push(Entry { json: json.into(), key, value: value.clone() });
Some((key, value))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
#[test]
fn reuses_content_and_invalidates_same_allocation_edits() {
let cache = AnalysisCache::new();
let calls = Cell::new(0);
let parse = |s: &str| {
calls.set(calls.get() + 1);
Some(s.to_owned())
};
let mut json = String::from("analysis-1");
let (key, a) = cache.get(&json, parse).unwrap();
let copy = json.clone();
assert_ne!(json.as_ptr(), copy.as_ptr());
let (same_key, same) = cache.get(&copy, parse).unwrap();
assert_eq!(key, fnv(json.as_bytes()));
assert_eq!(key, same_key);
assert!(Arc::ptr_eq(&a, &same));
assert_eq!(calls.get(), 1);
let ptr = json.as_ptr();
json.replace_range(9..10, "2");
assert_eq!(ptr, json.as_ptr());
let (changed_key, changed) = cache.get(&json, parse).unwrap();
assert_ne!(key, changed_key);
assert_eq!(&*changed, "analysis-2");
assert_eq!(&*a, "analysis-1");
assert_eq!(calls.get(), 2);
assert!(cache.get("invalid", |_| None).is_none());
assert!(Arc::ptr_eq(&cache.get(&json, parse).unwrap().1, &changed));
}
#[test]
fn source_text_retention_is_bounded() {
let cache = AnalysisCache::new();
for i in 0..30 {
cache.get(&i.to_string(), |s| Some(s.to_owned())).unwrap();
assert!(cache.entries.get().unwrap().lock().unwrap().len() <= 9);
}
}
}
+6
View File
@@ -206,6 +206,12 @@ pub fn reverses(id: &str) -> bool {
id == "ec.audio.backwards"
}
/// Does each output sample depend only on the input sample at the same time (no state), so the
/// mixer may run the effect in short pieces with parameters animated between them?
pub fn per_sample(id: &str) -> bool {
matches!(id, "ec.audio.stereomixer" | "ec.audio.backwards")
}
/// Seconds of input history the effect needs before a block to reach the state it would have
/// had when processing continuously.
pub fn preroll(id: &str, params: &Params) -> f64 {
+28 -32
View File
@@ -16,8 +16,9 @@
//! on it draws the solved points (as the viewer shows them) into the frame. The viewer overlay, target and the Create
//! commands live in the engine and the UI.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::Arc;
use crate::analysis_cache::AnalysisCache;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
@@ -122,50 +123,24 @@ pub fn solve_key(st: &SolveSettings) -> String {
format!("{:?}|{hfov}|{:?}|{:?}{lens}", st.shot, st.method, st.deleted)
}
fn fnv(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
type Cache<T> = Mutex<HashMap<u64, Arc<T>>>;
fn cached<T>(c: &'static OnceLock<Cache<T>>, json: &str, parse: impl Fn(&str) -> Option<T>) -> Option<Arc<T>> {
let key = fnv(json.as_bytes());
let c = c.get_or_init(Default::default);
if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(a.clone());
}
let a = Arc::new(parse(json)?);
let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 8 {
m.clear();
}
m.insert(key, a.clone());
Some(a)
}
/// The stored feature tracks of an instance (`None` when not analysed).
pub fn tracks(params: &Params) -> Option<Arc<CameraTracks>> {
static C: OnceLock<Cache<CameraTracks>> = OnceLock::new();
static C: AnalysisCache<CameraTracks> = AnalysisCache::new();
let j = s(params, TRACKS);
if j.is_empty() {
return None;
}
cached(&C, j, CameraTracks::from_json).filter(|t| !t.is_empty())
C.get(j, CameraTracks::from_json).map(|(_, a)| a).filter(|t| !t.is_empty())
}
/// The stored camera solve of an instance (`None` when not solved).
pub fn solve(params: &Params) -> Option<Arc<CameraSolve>> {
static C: OnceLock<Cache<CameraSolve>> = OnceLock::new();
static C: AnalysisCache<CameraSolve> = AnalysisCache::new();
let j = s(params, SOLVE);
if j.is_empty() {
return None;
}
cached(&C, j, CameraSolve::from_json).filter(|t| !t.is_empty())
C.get(j, CameraSolve::from_json).map(|(_, a)| a).filter(|t| !t.is_empty())
}
/// Screen size (layer pixels) of a target drawn for a point at depth `z`, relative to the
@@ -338,6 +313,27 @@ mod tests {
assert!(!st.lens_distortion && !solve_key(&st).contains("lens"));
}
#[test]
fn tracks_and_solve_follow_analysis_replacements() {
let mut t = CameraTracks { frames: 3, start: 1.0, ..Default::default() };
let mut sol = CameraSolve { frames: vec![SolvedFrame::default()], start: 1.0, ..Default::default() };
let mut ps = params_with(&sol, false);
ps.values.insert(TRACKS.into(), Value::Str(t.to_json()));
assert_eq!(*tracks(&ps).unwrap(), t);
assert_eq!(*solve(&ps).unwrap(), sol);
t.start = 2.0;
sol.start = 2.0;
ps.values.insert(TRACKS.into(), Value::Str(t.to_json()));
ps.values.insert(SOLVE.into(), Value::Str(sol.to_json()));
assert_eq!(*tracks(&ps).unwrap(), t);
assert_eq!(*solve(&ps).unwrap(), sol);
assert_eq!(*tracks(&ps.clone()).unwrap(), t);
ps.values.insert(TRACKS.into(), Value::Str("invalid".into()));
ps.values.insert(SOLVE.into(), Value::Str(String::new()));
assert!(tracks(&ps).is_none());
assert!(solve(&ps).is_none());
}
#[test]
fn undistort_footage_remaps_through_the_solved_lens() {
use effectcraft_track::camtrack::Distortion;
+14
View File
@@ -182,6 +182,20 @@ mod tests {
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
}
/// The README quotes the number of effects (#2565): every count it gives is the registry's.
#[test]
fn readme_effect_counts_are_current() {
let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md")).unwrap_or_default();
let words: Vec<&str> = readme.split_whitespace().collect();
let counts: Vec<&str> = words
.windows(3)
.filter(|w| w[0].parse::<u32>().is_ok() && (w[1].starts_with("effects") || (w[1] == "built-in" && w[2].starts_with("effects"))))
.map(|w| w[0])
.collect();
let n = super::registry().len().to_string();
assert!(!counts.is_empty() && counts.iter().all(|c| *c == n), "README.md says {counts:?} effects; the registry has {n}");
}
#[test]
fn cpu_only_controls_keep_effects_off_the_gpu() {
use effectcraft_keyframe::Value;
+19
View File
@@ -7,6 +7,11 @@
//! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through
//! (EXtractoR still maps the layer's own R, G, B and A).
//!
//! 3D Layer Instance (Create 3D Layer Instance's own effect, Layer ▸ Create) is not an aux
//! reader: it replaces the layer's pixels with a referenced layer's render — for a 3D model
//! layer, its render on its own through the comp's camera — so 2D effects can be applied to a
//! 3D layer's render.
//!
//! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer
//! pixel's layer position, so render scale and layer padding are respected.
@@ -582,6 +587,19 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// A 2D copy of a referenced layer (Create 3D Layer Instance's solid): the layer's pixels
/// replaced with the referenced layer's render, alpha included — the copy is transparent
/// where the referenced layer isn't. A 3D model or primitive layer is rendered on its own
/// through the comp's camera, as After Effects renders 3D layers; another layer is read with
/// its masks and effects. With no layer chosen the effect passes the layer through, as a layer
/// parameter with nothing chosen does.
fn instance_3d(ctx: &EffectCtx, mut b: Buf) -> Buf {
if let Some(o) = ctx.layer_param("layer", true) {
b.img = crate::util::fit_layer(ctx, &b, &o, false);
}
b
}
pub fn specs() -> Vec<EffectSpec> {
let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text);
vec![
@@ -682,6 +700,7 @@ pub fn specs() -> Vec<EffectSpec> {
],
identifier,
),
spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d),
]
}
+26
View File
@@ -373,3 +373,29 @@ fn channel_extract_antialias_averages_sub_pixels() {
let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host);
assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5));
}
#[test]
fn instance3d_replaces_the_pixels_with_the_referenced_layer() {
// Create 3D Layer Instance's copy: the chosen layer's render takes the place of the layer's
// own pixels, alpha included — the copy is transparent where the referenced layer isn't —
// and with no layer chosen the layer passes through.
struct Host(Image);
impl EffectHost for Host {
fn layer(&self, id: u64, _: bool) -> Option<LayerPixels> {
(id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
let solid = || Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]);
let mut render = Image::new(4, 4);
render.set(1, 1, [1.0, 0.0, 0.0, 1.0]);
let env = EffectEnv { host: Some(&Host(render)), ..Default::default() };
let out = run_fx("ec.3d.instance3d", &[("layer", Value::Layer(Some(7)))], solid(), 0.0, env);
assert_eq!(out.img.get(1, 1), [1.0, 0.0, 0.0, 1.0], "the referenced layer's pixel");
assert_eq!(out.img.get(3, 3), [0.0; 4], "transparent where the referenced layer isn't");
// No layer chosen: the layer's own pixels.
let out = run_fx("ec.3d.instance3d", &[], solid(), 0.0, EffectEnv::default());
assert_eq!(out.img.data, solid().data);
}
+46 -2
View File
@@ -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;
@@ -93,6 +95,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
let size = (ctx.params.f("size") * b.scale).max(1.0);
if ctx.params.b("resizeLayer") && !ctx.adjustment {
b.pad(amount.abs().ceil() as u32 + 1);
} else {
// Room for pixels displaced past the content, up to the layer's edge (#1205).
b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1));
}
let off = b.to_px(ctx.params.v2("offset"));
let oct = ctx.params.f("complexity").clamp(1.0, 10.0);
@@ -1115,6 +1120,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);
+35 -2
View File
@@ -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.
+143 -6
View File
@@ -8,6 +8,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod analysis_cache;
pub mod audio_fx;
mod blur2;
mod blur3;
@@ -116,7 +117,7 @@ pub use ocio::color_stabilizer_maps;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes};
pub use ocio::{ColorOp, Straight, Tf, color_program};
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan};
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan};
pub use sim2::{
CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan,
wave_world_plan,
@@ -311,17 +312,47 @@ impl Buf {
self.offset[0] += l as f64;
self.offset[1] += t as f64;
}
/// Grow by `pad` transparent pixels on every side.
/// Grow by `left` and `top` transparent pixels on those sides only.
pub fn grow_left_top(&mut self, left: u32, top: u32) {
self.pad_sides([left, top, 0, 0]);
}
/// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of
/// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled
/// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at
/// its content's corner instead of the cover's resamples to different pixels (#1205).
pub fn align(&mut self, size: [f64; 2]) {
if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) {
self.grow_left_top(l, t);
}
}
/// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`] and as
/// clamped by [`Image::effective_pad`].
pub fn pad(&mut self, pad: u32) {
if pad == 0 {
let eff = self.img.effective_pad(pad_within_budget(self.img.width, self.img.height, pad));
if eff == 0 {
return;
}
self.img = self.img.padded(pad);
self.offset[0] += pad as f64;
self.offset[1] += pad as f64;
self.img = self.img.padded(eff);
self.offset[0] += eff as f64;
self.offset[1] += eff as f64;
}
}
/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within
/// 16 Mpx (256 MiB of f32 RGBA) or four times its own area, whichever is larger: a small layer
/// far from an effect's centre can't ask for gigabytes (#2261). Shared with the GPU path.
pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 {
const MIN_BUDGET: f64 = 16_777_216.0;
let (w, h) = (f64::from(w), f64::from(h));
let budget = MIN_BUDGET.max(4.0 * w * h);
if (w + 2.0 * f64::from(pad)) * (h + 2.0 * f64::from(pad)) <= budget {
return pad;
}
// (w + 2p)(h + 2p) = budget, solved for p.
let p = (((w - h).powi(2) + 4.0 * budget).sqrt() - (w + h)) / 4.0;
(p.floor().max(0.0) as u32).min(pad)
}
/// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`:
/// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its
/// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp
@@ -359,6 +390,33 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3
Some((dx as i64, dy as i64))
}
/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge
/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these
/// skips the renderer's cover of the whole layer rectangle (#1205).
pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"];
/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels
/// of the resampling pyramid used for downscaled layers.
pub const GRID_ALIGN: u32 = 64;
/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None`
/// when there is nothing to add.
pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> {
let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?;
let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN);
(l != 0 || t != 0).then_some((l, t))
}
/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment
/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so
/// the grid phase stays. 0 once the buffer covers the layer.
pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 {
if ctx.adjustment {
return 0;
}
want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN
}
/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to
/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the
/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer
@@ -445,6 +503,18 @@ pub trait EffectHost: Sync {
fn params_at(&self, _layer_time: f64) -> Option<Params> {
None
}
/// Parameter `id` (a [`flatten_params`] id such as `birthRate`) of effect `effect_index` in
/// the layer's stack (the running effect's [`EffectEnv::effect_index`]) at another **layer
/// time**: CC Particle World samples its Birth Rate at every step it simulates (#1373).
/// `None` when unavailable.
fn param_at(&self, _effect_index: usize, _id: &str, _layer_time: f64) -> Option<Value> {
None
}
/// Parameter `id` of effect `effect_index` has one value at every time (no keyframes, no
/// expression), so sampling it over time can be skipped. `false` when unknown.
fn param_is_static(&self, _effect_index: usize, _id: &str) -> bool {
false
}
/// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the
/// CPU.
fn particles(&self) -> Option<&dyn psim::ParticleSim> {
@@ -525,6 +595,15 @@ pub struct EffectSpec {
pub float: bool,
}
impl EffectSpec {
/// True when this effect grows its own buffer (blur/glow margins, Repeat Edge
/// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the
/// renderer's full-layer cover and keep the pyramid grid phase (#1205).
pub fn skips_cover(&self) -> bool {
SKIPS_COVER.contains(&self.id)
}
}
pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec {
ParamSpec { id, name, default, ui }
}
@@ -1300,6 +1379,13 @@ pub fn is_time_dependent(id: &str) -> bool {
id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id)
}
/// Does effect `id` read its own parameters at earlier times (the stepped particle systems sample
/// their Birth Rate at every step, #1373)? Caches keyed on the current values can't see what an
/// expression returned then.
pub fn reads_param_history(id: &str) -> bool {
matches!(id, "ec.sim.ccparticleworld" | "ec.sim.ccparticlesystems2" | "ec.sim.ccmrmercury")
}
/// Run an effect on a buffer.
pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf {
(spec.render)(ctx, buf)
@@ -1324,6 +1410,23 @@ pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, en
mod tests {
use super::*;
/// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx;
/// big layers may still grow to four times their area, and small pads are untouched.
#[test]
fn padded_buffers_stay_within_the_budget() {
let area = |w: u32, h: u32, p: u32| (u64::from(w) + 2 * u64::from(p)) * (u64::from(h) + 2 * u64::from(p));
let far = radial_fast_pad((6500.0, 6500.0), 20, 20, 0.8);
assert_eq!(far, 4096, "the effect asks for its full pad");
let mut b = Buf { img: Image::new(20, 20), offset: [0.0; 2], scale: 1.0 };
b.pad(far);
assert!(area(20, 20, 0) < area(b.img.width, b.img.height, 0) && area(b.img.width, b.img.height, 0) <= 1 << 24, "{}x{}", b.img.width, b.img.height);
assert_eq!(b.offset, [f64::from((b.img.width - 20) / 2); 2], "the content keeps its place");
let p = pad_within_budget(3840, 2160, 4096);
assert!(p > 1000 && area(3840, 2160, p) <= 4 * 3840 * 2160 && area(3840, 2160, p + 1) > 4 * 3840 * 2160);
assert_eq!(pad_within_budget(640, 360, 200), 200);
assert_eq!(pad_within_budget(20, 20, 0), 0);
}
#[test]
fn run_fx_passthrough() {
let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]);
@@ -1331,6 +1434,20 @@ mod tests {
assert_eq!(out.img.data, img.data);
}
#[test]
fn pad_offset_follows_the_clamped_pad() {
// Image::padded clamps a hostile pad to keep each side within MAX_PADDED_SIDE; the offset
// must move by the pad actually applied, or the content drifts from its layer position.
let mut img = Image::new(29_990, 2);
img.set(0, 0, [1.0, 0.0, 0.0, 1.0]);
let mut buf = Buf { img, offset: [3.0, 4.0], scale: 1.0 };
buf.pad(u32::MAX);
// Width binds: (MAX_PADDED_SIDE - 29 990) / 2 = 5.
assert_eq!(buf.offset, [8.0, 9.0]);
let (x, y) = buf.to_px([-3.0, -4.0]);
assert_eq!(buf.img.get(x as i64, y as i64), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
#[ignore]
fn dump_registry() {
@@ -1515,4 +1632,24 @@ mod tests {
assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0)));
assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1);
}
#[test]
fn align_plan_is_cover_shift_mod_grid() {
// Content inset from the layer origin: pixel 0 maps to layer (50, 30).
let offset = [-50.0, -30.0];
let scale = 1.0;
let size = [1920.0, 1080.0];
let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap();
assert_eq!((dx, dy), (50, 30));
assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30)));
assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None);
}
#[test]
fn known_margin_effects_skip_cover_and_twirl_does_not() {
for id in SKIPS_COVER {
assert!(find(id).expect(id).skips_cover(), "{id}");
}
assert!(!find("ec.distort.twirl").unwrap().skips_cover());
}
}
+32
View File
@@ -344,6 +344,23 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
*g != before
}
/// The spec id of the parameter that an instance of `spec` saved at `id` (`a/b/param`) is now:
/// `id` itself when the spec still has it, the new id of a renamed parameter
/// ([`PARAM_ID_ALIASES`]), or the twirl-down path of a top-level parameter that moved into a
/// group, as [`upgrade_instance`] finds them. Returns `None` when no parameter matches.
pub fn current_param_id(spec: &EffectSpec, id: &str) -> Option<&'static str> {
if let Some(ps) = spec.params.iter().find(|ps| ps.id == id) {
return Some(ps.id);
}
if let Some((_, _, new)) = PARAM_ID_ALIASES.iter().find(|(e, old, _)| *e == spec.id && *old == id) {
return Some(new);
}
if id.contains('/') {
return None;
}
spec.params.iter().find(|ps| ps.id.rsplit('/').next() == Some(id)).map(|ps| ps.id)
}
/// Bring an effect instance saved before project schema 2 into effect space (#227).
///
/// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on
@@ -559,6 +576,21 @@ mod tests {
assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0));
}
/// A saved parameter id maps to the parameter `upgrade_instance` moves it to.
#[test]
fn current_param_id_follows_renames_and_twirl_down_moves() {
let stab = find(crate::warp_stab::ID).unwrap();
assert_eq!(current_param_id(stab, "stabilization/smoothness"), Some("stabilization/smoothness"));
assert_eq!(current_param_id(stab, "smoothness"), Some("stabilization/smoothness"));
let ctc = find("ec.color.changetocolor").unwrap();
assert_eq!(current_param_id(ctc, "tolerance"), Some("toleranceGroup/hue"));
// Radio Waves' old Velocity is the growth speed, not the new Velocity.
let waves = find("ec.generate.radiowaves").unwrap();
assert_eq!(current_param_id(waves, "velocity"), Some("waveMotion/expansion"));
assert_eq!(current_param_id(stab, "noSuchParam"), None);
assert_eq!(current_param_id(stab, "other/smoothness"), None);
}
#[test]
fn card_wipe_back_layer_popup_becomes_a_layer_parameter() {
let spec = find("ec.transition.cardwipe").unwrap();
+6
View File
@@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf {
let repeat = ctx.params.b("repeatEdge");
if !repeat && !ctx.adjustment {
b.pad((s * 3.0).ceil() as u32);
} else if repeat && !ctx.adjustment {
// The edge it repeats is the layer's, not the painted content's (#1205).
b.cover(ctx.layer_size);
}
let (sx, sy) = (s * kx, s * ky);
// Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939).
@@ -60,6 +63,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
if !repeat && !ctx.adjustment {
let reach = if frac > 0.0 { r + 1 } else { r };
b.pad((reach * it) as u32 + 1);
} else if repeat && !ctx.adjustment {
// The edge it repeats is the layer's, not the painted content's (#1205).
b.cover(ctx.layer_size);
}
let edge = repeat || ctx.adjustment;
let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge);
+24 -28
View File
@@ -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
View File
@@ -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]);
+44 -5
View File
@@ -191,6 +191,11 @@ pub fn roto_data(params: &Params, layer_size: [f64; 2], f: i64, with_model: bool
/// Seed of the chain keys: what the frames are (the Input Key), the layer size, the frame rate,
/// the mode and the model. The prompt is in each frame's key.
pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> u64 {
seed_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id))
}
/// [`seed`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model).
fn seed_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325;
fnv(&mut h, ID.as_bytes());
fnv(&mut h, s(params, INPUT_KEY).as_bytes());
@@ -198,7 +203,7 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn
fnv(&mut h, &v.to_bits().to_le_bytes());
}
fnv(&mut h, &[tracks(params) as u8]);
fnv(&mut h, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes());
fnv(&mut h, model.as_bytes());
h
}
@@ -255,9 +260,14 @@ pub fn frame_keys(params: &Params, layer_size: [f64; 2], fps: f64, frames: &[i64
/// What frozen mattes depend on: what the frames are (the seed: Input Key, size, rate, mode,
/// model), the prompt, the tracked span and the matte settings.
pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> String {
let mut h = seed(params, layer_size, fps, model);
frozen_key_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id))
}
/// [`frozen_key`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model).
fn frozen_key_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> String {
let mut h = seed_of(params, layer_size, fps, model);
let base = reference_frame(params);
if let Some(st) = prompt_strokes(params, layer_size, base, model.is_some()) {
if let Some(st) = prompt_strokes(params, layer_size, base, model != effectcraft_segment::CLASSICAL) {
fnv(&mut h, serde_json::to_string(&st).unwrap_or_default().as_bytes());
}
for v in [base, reach(params)[0], reach(params)[1]] {
@@ -273,7 +283,9 @@ pub fn is_frozen(params: &Params) -> bool {
}
/// Frame `f`'s frozen matte on a `w`×`h` grid, when the instance is frozen, the mattes still
/// match ([`frozen_key`]) and `f` is one of them.
/// match ([`frozen_key`]) and `f` is one of them. The mattes match under the session's `model`
/// or under the model that froze them (which this session may not have loaded, e.g. a fresh
/// headless render): frozen mattes need no segmentation.
pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: usize, h: usize, model: Option<&dyn MaskModel>) -> Option<Vec<f32>> {
let j = s(params, FROZEN);
if j.is_empty() {
@@ -281,7 +293,10 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w:
}
let fc = frozen_cache(j)?;
if fc.key != frozen_key(params, layer_size, fps, model) {
return None;
let ids = std::iter::once(effectcraft_segment::CLASSICAL).chain(effectcraft_segment::MODELS.iter().map(|m| m.id));
if !ids.into_iter().any(|id| fc.key == frozen_key_of(params, layer_size, fps, id)) {
return None;
}
}
let fr = fc.frames.get(&f)?;
let [fw, fh] = fc.size;
@@ -705,4 +720,28 @@ mod tests {
p.values.insert(FROZEN.into(), Value::Str(fc2.to_json()));
assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None);
}
/// Mattes frozen with a model stay usable in a session where that model is not loaded (a
/// fresh headless render): they are used as stored, not re-segmented with another model.
#[test]
fn frozen_mattes_survive_a_session_without_their_model() {
let mut p = params(&[
("objectPoint", Value::Vec2([40.0, 40.0])),
("useBox", Value::Bool(true)),
("boxTopLeft", Value::Vec2([10.0, 10.0])),
("boxBottomRight", Value::Vec2([90.0, 70.0])),
("objectTracking/framesAfter", Value::Scalar(3.0)),
]);
p.values.insert(INPUT_KEY.into(), Value::Str("frozen-model-test".into()));
let stub = Stub::default();
let alpha: Vec<f32> = (0..100 * 80).map(|i| ((i % 100) < 50) as u8 as f32).collect();
let mut fc = FrozenCache { key: frozen_key(&p, [100.0, 80.0], 25.0, Some(&stub)), size: [100, 80], ..Default::default() };
fc.frames.insert(2, roto::freeze_frame(&alpha, &[]));
p.values.insert(FROZEN.into(), Value::Str(fc.to_json()));
assert!(is_frozen(&p));
assert!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, Some(&stub)).is_some());
assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 50, 40, None).map(|a| (a[20 * 50 + 5], a[20 * 50 + 45])), Some((1.0, 0.0)));
let out = run(&p, 2);
assert!(out.data[40 * 100 + 10][3] > 0.99 && out.data[40 * 100 + 90][3] < 0.01, "the frozen matte is used");
}
}
+12 -6
View File
@@ -85,21 +85,27 @@ pub trait ParticleSim: Send + Sync {
/// (the CPU step's `carry` arithmetic, exactly). `None` when more than `cap` would be born (the
/// CPU drops births at its live-particle cap, which an independent simulation can't know).
pub fn births(rate: f64, steps: u64, cap: usize) -> Option<Vec<u32>> {
let per = rate / SPS;
if per * steps as f64 > cap as f64 + 2.0 {
if rate / SPS * steps as f64 > cap as f64 + 2.0 {
return None;
}
births_at(steps, cap, |_| rate)
}
/// [`births`] with the rate of each step, `rate(i)` particles per second during step `i` (a
/// keyframed Birth Rate, #1373).
pub fn births_at(steps: u64, cap: usize, rate: impl Fn(u64) -> f64) -> Option<Vec<u32>> {
let mut out = Vec::new();
let mut carry = 0.0f64;
for i in 0..steps {
carry += per;
carry += rate(i) / SPS;
let n = carry.floor() as u32;
carry -= n as f64;
for _ in 0..n {
out.push(i as u32);
if n as usize > cap.saturating_sub(out.len()) {
return None;
}
out.extend(std::iter::repeat_n(i as u32, n as usize));
}
(out.len() <= cap).then_some(out)
Some(out)
}
/// Steps per second of the stepped simulations.
+6 -4
View File
@@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::generate::value_noise;
use crate::util::{SimCache, hash1, params_key, params_key_except, unpremul};
use crate::util::{SimCache, hash1, params_key_except, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
mod common;
@@ -28,15 +28,17 @@ mod weather;
pub(crate) use common::{h, hs, pct, pt, spec};
pub use engine::particle_state;
#[cfg(test)]
pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate};
use engine::{MERCURY, PS2, PW_EFFECT_CAMERA, Stepped, rate_history, sim_key, simulate_rates};
#[cfg(test)]
use engine::{PW_EFFECT_CAMERA, sim_key};
pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate};
pub(crate) use engine::{SPS, steps_at};
pub use particle_fx::{Blob, mercury_blobs};
#[cfg(test)]
use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys};
pub use registry::{specs, sprite_plan};
pub use sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint};
pub(crate) use sprites::{combine, raster, splat};
pub use weather::{Bubble, bubble_list, drizzle_drops};
pub use weather::{Bubble, bubble_list, bubble_sample_bounds, drizzle_drops};
#[cfg(test)]
mod tests;
+100 -14
View File
@@ -1,5 +1,5 @@
use super::common::{h, hs};
use super::particle_fx::{PS2_CACHE, PW_CACHE, ps2_phys, pw_phys};
use super::particle_fx::{MERCURY_CACHE, MERCURY_RATE, PS2_CACHE, PS2_RATE, PW_CACHE, PW_RATE, ps2_phys, pw_phys};
use super::sprites::{Shape, Sprite};
use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, SimCache, params_key_except, value_noise};
@@ -29,6 +29,14 @@ pub(crate) struct PState {
pub parts: Vec<Particle>,
pub carry: f64,
pub next_id: u32,
/// The birth rates of the steps so far (see [`rates_hash`]): a checkpoint is only resumed
/// from with the same history.
pub hist: u64,
}
/// `hist` after one more step at `rate` particles per second.
fn rates_hash(hist: u64, rate: f64) -> u64 {
(hist ^ rate.to_bits()).wrapping_mul(0x0100_0000_01b3).rotate_left(5)
}
/// Physics configuration (world units; y down).
@@ -106,7 +114,15 @@ impl Phys {
Particle { p, v, age: 0.0, life, rnd: h(id, 11, seed), id }
}
/// One step at the constant [`Phys::rate`] (the tests' reference).
#[cfg(test)]
pub(crate) fn step(&self, st: &mut PState, step: u64) {
self.step_at_rate(st, step, self.rate);
}
/// One step with `rate` particles per second born during it (rather than [`Phys::rate`]):
/// the rate only decides the births, particles already alive move and age as ever.
pub(crate) fn step_at_rate(&self, st: &mut PState, step: u64, rate: f64) {
let dt = (1.0 / SPS) as f32;
let t = step as f32 * dt;
let mut drag = self.resistance;
@@ -164,7 +180,8 @@ impl Phys {
q.age += dt;
});
st.parts.retain(|q| q.age < q.life);
st.carry += self.rate / SPS;
st.hist = rates_hash(st.hist, rate);
st.carry += rate / SPS;
let n = st.carry.floor() as u32;
st.carry -= n as f64;
for _ in 0..n {
@@ -211,42 +228,111 @@ impl Phys {
}
/// The state at layer time `ctx.time` from the host's particle backend (GPU particles), or
/// `None` (simulate on the CPU).
pub(super) fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys) -> Option<std::sync::Arc<PState>> {
/// `None` (simulate on the CPU). `rates`: the birth rate of each step, when it changes over time
/// (else [`Phys::rate`]).
pub(super) fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys, rates: Option<&[f64]>) -> Option<std::sync::Arc<PState>> {
let backend = ctx.env.host?.particles()?;
let steps = steps_at(ctx.time);
let births = crate::psim::births(phys.rate, steps, phys.max)?;
let births = match rates {
Some(r) => crate::psim::births_at(steps, phys.max, |i| r.get(i as usize).copied().unwrap_or(0.0))?,
None => crate::psim::births(phys.rate, steps, phys.max)?,
};
let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Engine(phys.desc()) };
let parts = backend.simulate(&req)?;
Some(std::sync::Arc::new(PState {
parts: parts.into_iter().map(|q| Particle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect(),
carry: 0.0,
next_id: births.len() as u32,
hist: 0,
}))
}
/// The birth rate (particles per second) of each step up to layer time `ctx.time`: parameter
/// `id` × `k`, sampled where the step starts (a keyframed or expression-driven Birth Rate only
/// decides the births of its own time, #1373). Without a host that evaluates the effect at
/// other times, the current value for every step.
pub(super) fn rate_history(ctx: &EffectCtx, id: &str, k: f64) -> Vec<f64> {
let now = ctx.params.f(id);
// A rate without keyframes or an expression is the same at every step.
let host = ctx.env.host.filter(|h| !h.param_is_static(ctx.env.effect_index, id));
(0..steps_at(ctx.time))
.map(|i| {
let v = host.and_then(|h| h.param_at(ctx.env.effect_index, id, i as f64 / SPS)).map_or(now, |v| v.as_f64());
if v.is_finite() { v.max(0.0) * k } else { 0.0 }
})
.collect()
}
/// Run a particle system through `rates` (one birth rate per step, see [`rate_history`]) with
/// `cache`, resuming only from checkpoints reached with the same rates.
pub(super) fn simulate_rates(cache: &SimCache<PState>, key: u64, phys: &Phys, rates: &[f64]) -> std::sync::Arc<PState> {
// `hist` of the state after each number of steps.
let mut prefix = Vec::with_capacity(rates.len() + 1);
prefix.push(0u64);
for r in rates {
prefix.push(rates_hash(prefix.last().copied().unwrap_or(0), *r));
}
let rate = |i: u64| rates.get(i as usize).copied().unwrap_or(0.0);
cache.run_where(key, rates.len() as u64, PState::default, |st, i| phys.step_at_rate(st, i, rate(i)), |s, st| prefix.get(s as usize) == Some(&st.hist))
}
/// A stepped particle system's particles at `ctx.time`, born at the Birth Rate of their own
/// time (`birthRate` × `k` particles per second, see [`rate_history`]). The cache key is every
/// parameter but Birth Rate (its history is checked through `PState::hist`) and those under
/// `skip`, with `salt`. `gpu`: the host's particle backend may simulate it.
pub(super) struct Stepped<'a> {
pub(super) cache: &'a SimCache<PState>,
pub(super) salt: u64,
pub(super) skip: &'a [&'a str],
pub(super) k: f64,
pub(super) gpu: bool,
}
impl Stepped<'_> {
pub(super) fn state(&self, ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc<PState> {
let rates = rate_history(ctx, "birthRate", self.k);
let mut skip = self.skip.to_vec();
skip.push("birthRate");
let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, self.salt, &skip);
let gpu = if self.gpu { accelerated(ctx, key, phys, Some(&rates)) } else { None };
gpu.unwrap_or_else(|| simulate_rates(self.cache, key, phys, &rates))
}
}
/// CC Particle World.
const PW: Stepped = Stepped { cache: &PW_CACHE, salt: 1, skip: &[PW_EFFECT_CAMERA], k: PW_RATE, gpu: true };
/// CC Particle Systems II.
pub(super) const PS2: Stepped = Stepped { cache: &PS2_CACHE, salt: 2, skip: &[], k: PS2_RATE, gpu: true };
/// CC Mr. Mercury (its blobs are simulated on the CPU).
pub(super) const MERCURY: Stepped = Stepped { cache: &MERCURY_CACHE, salt: 3, skip: &[], k: MERCURY_RATE, gpu: false };
/// CC Particle World's particles at `ctx.time`, born at the Birth Rate of their own time.
pub(super) fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc<PState> {
PW.state(ctx, phys)
}
/// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`,
/// `ec.sim.ccparticlesystems2`) at `ctx.time`: from the host's particle backend when it has
/// one (GPU particles), else from the CPU simulation. For comparing the two.
pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option<Vec<crate::psim::SimParticle>> {
let (phys, cache, salt) = match id {
"ec.sim.ccparticleworld" => (pw_phys(ctx), &PW_CACHE, 1),
"ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2),
let st = match id {
"ec.sim.ccparticleworld" => pw_state(ctx, &pw_phys(ctx)),
"ec.sim.ccparticlesystems2" => PS2.state(ctx, &ps2_phys(ctx)),
_ => return None,
};
let key = sim_key(ctx, salt);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(cache, key, ctx.time, &phys));
Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect())
}
/// The simulation cache key of a stepped particle effect: every parameter but CC Particle World's
/// Effect Camera, which only changes how the particles are seen (animating it doesn't restart
/// the simulation).
/// The simulation cache key of a stepped particle effect at a constant Birth Rate (the tests'
/// reference): every parameter but CC Particle World's Effect Camera.
#[cfg(test)]
pub(super) fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 {
params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA])
}
/// Run a particle system to layer time `t` through `cache`.
/// Run a particle system at its constant [`Phys::rate`] to layer time `t` through `cache` (the
/// tests' reference).
#[cfg(test)]
pub(crate) fn simulate(cache: &SimCache<PState>, key: u64, t: f64, phys: &Phys) -> std::sync::Arc<PState> {
cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i))
}
+30 -18
View File
@@ -1,16 +1,19 @@
use super::engine::{PState, PW_EFFECT_CAMERA, Phys, accelerated, norm3, particle_look, sim_key, simulate, type_sprite};
use super::engine::{MERCURY, PS2, PState, PW_EFFECT_CAMERA, Phys, norm3, particle_look, pw_state, type_sprite};
use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat};
use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, params_key, unpremul};
use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, unpremul};
// ------------------------------------------------------------------ CC Particle World
pub(super) static PW_CACHE: SimCache<PState> = SimCache::new(6);
/// Particles per second per unit of CC Particle World's Birth Rate.
pub(super) const PW_RATE: f64 = 120.0;
/// CC Particle World's physics.
pub(super) fn pw_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
Phys {
rate: pr.f("birthRate").max(0.0) * 120.0,
rate: pr.f("birthRate").max(0.0) * PW_RATE,
life: pr.f("longevity").max(0.0) as f32,
producer: [pr.f("producer/producerX") as f32, pr.f("producer/producerY") as f32, pr.f("producer/producerZ") as f32],
radius: [pr.f("producer/radiusX").max(0.0) as f32, pr.f("producer/radiusY").max(0.0) as f32, pr.f("producer/radiusZ").max(0.0) as f32],
@@ -152,8 +155,7 @@ pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan
if phys.rate <= 0.0 && ctx.time <= 0.0 {
return None;
}
let key = sim_key(ctx, 1);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PW_CACHE, key, ctx.time, &phys));
let st = pw_state(ctx, &phys);
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
let death = pr.color("particle/deathColor");
@@ -190,6 +192,9 @@ fn particle_sprites(sprites: Vec<Sprite>, mode: u32) -> SpritePlan {
pub(super) static PS2_CACHE: SimCache<PState> = SimCache::new(6);
/// Particles per second per unit of CC Particle Systems II's Birth Rate.
pub(super) const PS2_RATE: f64 = 60.0;
/// CC Particle Systems II's physics.
pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
@@ -198,7 +203,7 @@ pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys {
let pos = pr.v2("producer/position");
let dir = (pr.f("physics/direction") as f32).to_radians();
Phys {
rate: pr.f("birthRate").max(0.0) * 60.0,
rate: pr.f("birthRate").max(0.0) * PS2_RATE,
life: pr.f("longevity").max(0.0) as f32,
producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0],
radius: [pr.f("producer/radiusX").max(0.0) as f32 / unit, pr.f("producer/radiusY").max(0.0) as f32 / unit, 0.0],
@@ -225,9 +230,7 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
let phys = ps2_phys(ctx);
let key = sim_key(ctx, 2);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys));
let st = PS2.state(ctx, &ps2_phys(ctx));
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
let death = pr.color("particle/deathColor");
@@ -250,7 +253,10 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
// ------------------------------------------------------------------ CC Mr. Mercury
static MERCURY_CACHE: SimCache<PState> = SimCache::new(6);
pub(super) static MERCURY_CACHE: SimCache<PState> = SimCache::new(6);
/// Blobs per second per unit of CC Mr. Mercury's Birth Rate.
pub(super) const MERCURY_RATE: f64 = 10.0;
/// A CC Mr. Mercury blob (buffer px).
#[derive(Clone, Copy, Debug)]
@@ -260,15 +266,14 @@ pub struct Blob {
pub r: f32,
}
/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`).
pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec<Blob> {
/// CC Mr. Mercury's physics.
pub(super) fn mercury_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
let unit = (ctx.layer_size[1] as f32).max(1.0);
let pos = pr.v2("producer");
let dir = (pr.f("direction") as f32).to_radians();
let phys = Phys {
rate: pr.f("birthRate").max(0.0) * 10.0,
Phys {
rate: pr.f("birthRate").max(0.0) * MERCURY_RATE,
life: pr.f("longevity").max(0.0) as f32,
producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0],
radius: [pr.f("radiusX").max(0.0) as f32 / unit, pr.f("radiusY").max(0.0) as f32 / unit, 0.0],
@@ -282,8 +287,15 @@ pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec<Blob> {
seed: ctx.seed.wrapping_mul(0xc2b2ae35),
max: 4_000,
flat: true,
};
let st = simulate(&MERCURY_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 3), ctx.time, &phys);
}
}
/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`).
pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec<Blob> {
let pr = ctx.params;
let unit = (ctx.layer_size[1] as f32).max(1.0);
let phys = mercury_phys(ctx);
let st = MERCURY.state(ctx, &phys);
let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32);
let s = b.scale as f32;
st.parts
+317
View File
@@ -97,6 +97,60 @@ fn bubbles_rise() {
assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}");
}
/// #2024: bubbles near the layer's edges show its edge pixels, not the pale colour that stands
/// in for transparent ones.
#[test]
fn bubbles_near_edges_show_the_layer() {
let blue = Image::filled(40, 40, [0.0, 0.0, 1.0, 1.0]);
let pale = [0, 1].map(|reflection| {
let vals = [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))];
let a = run("ec.sim.ccbubbles", &vals, blue.clone(), 0.3);
assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn");
// Blue plus the white highlight keeps blue − red = alpha; the pale colour leaves 0.2 · alpha.
a.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count()
});
assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)");
}
/// CC Bubbles on a 40 × 40 layer `img` at `scale`, in a buffer padded by 6 transparent pixels
/// (a half-resolution preview's layout).
fn padded_bubbles(img: Image, scale: f64, reflection: u32) -> Image {
let size = 40.0;
let s = crate::find("ec.sim.ccbubbles").unwrap();
let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [size; 2]))).collect() };
for (k, v) in [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))] {
params.values.insert(k.into(), v);
}
let ctx = EffectCtx { params: &params, time: 0.3, layer_size: [size; 2], seed: 1, adjustment: false, env: EffectEnv::default() };
let mut b = Buf { img, offset: [0.0; 2], scale };
b.pad(6);
crate::apply(s, &ctx, b).img
}
/// #2024: the same when the buffer has transparent padding around the layer: samples past the
/// layer's edges repeat its edge pixels, not the padding, and a clear hole inside the layer is
/// still refracted as the pale colour.
#[test]
fn bubbles_near_padded_edges_show_the_layer() {
let blue = |side| Image::filled(side, side, [0.0, 0.0, 1.0, 1.0]);
let pale = |img: &Image| img.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count();
for (scale, side) in [(1.0, 40), (0.5, 20)] {
let counts = [0, 1].map(|reflection| {
let a = padded_bubbles(blue(side), scale, reflection);
assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn");
pale(&a)
});
assert_eq!(counts, [0, 0], "pale bubble pixels at scale {scale} (liquid, metal)");
}
let mut holed = blue(40);
for y in 12..28 {
for x in 12..28 {
holed.set(x, y, [0.0; 4]);
}
}
assert!(pale(&padded_bubbles(holed, 1.0, 0)) > 0, "the hole refracts as the pale colour");
}
#[test]
fn drizzle_ripples() {
let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6);
@@ -405,3 +459,266 @@ fn half_resolution_scales_sprites() {
let sum = |i: &Image| i.data.iter().map(|p| p[0]).sum::<f32>() / i.data.len() as f32;
assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img));
}
// ---------------------------------------------------------- Birth Rate over time (#1373)
/// A host that evaluates effect `index`'s Birth Rate at other layer times as `rate(t)`.
struct Rate {
index: usize,
rate: fn(f64) -> f64,
}
impl crate::EffectHost for Rate {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn param_at(&self, effect_index: usize, id: &str, t: f64) -> Option<Value> {
(effect_index == self.index && id == "birthRate").then(|| num((self.rate)(t)))
}
}
/// Birth Rate 3 until 0.5 s, then 0 (the issue's one-shot burst).
fn burst(t: f64) -> f64 {
if t < 0.5 { 3.0 } else { 0.0 }
}
/// The same burst, cut at 0.25 s (an earlier key moved).
fn short_burst(t: f64) -> f64 {
if t < 0.25 { 3.0 } else { 0.0 }
}
/// Birth Rate falling linearly from 3 at 0 s to 0 at 0.5 s.
fn falling(t: f64) -> f64 {
(3.0 * (1.0 - t / 0.5)).max(0.0)
}
/// CC Particle World's parameters: random seed `seed`, Birth Rate `now` (its value at the
/// rendered time), Longevity 1 s.
fn pw_params(seed: f64, now: f64) -> crate::Params {
let spec = crate::find("ec.sim.ccparticleworld").unwrap();
let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() };
for (k, v) in [("birthRate", num(now)), ("extras/randomSeed", num(seed)), ("longevity", num(1.0))] {
p.values.insert(k.to_string(), v);
}
p
}
fn pw_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, index: usize) -> EffectCtx<'a> {
let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: index, ..Default::default() };
EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed: 1, adjustment: false, env }
}
/// The live particles at layer time `t` with Birth Rate `now` there and `host`'s history.
fn pw_at(seed: f64, now: f64, t: f64, host: Option<&Rate>) -> Vec<crate::psim::SimParticle> {
let p = pw_params(seed, now);
particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, t, host, host.map_or(0, |h| h.index))).unwrap()
}
/// The same, simulated from layer time 0 with a cache of its own (the reference).
fn pw_fresh(seed: f64, now: f64, t: f64, host: &Rate) -> Vec<crate::psim::SimParticle> {
let p = pw_params(seed, now);
let ctx = pw_ctx(&p, t, Some(host), host.index);
let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1, &[PW_EFFECT_CAMERA, "birthRate"]);
let st = simulate_rates(&SimCache::new(1), key, &pw_phys(&ctx), &rate_history(&ctx, "birthRate", PW_RATE));
st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect()
}
/// Particles born in the first `t` seconds of `rate`.
fn born_by(rate: fn(f64) -> f64, t: f64) -> usize {
crate::psim::births_at(steps_at(t), 40_000, |i| rate(i as f64 / SPS) * PW_RATE).unwrap().len()
}
/// #1373: Birth Rate only decides the births of its own time. After a burst stops, the
/// particles born in it live on (moving and ageing) until their Longevity runs out, rather
/// than vanishing because the rate is now 0.
#[test]
fn a_burst_lives_on_after_birth_rate_drops_to_zero() {
let host = Rate { index: 2, rate: burst };
// During the burst: as a constant Birth Rate of 3.
assert_eq!(pw_at(101.0, 3.0, 0.4, Some(&host)), pw_at(101.0, 3.0, 0.4, None));
// After it: the burst's particles, none newer.
let late = pw_at(101.0, 0.0, 0.9, Some(&host));
let n = born_by(burst, 0.5);
assert!(late.len() > n / 2 && late.iter().all(|q| (q.id as usize) < n), "{} live of {n} born", late.len());
assert!(pw_at(101.0, 0.0, 0.9, None).is_empty(), "a constant Birth Rate of 0 never bore any");
// They keep moving.
let early = pw_at(101.0, 0.0, 0.7, Some(&host));
let q0 = early.iter().find(|q| q.id == late[0].id).unwrap();
assert!(q0.p != late[0].p && late[0].age > q0.age + 0.19, "{q0:?} -> {:?}", late[0]);
// Longevity 1 s (± 20 %): all gone by 1.8 s.
assert!(pw_at(101.0, 0.0, 1.8, Some(&host)).is_empty());
// The effect asks for its own index: another effect's history isn't its own.
let p = pw_params(101.0, 0.0);
assert!(particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, 0.9, Some(&host), 0)).unwrap().is_empty());
}
/// A Birth Rate ramping down isn't simulated as if its current value had always applied.
#[test]
fn a_ramped_birth_rate_bears_what_each_moment_asked_for() {
let host = Rate { index: 0, rate: falling };
let ramped = pw_at(102.0, falling(0.25), 0.25, Some(&host));
let constant = pw_at(102.0, falling(0.25), 0.25, None);
assert_eq!(ramped.len(), born_by(falling, 0.25), "every particle born so far is alive");
assert_eq!(constant.len(), born_by(|_| 1.5, 0.25));
assert!(ramped.len() > constant.len() + 10, "{} vs {}", ramped.len(), constant.len());
}
/// Seeking backward, forward and to the same time again gives the state simulated from 0.
#[test]
fn seeking_through_a_birth_rate_history_matches_simulating_from_zero() {
let host = Rate { index: 0, rate: burst };
for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] {
let now = burst(t);
assert_eq!(pw_at(103.0, now, t, Some(&host)), pw_fresh(103.0, now, t, &host), "at {t}");
}
}
/// The simulation cache key leaves Birth Rate out (so an animated rate keeps its checkpoints),
/// but a checkpoint is only resumed with the history that reached it: after an earlier key
/// moves, the same current rate (0) gives the new history's particles.
#[test]
fn an_earlier_birth_rate_change_is_not_served_from_stale_checkpoints() {
let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst });
let a = pw_at(104.0, 0.0, 0.9, Some(&long));
let b = pw_at(104.0, 0.0, 0.9, Some(&short));
assert_eq!(b, pw_fresh(104.0, 0.0, 0.9, &short));
assert!(b.len() < a.len() && !b.is_empty(), "{} vs {}", b.len(), a.len());
assert_eq!(pw_at(104.0, 0.0, 1.0, Some(&long)), pw_fresh(104.0, 0.0, 1.0, &long));
}
/// A constant Birth Rate gives exactly the particles it did before the history was sampled.
#[test]
fn a_constant_birth_rate_is_unchanged() {
let host = Rate { index: 0, rate: |_| 2.0 };
let p = pw_params(105.0, 2.0);
let ctx = pw_ctx(&p, 1.3, None, 0);
let phys = pw_phys(&ctx);
let before = simulate(&SimCache::new(1), sim_key(&ctx, 1), 1.3, &phys);
let now = pw_at(105.0, 2.0, 1.3, Some(&host));
assert_eq!(now.len(), before.parts.len());
assert!(now.iter().zip(&before.parts).all(|(a, b)| a.id == b.id && a.p == b.p && a.v == b.v && a.age == b.age));
assert_eq!(crate::psim::births_at(78, 40_000, |_| phys.rate), crate::psim::births(phys.rate, 78, 40_000));
}
/// The GPU's birth schedule for a rate history is the CPU's: the ids born are the ids the
/// CPU simulation numbers.
#[test]
fn the_birth_schedule_follows_the_rate_history() {
let host = Rate { index: 0, rate: falling };
let p = pw_params(106.0, 0.0);
let ctx = pw_ctx(&p, 0.7, Some(&host), 0);
let rates = rate_history(&ctx, "birthRate", PW_RATE);
let st = simulate_rates(&SimCache::new(1), 9, &pw_phys(&ctx), &rates);
let births = crate::psim::births_at(rates.len() as u64, 40_000, |i| rates[i as usize]).unwrap();
assert_eq!(st.next_id as usize, births.len());
assert!(births.iter().all(|&b| f64::from(b) / SPS < 0.5), "nothing born once the ramp reaches 0");
}
// ------------------------- CC Particle Systems II and CC Mr. Mercury's Birth Rate over time
/// A stepped particle effect: its id, physics and simulation.
struct System {
id: &'static str,
phys: fn(&EffectCtx) -> Phys,
stepped: Stepped<'static>,
}
const SYSTEMS: [System; 2] =
[System { id: "ec.sim.ccparticlesystems2", phys: ps2_phys, stepped: PS2 }, System { id: "ec.sim.ccmrmercury", phys: mercury_phys, stepped: MERCURY }];
/// `id`'s parameters with Birth Rate `now` and Longevity 1 s.
fn sys_params(id: &str, now: f64) -> crate::Params {
let spec = crate::find(id).unwrap();
let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() };
for (k, v) in [("birthRate", num(now)), ("longevity", num(1.0))] {
assert!(p.values.contains_key(k), "{id}: no {k}");
p.values.insert(k.to_string(), v);
}
p
}
/// The context at layer time `t` (`seed` keeps each test's simulations apart).
fn sys_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, seed: u32) -> EffectCtx<'a> {
let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: host.map_or(0, |h| h.index), ..Default::default() };
EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed, adjustment: false, env }
}
/// The live particles (id, position, age) of `sys` at `t` through its simulation.
fn sys_at(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> Vec<(u32, [f32; 3], f32)> {
let p = sys_params(sys.id, now);
let ctx = sys_ctx(&p, t, host, seed);
sys.stepped.state(&ctx, &(sys.phys)(&ctx)).parts.iter().map(|q| (q.id, q.p, q.age)).collect()
}
/// The same, simulated from layer time 0 with a cache of its own (the reference).
fn sys_fresh(sys: &System, seed: u32, now: f64, t: f64, host: &Rate) -> Vec<(u32, [f32; 3], f32)> {
let p = sys_params(sys.id, now);
let ctx = sys_ctx(&p, t, Some(host), seed);
let mut skip = sys.stepped.skip.to_vec();
skip.push("birthRate");
let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, sys.stepped.salt, &skip);
let st = simulate_rates(&SimCache::new(1), key, &(sys.phys)(&ctx), &rate_history(&ctx, "birthRate", sys.stepped.k));
st.parts.iter().map(|q| (q.id, q.p, q.age)).collect()
}
/// What `sys` draws at `t`: CC Particle Systems II's particles, CC Mr. Mercury's blobs.
fn sys_drawn(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> usize {
let p = sys_params(sys.id, now);
let ctx = sys_ctx(&p, t, host, seed);
match sys.id {
"ec.sim.ccmrmercury" => mercury_blobs(&ctx, &Buf { img: Image::new(100, 100), offset: [0.0; 2], scale: 1.0 }).len(),
id => particle_state(id, &ctx).unwrap().len(),
}
}
/// #1373 for CC Particle Systems II and CC Mr. Mercury: a burst lives on after Birth Rate
/// drops to 0 (what they draw too), moving and ageing, and is gone after its Longevity.
#[test]
fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops() {
let host = Rate { index: 1, rate: burst };
for sys in &SYSTEMS {
let born = crate::psim::births_at(steps_at(0.5), 40_000, |i| burst(i as f64 / SPS) * sys.stepped.k).unwrap().len();
assert_eq!(sys_at(sys, 201, 3.0, 0.4, Some(&host)), sys_at(sys, 201, 3.0, 0.4, None), "{}: during the burst", sys.id);
let late = sys_at(sys, 201, 0.0, 0.9, Some(&host));
assert!(late.len() > born / 2 && late.iter().all(|q| (q.0 as usize) < born), "{}: {} live of {born}", sys.id, late.len());
assert!(sys_drawn(sys, 201, 0.0, 0.9, Some(&host)) > 0, "{}: drawn", sys.id);
assert_eq!(sys_drawn(sys, 201, 0.0, 0.9, None), 0, "{}: a constant 0 bore none", sys.id);
let early = sys_at(sys, 201, 0.0, 0.7, Some(&host));
let q0 = early.iter().find(|q| q.0 == late[0].0).unwrap();
assert!(q0.1 != late[0].1 && late[0].2 > q0.2 + 0.19, "{}: {q0:?} -> {:?}", sys.id, late[0]);
assert!(sys_at(sys, 201, 0.0, 1.8, Some(&host)).is_empty(), "{}: gone after Longevity", sys.id);
}
}
/// Seeking matches a simulation from 0, and moving an earlier key (same current rate, same
/// cache key) isn't served from the old history's checkpoints.
#[test]
fn particle_systems_ii_and_mr_mercury_seek_and_drop_stale_checkpoints() {
let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst });
for sys in &SYSTEMS {
for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] {
let now = burst(t);
assert_eq!(sys_at(sys, 202, now, t, Some(&long)), sys_fresh(sys, 202, now, t, &long), "{} at {t}", sys.id);
}
let b = sys_at(sys, 202, 0.0, 0.9, Some(&short));
assert_eq!(b, sys_fresh(sys, 202, 0.0, 0.9, &short), "{}: the moved key", sys.id);
assert!(!b.is_empty() && b.len() < sys_at(sys, 202, 0.0, 0.9, Some(&long)).len(), "{}", sys.id);
}
}
/// A constant Birth Rate gives exactly the particles the constant-rate simulation did.
#[test]
fn particle_systems_ii_and_mr_mercury_are_unchanged_at_a_constant_rate() {
let host = Rate { index: 0, rate: |_| 2.0 };
for sys in &SYSTEMS {
let p = sys_params(sys.id, 2.0);
let ctx = sys_ctx(&p, 1.3, None, 203);
let before = simulate(&SimCache::new(1), 1, 1.3, &(sys.phys)(&ctx));
let before: Vec<_> = before.parts.iter().map(|q| (q.id, q.p, q.age)).collect();
assert!(!before.is_empty());
assert_eq!(sys_at(sys, 203, 2.0, 1.3, Some(&host)), before, "{}: keyed to a constant", sys.id);
assert_eq!(sys_at(sys, 203, 2.0, 1.3, None), before, "{}: no host", sys.id);
}
}
+13 -1
View File
@@ -1,6 +1,7 @@
use super::common::{h, hs};
use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster};
use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul};
use crate::util::layer_rect_at;
// ------------------------------------------------------------------ CC Rainfall / CC Snowfall
@@ -160,11 +161,21 @@ pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec<Bubble> {
.collect()
}
/// CC Bubbles' refraction sample bounds in the pixels of a buffer with `offset` and `scale`: the
/// layer rectangle's first and last pixel centres (x0, y0, x1, y1). Samples clamped to them repeat
/// the layer's edge pixels instead of reaching transparent padding around it (#2024).
pub fn bubble_sample_bounds(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> [f32; 4] {
let (lx, ly, lw, lh) = layer_rect_at(ctx, offset, scale);
let (x0, y0) = (lx + 0.5, ly + 0.5);
[x0 as f32, y0 as f32, (lx + lw - 0.5).max(x0) as f32, (ly + lh - 0.5).max(y0) as f32]
}
pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let metal = pr.e("reflectionType") == 1;
let shading = pr.e("shadingType");
let list = bubble_list(ctx, &b);
let [x0, y0, x1, y1] = bubble_sample_bounds(ctx, b.offset, b.scale);
let src = b.img.clone();
let mut out = Image::new(src.width, src.height);
raster(
@@ -182,7 +193,8 @@ pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror).
let k = if metal { 1.6 } else { -0.7 };
let nz = (1.0 - d2).max(0.0).sqrt();
let p = src.sample_bilinear((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64);
let (sx, sy) = ((q.sx + dx * q.r * k).max(x0).min(x1), (q.sy + dy * q.r * k).max(y0).min(y1));
let p = src.sample_bilinear_clamped(sx as f64, sy as f64);
let (c, sa) = unpremul(p);
let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c };
let mut a = cov;
+1 -1
View File
@@ -351,7 +351,7 @@ fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) {
let rr = (2.0 / 3.0 * y) / r;
// Cube rounding.
let (cx, cz) = (q, rr);
let cy = -cx - cz;
let cy = (-3f64.sqrt() / 3.0 * x - y / 3.0) / r;
let (mut rx, ry, mut rz) = (cx.round(), cy.round(), cz.round());
let (dx, dy, dz) = ((rx - cx).abs(), (ry - cy).abs(), (rz - cz).abs());
if dx > dy && dx > dz {
+37 -5
View File
@@ -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
}
@@ -534,14 +552,29 @@ impl<S: Clone> SimCache<S> {
/// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step
/// `i` to `i + 1`.
pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc<S> {
pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, step: impl FnMut(&mut S, u64)) -> std::sync::Arc<S> {
self.run_where(key, target, init, step, |_, _| true)
}
/// [`SimCache::run`] for a simulation whose steps also depend on inputs outside `key` (a
/// parameter's values over time): a checkpoint after `s` steps is only resumed from when
/// `valid(s, state)` says it was reached with the same inputs. Fresh checkpoints replace
/// those at the same step.
pub fn run_where(
&self,
key: u64,
target: u64,
init: impl FnOnce() -> S,
mut step: impl FnMut(&mut S, u64),
valid: impl Fn(u64, &S) -> bool,
) -> std::sync::Arc<S> {
use std::sync::Arc;
let start = {
let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1;
g.iter_mut().find(|e| e.key == key).and_then(|e| {
e.used = tick;
e.checkpoints.iter().filter(|(s, _)| *s <= target).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone()))
e.checkpoints.iter().filter(|(s, st)| *s <= target && valid(*s, st)).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone()))
})
};
let had_start = start.is_some();
@@ -579,9 +612,8 @@ impl<S: Clone> SimCache<S> {
let e = &mut g[idx];
e.used = tick;
for (s, st) in fresh {
if !e.checkpoints.iter().any(|(c, _)| *c == s) {
e.checkpoints.push((s, st));
}
e.checkpoints.retain(|(c, _)| *c != s);
e.checkpoints.push((s, st));
}
// Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin.
if e.checkpoints.len() > 256 {
+85 -10
View File
@@ -100,17 +100,11 @@ impl Cineon {
fn knee(&self) -> f32 {
1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99)
}
/// Normalised code value (code / 1023) → linear.
/// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma`
/// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings
/// also uses with `1 / gamma`).
pub fn log_to_lin(&self, v: f32) -> f32 {
let off = self.offset();
let code = v * 1023.0;
let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01));
let k = self.knee();
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k + w * (1.0 - (-(lin - k) / w).exp());
}
let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff);
self.ib + (self.iw - self.ib) * lin
}
/// Linear → normalised code value.
@@ -495,6 +489,87 @@ mod tests {
assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4);
}
/// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same
/// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times
/// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give
/// linear light (code 400 is 0.10251).
#[test]
fn cineon_converter_matches_interpret_footage() {
let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] {
let mut img = Image::new(codes.len() as u32, 1);
for (x, code) in codes.iter().enumerate() {
let v = code / 1023.0;
img.set(x as u32, 0, [v, v, v, 1.0]);
}
let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))];
let out = run("ec.utility.cineon", &vals, img);
let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() };
for (x, code) in codes.iter().enumerate() {
let footage = settings.convert(code / 1023.0);
let effect = out.img.data[x][0];
assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}");
}
}
let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() };
assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4);
}
/// The Cineon Converter gives the same values as before Cineon Settings shared its
/// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three
/// conversion types.
#[test]
fn cineon_converter_output_is_unchanged() {
fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
let off = 10f32.powf((black - white) * 0.002 / 0.6);
let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
lin = lin.max(0.0).powf(1.7 / gamma.max(0.01));
let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k + w * (1.0 - (-(lin - k) / w).exp());
}
lin
}
fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
let off = 10f32.powf((black - white) * 0.002 / 0.6);
let mut lin = v;
let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln();
}
lin = lin.max(0.0).powf(gamma.max(0.01) / 1.7);
(white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0
}
let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
for gamma in [None, Some(2.4)] {
for kind in 0..3 {
let mut img = Image::new(codes.len() as u32, 1);
for (x, code) in codes.iter().enumerate() {
let v = code / 1023.0;
img.set(x as u32, 0, [v, v, v, 1.0]);
}
let mut vals = vec![("conversionType", Value::Enum(kind))];
if let Some(g) = gamma {
vals.push(("gamma", num(g)));
}
let out = run("ec.utility.cineon", &vals, img);
let g = gamma.unwrap_or(1.7) as f32;
for (x, code) in codes.iter().enumerate() {
let v = code / 1023.0;
let want = match kind {
0 => lin_to_log(v, g, 20.0, 95.0, 685.0),
1 => log_to_lin(v, g, 20.0, 95.0, 685.0),
_ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0),
};
let got = out.img.data[x][0];
assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}");
}
}
}
}
#[test]
fn hdr_compression_behaviour() {
let mut img = Image::new(2, 1);
+72 -24
View File
@@ -152,15 +152,6 @@ pub fn settings(params: &Params, fps: f64) -> StabSettings {
}
}
fn fnv(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
struct KeyHasher(u64);
impl Hasher for KeyHasher {
fn finish(&self) -> u64 {
@@ -183,24 +174,12 @@ fn settings_key(s: &StabSettings) -> u64 {
h.finish()
}
type AnalysisCache = Mutex<HashMap<u64, Arc<WarpAnalysis>>>;
type PlanCache = Mutex<HashMap<(u64, u64), Arc<Plan>>>;
/// Parsed analyses by content hash (parsing a long clip's JSON every frame would dominate).
/// Parsed analyses and their content keys; cache hits compare source text without rehashing.
fn analysis_cached(json: &str) -> Option<(u64, Arc<WarpAnalysis>)> {
static C: OnceLock<AnalysisCache> = OnceLock::new();
let key = fnv(json.as_bytes());
let c = C.get_or_init(Default::default);
if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some((key, a.clone()));
}
let a = Arc::new(WarpAnalysis::from_json(json)?);
let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 8 {
m.clear();
}
m.insert(key, a.clone());
Some((key, a))
static C: crate::analysis_cache::AnalysisCache<WarpAnalysis> = crate::analysis_cache::AnalysisCache::new();
C.get(json, WarpAnalysis::from_json)
}
fn plans() -> &'static PlanCache {
@@ -223,6 +202,19 @@ fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc<Plan> {
p
}
/// Validate and cache the default stabilization plan after analysis. Use the serialized
/// analysis so the cached plan has exactly the same inputs as the first render, including
/// any floating-point round trip through JSON. Other settings still get their own plan.
pub fn prepare_default_plan(json: &str) -> bool {
let Some((key, a)) = analysis_cached(json) else { return false };
if a.is_empty() {
return false;
}
let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 };
let p = plan_cached(key, &a, &StabSettings { fps, ..Default::default() });
p.warps.len() == a.frames.len() && p.warps.iter().all(|h| h.0.iter().flatten().all(|v| v.is_finite()))
}
/// The stored analysis of an instance (`None` when not analysed).
pub fn analysis(params: &Params) -> Option<Arc<WarpAnalysis>> {
let j = s(params, ANALYSIS);
@@ -531,6 +523,7 @@ fn dot(img: &mut Image, c: [f64; 2], r: f64, col: Px) {
mod tests {
use super::*;
use crate::EffectEnv;
use crate::analysis_cache::fnv;
use effectcraft_track::stabilize::FrameMotion;
fn analysis_json(n: usize, w: f64, h: f64, shift: impl Fn(usize) -> [f64; 2]) -> String {
@@ -601,6 +594,61 @@ mod tests {
assert_eq!(render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }).img.data, img.data);
}
#[test]
fn prepared_default_plan_preserves_first_render_and_is_reused() {
let json = analysis_json(25, 64.0, 48.0, |k| [((k * 7) % 5) as f64 - 2.0, k as f64 * 0.1]);
let mut a = WarpAnalysis::from_json(&json).unwrap();
a.start = 0.012345678901234567;
a.tracks = (0..24)
.map(|id| effectcraft_track::camtrack::Track2D {
id,
start: 0,
pts: (0..25).map(|k| [8.0 + (id % 6) as f32 * 8.0 + ((k * 7) % 5) as f32, 8.0 + (id / 6) as f32 * 8.0 + k as f32 * 0.1]).collect(),
})
.collect();
let json = a.to_json();
let ps = params(&json, 2, 0);
let ctx = EffectCtx { params: &ps, time: a.start, layer_size: a.size, seed: 1, adjustment: false, env: EffectEnv::default() };
let input = Buf { img: frame([30.0, 22.0]), offset: [0.0; 2], scale: 1.0 };
// Original flow: validate, discard the plan, then render from the stored analysis.
let _discarded = plan(&a, &StabSettings { fps: 25.0, ..Default::default() });
let before = render(&ctx, input.clone());
let key = (fnv(json.as_bytes()), settings_key(&settings(&ps, 25.0)));
plans().lock().unwrap().remove(&key);
assert!(prepare_default_plan(&json));
let prepared = plans().lock().unwrap().get(&key).unwrap().clone();
assert!(!prepared.meshes.is_empty(), "exercise the Subspace Warp plan");
let after = render(&ctx, input);
let (_, _, rendered) = plan_at(&ps, a.start).unwrap();
assert!(Arc::ptr_eq(&prepared, &rendered));
assert_eq!(before.offset, after.offset);
assert_eq!(before.scale.to_bits(), after.scale.to_bits());
for (a, b) in before.img.data.iter().flatten().zip(after.img.data.iter().flatten()) {
assert_eq!(a.to_bits(), b.to_bits());
}
assert!(!prepare_default_plan("invalid"));
assert!(!prepare_default_plan(&WarpAnalysis::default().to_json()));
}
#[test]
fn changing_analysis_changes_the_plan_key() {
let json = analysis_json(10, 64.0, 48.0, |k| [k as f64 * 0.1, 0.0]);
let mut ps = params(&json, 2, 0);
let before = summary_key(&ps).unwrap();
assert_eq!(before[0], fnv(json.as_bytes()));
let mut a = WarpAnalysis::from_json(&json).unwrap();
a.start = 1.0;
ps.values.insert(ANALYSIS.into(), Value::Str(a.to_json()));
let after = summary_key(&ps).unwrap();
assert_ne!(before[0], after[0]);
assert_eq!(before[1], after[1]);
assert_eq!(*analysis(&ps).unwrap(), a);
ps.values.insert(ANALYSIS.into(), Value::Str(json));
assert_eq!(summary_key(&ps).unwrap(), before);
ps.values.insert(ANALYSIS.into(), Value::Str(String::new()));
assert!(analysis(&ps).is_none());
}
#[test]
fn nested_param_lookup() {
let mut p = Params::default();
+20 -20
View File
@@ -433,26 +433,26 @@ const GOLDEN: &[(&str, u64)] = &[
("ec.sim.ccstarburst/1/32bpc/t1.5", 0x5c39f8b1bd0e9e43),
("ec.sim.ccstarburst/1/32bpc/t0.8", 0xdf20aa9dcf4b3e22),
("ec.sim.ccstarburst/1/32bpc/t2.2", 0x4fa441737bfcb64d),
("ec.sim.ccbubbles/0/8bpc/t0.5", 0xdcd187e75eeb2fae),
("ec.sim.ccbubbles/0/8bpc/t1.5", 0x835ca088747736ed),
("ec.sim.ccbubbles/0/8bpc/t0.8", 0x5cbb1a6fbb77b41d),
("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3b168068f5bfa533),
("ec.sim.ccbubbles/0/32bpc/t0.5", 0x524b84d14edd77e2),
("ec.sim.ccbubbles/0/32bpc/t1.5", 0xfd7a17f0d40f2a27),
("ec.sim.ccbubbles/0/32bpc/t0.8", 0x70dd2b2b4ff0f7ce),
("ec.sim.ccbubbles/0/32bpc/t2.2", 0xae21dd5cc2c2c07c),
("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x931b4ca707604220),
("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x21c25d4555abebe9),
("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0x7e05972683359632),
("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0xadc4f7219dab4921),
("ec.sim.ccbubbles/1/8bpc/t0.5", 0x89d27a2fe2011067),
("ec.sim.ccbubbles/1/8bpc/t1.5", 0x2bbfee897bf5bf6e),
("ec.sim.ccbubbles/1/8bpc/t0.8", 0x26cc704e7b06fffc),
("ec.sim.ccbubbles/1/8bpc/t2.2", 0x959d331545d96479),
("ec.sim.ccbubbles/1/32bpc/t0.5", 0xaf486cebfcbf4641),
("ec.sim.ccbubbles/1/32bpc/t1.5", 0xe150e9fdb558036f),
("ec.sim.ccbubbles/1/32bpc/t0.8", 0x6395f3967cb24284),
("ec.sim.ccbubbles/1/32bpc/t2.2", 0x68a55894d86ffb9e),
("ec.sim.ccbubbles/0/8bpc/t0.5", 0x6cb63df0c062fdf4),
("ec.sim.ccbubbles/0/8bpc/t1.5", 0x5bb1350114234ac3),
("ec.sim.ccbubbles/0/8bpc/t0.8", 0xaa824d49f12c4156),
("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3528577358c4fc90),
("ec.sim.ccbubbles/0/32bpc/t0.5", 0xdcd8e71222c13a9a),
("ec.sim.ccbubbles/0/32bpc/t1.5", 0xd09565e124d8f5db),
("ec.sim.ccbubbles/0/32bpc/t0.8", 0x91242f8e1fd0ad84),
("ec.sim.ccbubbles/0/32bpc/t2.2", 0x1f9782131e62149d),
("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x020755e48bdf8f43),
("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x4aac0f1332e6001e),
("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0xdc2ab5e04c4a95a2),
("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0x30f78adc5d1dd715),
("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980),
("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0),
("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c),
("ec.sim.ccbubbles/1/8bpc/t2.2", 0xd365c88c12219802),
("ec.sim.ccbubbles/1/32bpc/t0.5", 0x50f20497f40bb46d),
("ec.sim.ccbubbles/1/32bpc/t1.5", 0x74760599ffba220a),
("ec.sim.ccbubbles/1/32bpc/t0.8", 0x028de52900ed8618),
("ec.sim.ccbubbles/1/32bpc/t2.2", 0xaf0e21d9a7f625fe),
("ec.sim.ccdrizzle/0/8bpc/t0.5", 0x2bdfbc17d92b65b5),
("ec.sim.ccdrizzle/0/8bpc/t1.5", 0x8c354c46f99e35d3),
("ec.sim.ccdrizzle/0/8bpc/t0.8", 0x891f331ba47ee557),
+248
View File
@@ -0,0 +1,248 @@
{
"format": "effectcraft.animationPresets/1",
"notes": "Original EffectCraft behavior presets (MIT OR Apache-2.0), written from scratch. Each one drives a transform property or opacity with a time-based expression built on the property's own value, so the motion runs for the layer's whole duration and still follows the property's keyframes. Apply to any layer; no effects are added.",
"presets": [
{
"id": "wiggleSoft",
"name": "Soft Wiggle",
"category": "Behaviors",
"description": "The layer wanders gently around its position.",
"layer": {
"expr": {
"transform/position": "var w = wiggle(1.2, 30); [w[0], w[1]]"
}
}
},
{
"id": "shake",
"name": "Shake",
"category": "Behaviors",
"description": "A fast, tight shake of the layer's position.",
"layer": {
"expr": {
"transform/position": "var w = wiggle(14, 12); [w[0], w[1]]"
}
}
},
{
"id": "floatUpDown",
"name": "Float",
"category": "Behaviors",
"description": "The layer bobs up and down like it floats on water.",
"layer": {
"expr": {
"transform/position": "[value[0]+(0), value[1]+(Math.sin(time*2)*18)]"
}
}
},
{
"id": "sway",
"name": "Sway",
"category": "Behaviors",
"description": "The layer sways left and right.",
"layer": {
"expr": {
"transform/position": "[value[0]+(Math.sin(time*1.6)*40), value[1]+(0)]"
}
}
},
{
"id": "orbit",
"name": "Orbit",
"category": "Behaviors",
"description": "The layer travels round a circle around its position.",
"layer": {
"expr": {
"transform/position": "[value[0]+(Math.cos(time*2)*80), value[1]+(Math.sin(time*2)*80)]"
}
}
},
{
"id": "figureEight",
"name": "Figure Eight",
"category": "Behaviors",
"description": "The layer traces a figure eight around its position.",
"layer": {
"expr": {
"transform/position": "[value[0]+(Math.sin(time*1.5)*100), value[1]+(Math.sin(time*3)*45)]"
}
}
},
{
"id": "bouncingBall",
"name": "Bouncing Ball",
"category": "Behaviors",
"description": "The layer bounces up from its position again and again.",
"layer": {
"expr": {
"transform/position": "[value[0]+(0), value[1]+(-Math.abs(Math.sin(time*3.2))*140)]"
}
}
},
{
"id": "driftRight",
"name": "Drift Right",
"category": "Behaviors",
"description": "The layer drifts steadily to the right.",
"layer": {
"expr": {
"transform/position": "[value[0]+(time*40), value[1]+(0)]"
}
}
},
{
"id": "driftDiagonal",
"name": "Drift Diagonal",
"category": "Behaviors",
"description": "The layer drifts slowly down and to the right.",
"layer": {
"expr": {
"transform/position": "[value[0]+(time*30), value[1]+(time*18)]"
}
}
},
{
"id": "pendulum",
"name": "Pendulum",
"category": "Behaviors",
"description": "The layer swings like a pendulum.",
"layer": {
"expr": {
"transform/rotation": "value + Math.sin(time*2.4)*25"
}
}
},
{
"id": "wobble",
"name": "Wobble",
"category": "Behaviors",
"description": "The layer rotates with an irregular wobble.",
"layer": {
"expr": {
"transform/rotation": "wiggle(2, 10)"
}
}
},
{
"id": "spinSlow",
"name": "Spin Slow",
"category": "Behaviors",
"description": "The layer rotates once every ten seconds.",
"layer": {
"expr": {
"transform/rotation": "value + time*36"
}
}
},
{
"id": "spinFast",
"name": "Spin Fast",
"category": "Behaviors",
"description": "The layer rotates twice a second.",
"layer": {
"expr": {
"transform/rotation": "value + time*720"
}
}
},
{
"id": "pulse",
"name": "Pulse",
"category": "Behaviors",
"description": "The layer swells and shrinks smoothly.",
"layer": {
"expr": {
"transform/scale": "[value[0]*(1+0.12*Math.sin(time*4)), value[1]*(1+0.12*Math.sin(time*4))]"
}
}
},
{
"id": "heartbeat",
"name": "Heartbeat",
"category": "Behaviors",
"description": "Two quick beats, then a rest, repeating every second.",
"layer": {
"expr": {
"transform/scale": "var t = time % 1; var b = t < 0.3 ? Math.sin(t/0.3*Math.PI) : (t < 0.6 ? 0.7*Math.sin((t-0.3)/0.3*Math.PI) : 0); var k = 1 + 0.2*b; [value[0]*k, value[1]*k]"
}
}
},
{
"id": "breathe",
"name": "Breathe",
"category": "Behaviors",
"description": "The layer slowly grows and settles back.",
"layer": {
"expr": {
"transform/scale": "[value[0]*(1+0.06*Math.sin(time*1.1)), value[1]*(1+0.06*Math.sin(time*1.1))]"
}
}
},
{
"id": "rubberBand",
"name": "Rubber Band",
"category": "Behaviors",
"description": "Width and height stretch against each other.",
"layer": {
"expr": {
"transform/scale": "[value[0]*(1+0.15*Math.sin(time*5)), value[1]*(1-0.15*Math.sin(time*5))]"
}
}
},
{
"id": "jitterScale",
"name": "Jitter Scale",
"category": "Behaviors",
"description": "The layer's size jitters randomly.",
"layer": {
"expr": {
"transform/scale": "var w = wiggle(8, 6); [w[0], w[1]]"
}
}
},
{
"id": "blink",
"name": "Blink",
"category": "Behaviors",
"description": "The layer switches on and off twice a second.",
"layer": {
"expr": {
"transform/opacity": "(Math.floor(time*4) % 2 === 0) ? value : 0"
}
}
},
{
"id": "strobe",
"name": "Strobe",
"category": "Behaviors",
"description": "The layer flashes on for a few frames each beat.",
"layer": {
"expr": {
"transform/opacity": "((time*6) % 1 < 0.25) ? value : 0"
}
}
},
{
"id": "flicker",
"name": "Flicker",
"category": "Behaviors",
"description": "The layer's opacity flickers like a failing lamp.",
"layer": {
"expr": {
"transform/opacity": "var w = wiggle(14, 45); Math.max(0, Math.min(100, w))"
}
}
},
{
"id": "fadePulse",
"name": "Fade Pulse",
"category": "Behaviors",
"description": "Opacity breathes between full and a third.",
"layer": {
"expr": {
"transform/opacity": "value*(0.67+0.33*Math.sin(time*2.5))"
}
}
}
]
}
@@ -0,0 +1,7 @@
asset: behaviors.json
title: EffectCraft Behaviors animation presets (Soft Wiggle, Shake, Float, Sway, Orbit, Figure Eight, Bouncing Ball, Drift Right, Drift Diagonal, Pendulum, Wobble, Spin Slow, Spin Fast, Pulse, Heartbeat, Breathe, Rubber Band, Jitter Scale, Blink, Strobe, Flicker, Fade Pulse)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-15 by vangel76 (Claude Code agent, gap 7, #1510)
notes: Expressions and values written from scratch for EffectCraft on its own transform properties and expression engine; not derived from any Adobe preset (.ffx) or its values.
+1 -1
View File
@@ -77,7 +77,7 @@
"name": "Tracking In",
"properties": { "tracking": 0, "opacity": 100 },
"keys": {
"tracking": [[0, 400], [1.6, 0]],
"tracking": [[0, 40], [1.6, 0]],
"opacity": [[0, 0], [0.8, 100]]
},
"ease": true,
+4 -3
View File
@@ -197,7 +197,8 @@ pub(crate) struct Work {
}
/// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels).
/// Footage at its native size is used straight from the footage source (no copy).
/// Footage at its native size is used straight from the footage source (no copy unless its
/// Cineon Settings convert it).
pub(crate) fn source_frame(
r: &Renderer,
project: &Project,
@@ -207,7 +208,7 @@ pub(crate) fn source_frame(
t: Tick,
expr: Option<&dyn ExprHost>,
) -> Option<(Arc<Image>, [f64; 2])> {
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None };
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] };
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
&& f.has_video
@@ -215,7 +216,7 @@ pub(crate) fn source_frame(
&& img.width == f.width
&& img.height == f.height
{
return Some((img, [0.0; 2]));
return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2]));
}
let buf = r.layer_source(&ctx, layer)?;
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
+4 -5
View File
@@ -4,7 +4,7 @@
//! the Warp Stabilizer, `Renderer::layer_input`) for every frame between the layer's In and Out
//! points on a background thread, and feeds the frames to `effectcraft_track::stabilize`'s
//! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the
//! stabilization plan once to validate the result. When the job finishes, the analysis is
//! stabilization plan once to validate and cache the result. When the job finishes, the analysis is
//! written into the effect's hidden Analysis parameter as one undo step, together with a key of
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that
//! change those clear the analysis (`invalidate`, run after every edit) and queue the effect
@@ -21,7 +21,7 @@ use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSour
use effectcraft_raster::Image;
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
use effectcraft_time::Tick;
use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, StabSettings, WarpAnalysis};
use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis};
use serde::Serialize;
use crate::tracking::lock;
@@ -245,9 +245,8 @@ async fn run_work(w: WarpWork, shared: &WarpShared) {
let lt0 = layer.layer_time(w.times[0]).seconds();
let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() };
let a = an.finish(lt0, fd);
// Step 2: derive a plan once (validates the motion and warms nothing else up).
let plan = effectcraft_track::stabilize::plan(&a, &StabSettings { fps: if fd > 0.0 { 1.0 / fd } else { 30.0 }, ..Default::default() });
if plan.warps.len() != a.frames.len() || plan.warps.iter().any(|h| h.0.iter().flatten().any(|v| !v.is_finite())) {
// Plan from the stored JSON, exactly as rendering does, and retain it for the first render.
if !warp_stab::prepare_default_plan(&a.to_json()) {
return fail("the stabilization could not be computed");
}
*lock(&shared.result) = Some(a);
+1
View File
@@ -10,6 +10,7 @@ mod animation;
pub(crate) mod app_more;
mod autotrace;
mod batch;
mod blender;
mod camera_cmds;
mod comp;
pub(crate) mod comp_more;
+1
View File
@@ -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))
+7 -2
View File
@@ -1,6 +1,7 @@
//! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the
//! Transitions category (original presets built on our Transition effects and layer opacity) and
//! the Backgrounds category (generators, noise and colour effects driven by expressions).
//! the Backgrounds category (generators, noise and colour effects driven by expressions) and the
//! Behaviors category (transform and opacity driven by expressions, no effects).
//!
//! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting
//! at the current time, on every selected layer. The library is data
@@ -26,13 +27,17 @@ pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json"
/// The original background presets (`presets/backgrounds.json`).
pub const BACKGROUNDS_JSON: &str = include_str!("../../presets/backgrounds.json");
/// The original behavior presets (`presets/behaviors.json`): expression-driven motion on a layer's
/// transform and opacity, no effects.
pub const BEHAVIORS_JSON: &str = include_str!("../../presets/behaviors.json");
/// Keys per animated property, so a hostile inline preset can't allocate without bound.
const MAX_KEYS: usize = 256;
/// Longest expression text an inline preset may carry.
const MAX_EXPR: usize = 4096;
fn presets() -> Vec<Value> {
[TRANSITIONS_JSON, BACKGROUNDS_JSON]
[TRANSITIONS_JSON, BACKGROUNDS_JSON, BEHAVIORS_JSON]
.iter()
.flat_map(|src| serde_json::from_str::<Value>(src).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default())
.collect()
+64 -5
View File
@@ -10,11 +10,16 @@ use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project,
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::effect::{effect_time_error, retime_effect};
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, resolve_effect_path, selected_leaf_props, str_p};
use crate::{EngineError, KeyRef, Result, Session, cmd};
/// Preset file format version tag (`*.ecpreset`, JSON).
pub const PRESET_TAG: &str = "effectcraftAnimationPreset";
/// The preset format version that `anim.savePreset` writes.
pub const PRESET_VERSION: u64 = 2;
/// The first preset version that stores effect keys relative to the earliest key (#1971).
const RELATIVE_EFFECT_KEYS: u64 = 2;
fn has_selected_props(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
@@ -45,13 +50,26 @@ fn selected_effects(s: &Session) -> Vec<(LayerId, PropGroup)> {
.collect()
}
/// The earliest key anywhere in `groups`, nested controls included.
fn earliest_key<'a>(groups: impl IntoIterator<Item = &'a PropGroup>) -> Option<Tick> {
let mut first: Option<Tick> = None;
for g in groups {
g.walk("", &mut |_, pr| {
if let Some(t) = pr.keys.iter().map(|k| k.time).min() {
first = Some(first.map_or(t, |f| f.min(t)));
}
});
}
first
}
// ---------------------------------------------------------------- presets
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("anim.savePreset", "missing `path`"))?.to_string();
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let t = s.time();
let effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
let mut effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
let effect_uids: Vec<Uid> = effects.iter().map(|(_, g)| g.uid).collect();
let mut props = vec![];
for (lid, uid) in selected_leaf_props(s) {
@@ -67,7 +85,11 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("anim.savePreset", "select properties or effects to save"));
}
// Key times are stored relative to the earliest key so the preset applies at the CTI.
let first = props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).min().unwrap_or(Tick::ZERO);
let first =
props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).chain(earliest_key(effects.iter().map(|(_, g)| g))).min().unwrap_or(Tick::ZERO);
for (_, g) in &mut effects {
retime_effect(g, 1.0, "anim.savePreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.savePreset"))?)))?;
}
let props_json: Vec<Value> = props
.iter()
.map(|(_, mp, pr)| {
@@ -82,12 +104,27 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name")
.map(str::to_string)
.unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default());
let doc = json!({PRESET_TAG: 1, "name": name, "properties": props_json, "effects": fx_json});
let doc = json!({PRESET_TAG: PRESET_VERSION, "name": name, "properties": props_json, "effects": fx_json});
let text = serde_json::to_string_pretty(&doc).unwrap_or_default();
s.services.write_file(&path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()}))
}
/// Rewrite an effect property path in a preset (`effects/<effect id>[#n]/<param>`) to the current
/// parameter id when it names a parameter that the effect has since renamed or moved into a
/// twirl-down group. Any other path comes back unchanged.
fn upgrade_effect_path(path: &str) -> String {
let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() };
let effect = match seg.rsplit_once('#') {
Some((m, n)) if !m.is_empty() && n.parse::<usize>().is_ok() => m,
_ => seg,
};
match effectcraft_effects::find(effect).and_then(|spec| effectcraft_effects::migrate::current_param_id(spec, tail)) {
Some(id) if id != tail => format!("effects/{seg}/{id}"),
_ => path.to_string(),
}
}
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
let doc: Value = match (p.get("preset"), str_p(p, "path")) {
(Some(v), _) => v.clone(),
@@ -110,14 +147,22 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
s.save_prefs();
s.prefs_revision += 1;
}
let effects: Vec<PropGroup> =
let mut effects: Vec<PropGroup> =
doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default();
// Older presets stored effect keys at the source layer's times.
if doc[PRESET_TAG].as_u64().unwrap_or(1) < RELATIVE_EFFECT_KEYS
&& let Some(first) = earliest_key(&effects)
{
for g in &mut effects {
retime_effect(g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
}
}
let props: Vec<(String, Property)> = doc["properties"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| {
let path = v["path"].as_str()?.to_string();
let path = upgrade_effect_path(v["path"].as_str()?);
let value: KV = serde_json::from_value(v["value"].clone()).ok()?;
let keys: Vec<Keyframe> = serde_json::from_value(v["keys"].clone()).unwrap_or_default();
let expr = serde_json::from_value(v["expression"].clone()).ok().flatten();
@@ -130,16 +175,30 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
})
.unwrap_or_default();
let t = s.time();
// Effect bounds per layer size the point defaults of parameters added since the preset was saved.
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let sizes: BTreeMap<LayerId, [f64; 2]> =
ids.iter().filter_map(|lid| comp.layer(*lid).map(|l| (*lid, effectcraft_render::effect_bounds(&s.project, comp, l).0))).collect();
let mut applied = 0;
let mut skipped = vec![];
s.edit("Apply Animation Preset", None, |proj, _| {
let mut next = proj.next_id;
for lid in &ids {
let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]);
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
if let Some(fx) = l.props.sub_mut("effects") {
for g in &effects {
let mut g = g.clone();
// Effects saved by an earlier version get the upgrade a project gets on open.
let spec = match &g.kind {
GroupKind::Effect { effect } => effectcraft_effects::find(effect),
_ => None,
};
if let Some(spec) = spec {
effectcraft_effects::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), size);
}
retime_effect(&mut g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_add(lt.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
g.reassign_uids(&mut next);
next += 1;
fx.children.push(Node::Group(g));
+2 -2
View File
@@ -152,7 +152,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?;
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view(), false)?).map_err(EngineError::Other)?;
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved an XML copy to {path}"));
Ok(json!({"path": path, "bytes": xml.len()}))
@@ -240,7 +240,7 @@ fn replace_with_precomp(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<ItemId> = proj.items.keys().copied().filter(|c| *c != comp).collect();
for cid in ids {
if let Some(c) = proj.comp_mut(cid) {
for l in c.layers.iter_mut().filter(|l| is_user(l)) {
for l in c.layers.iter_mut_where(|l| is_user(l)) {
l.source = LayerSource::Comp { item: comp };
n += 1;
}
+4 -3
View File
@@ -9,8 +9,9 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::{EngineError, Result, Session};
/// Commands that can't run inside a batch (they replace the project or nest batches).
const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"];
/// Commands that can't run inside a batch (they nest batches), besides the commands that run
/// scripts ([`super::scripts::RUNS_SCRIPTS`]).
const NOT_IN_BATCH: &[&str] = &["engine.batch"];
/// Resolve a `$N` / `$N.key.0.key` reference against the results so far.
fn reference(s: &str, results: &[Value]) -> Option<Result<Value>> {
@@ -78,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
.or_else(|| step.get("id"))
.and_then(Value::as_str)
.ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?;
if NOT_IN_BATCH.contains(&id) {
if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) {
return Err(bad("engine.batch", format!("`{id}` can't run inside a batch")));
}
let params = substitute(step.get("params").unwrap_or(&json!({})), results)?;
+295
View File
@@ -0,0 +1,295 @@
//! Camera-solve interchange through Blender's public Python API. The generated script is
//! original MIT OR Apache-2.0 code; no Blender implementation is read or embedded.
use std::path::Path;
use effectcraft_effects::camera_tracker as ct;
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Uid};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
use effectcraft_track::camtrack::{CameraSolve, linalg};
use serde_json::{Value, json};
mod validate;
use super::{b_p, bad, f_p, str_p};
use crate::{EngineError, Result, Session};
const CMD: &str = "camera.exportBlender";
const MAX_FRAMES: usize = 100_000;
const MAX_POINTS: usize = 100_000;
const MAX_JSON: usize = 64 * 1024 * 1024;
const MAX_FRAME: i64 = 1_048_574;
fn number(p: &Value, key: &str, default: f64, min: f64, max: f64) -> Result<f64> {
let v = match p.get(key) {
None => default,
Some(_) => f_p(p, key).ok_or_else(|| bad(CMD, format!("{key}: expected a number")))?,
};
if !v.is_finite() || !(min..=max).contains(&v) {
return Err(bad(CMD, format!("{key}: expected {min}..{max}")));
}
Ok(v)
}
fn finite(values: impl IntoIterator<Item = f64>) -> Result<()> {
if values.into_iter().any(|v| !v.is_finite() || v.abs() > 1e12) {
return Err(bad(CMD, "invalid or excessive numeric camera/scene data; re-analyze the tracker"));
}
Ok(())
}
/// Right-handed EffectCraft world (right, down, forward) → Blender (right, forward, up).
fn position(p: [f64; 3], scale: f64) -> [f64; 3] {
[p[0] * scale, p[2] * scale, -p[1] * scale]
}
fn media_path(s: &Session, path: &str) -> Result<String> {
if path.is_empty() || path.contains('\0') {
return Err(bad(CMD, "the source footage has no valid file path; relink it before exporting"));
}
let path = Path::new(path);
if path.is_absolute() || cfg!(target_arch = "wasm32") {
return Ok(path.to_string_lossy().into_owned());
}
let cwd = std::env::current_dir().map_err(|e| bad(CMD, format!("cannot resolve footage path: {e}")))?;
let base = s.path.as_deref().and_then(|p| Path::new(p).parent()).map(|p| cwd.join(p)).unwrap_or(cwd);
Ok(base.join(path).to_string_lossy().into_owned())
}
pub(super) fn export(s: &mut Session, p: &Value, cid: ItemId, lid: LayerId, uid: Uid) -> Result<Value> {
if s.running_for_script() && !s.prefs.scripting.allow_scripts_write_files {
return Err(bad(CMD, "scripts can't export files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on"));
}
let options = Options::parse(p)?;
let Options { path, sensor, units, start, include_points } = options;
if s.camera_target().is_some_and(|(c, l, u)| (c, l, u) == (cid, lid, uid)) && s.is_camera_analyzing() || s.camera_pending.contains(&(cid, lid, uid)) {
return Err(bad(CMD, "wait for this tracker's analysis to finish before exporting"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or_else(|| bad(CMD, "tracked layer missing"))?;
let g = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "tracker missing"))?;
for name in [ct::SOLVE, ct::TRACKS] {
if g.prop(name).is_some_and(|p| matches!(&p.value, effectcraft_keyframe::Value::Str(v) if v.len() > MAX_JSON)) {
return Err(bad(CMD, "tracking data exceeds the 64 MiB export limit"));
}
}
let params = crate::camera_track::static_params(g);
let solve = ct::solve(&params).ok_or_else(|| bad(CMD, "the camera is not solved yet; analyze the tracker first"))?;
let (lo, end) = validate::timing(comp, layer, &solve, start)?;
let Source { footage, path: source_path, fps: source_fps, count: source_count } = source(s, layer, &solve, path)?;
validate::geometry(&solve, sensor)?;
let camera = params
.get(ct::CAMERA)
.map(|v| v.as_f64())
.filter(|v| v.is_finite() && *v > 0.0 && *v <= u64::MAX as f64)
.and_then(|id| comp.layer(LayerId(id as u64)))
.filter(|l| l.is_camera());
let warnings = scene_warnings(camera, &solve, footage);
let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, lo) };
let (frames, world) = bake_frames(&ctx, layer, &solve, camera, &options, footage, source_count)?;
let mut points = Vec::new();
for point in &solve.points {
finite(point.pos.into_iter().chain([point.error as f64]))?;
if point.first > point.last || point.last as usize >= solve.frames.len() {
return Err(bad(CMD, "invalid solved point frame range; re-analyze the tracker"));
}
let location = position(world.apply(point.pos), units);
finite(location)?;
if include_points {
points.push(json!({"id": point.id, "location": location, "error": point.error,
"first": start.saturating_add(point.first as i64), "last": start.saturating_add(point.last as i64)}));
}
}
let tracks = ct::tracks(&params);
if tracks.as_ref().is_some_and(|t| t.tracks.len() > MAX_POINTS || t.tracks.iter().any(|t| t.pts.len() > MAX_FRAMES)) {
return Err(bad(CMD, "2D tracks exceed export limits"));
}
let markers: Vec<Value> = comp
.markers
.iter()
.filter_map(|m| {
let frame = start.saturating_add(comp.frame_rate.frame_at(m.time).saturating_sub(comp.frame_rate.frame_at(lo)));
if (start..=end).contains(&frame) { Some(json!({"name": m.comment, "frame": frame})) } else { None }
})
.collect();
let payload = json!({"version": 1, "name": format!("{} — {}", s.project.item(cid).map(|i| i.name.as_str()).unwrap_or("Shot"), layer.name),
"resolution": solve.size, "fpsNumerator": comp.frame_rate.num, "fpsDenominator": comp.frame_rate.den,
"startFrame": start, "endFrame": end, "sensorWidth": sensor, "unitScale": units,
"media": {"path": source_path, "kind": if footage.kind == FootageKind::Still {"FILE"} else {"MOVIE"},
"fps": source_fps, "nativeRate": footage.native_rate, "pixelAspect": footage.pixel_aspect, "timecode": footage.start_timecode,
"frameCount": source_count, "loopCount": footage.loop_count},
"frames": frames, "points": points, "markers": markers, "ground": solve.ground.is_some(),
"groundSize": world.s * units, "shutter": comp.shutter_angle / 360.0, "shutterPhase": comp.shutter_phase,
"solve": solve, "tracks": if include_points { tracks.as_deref() } else { None },
"warnings": warnings});
let data = serde_json::to_string(&payload).map_err(|e| bad(CMD, format!("cannot serialize scene: {e}")))?;
if data.len() > MAX_JSON {
return Err(bad(CMD, "scene exceeds the 64 MiB script limit; disable includePoints or split the shot"));
}
let quoted = serde_json::to_string(&data).map_err(|e| bad(CMD, format!("cannot encode scene: {e}")))?;
let script = include_str!("blender_scene.py").replace("__EFFECTCRAFT_DATA__", &quoted);
s.services.write_file(path, script.as_bytes()).map_err(|e| bad(CMD, format!("cannot write {path}: {e}")))?;
Ok(json!({"path": path, "bytes": script.len(), "frames": frames.len(), "points": points.len(), "warnings": warnings}))
}
struct Options<'a> {
path: &'a str,
sensor: f64,
units: f64,
start: i64,
include_points: bool,
}
impl<'a> Options<'a> {
fn parse(p: &'a Value) -> Result<Self> {
let path = str_p(p, "path").filter(|p| !p.is_empty() && !p.contains('\0')).ok_or_else(|| bad(CMD, "missing `path` (.py)"))?;
if !Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("py")) {
return Err(bad(CMD, "the export path must end in .py"));
}
let sensor = number(p, "sensorWidth", 36.0, 1.0, 1000.0)?;
let units = number(p, "unitScale", 0.01, 0.000_001, 1000.0)?;
let start = match p.get("startFrame") {
None => 1,
Some(v) => v.as_i64().filter(|n| (1..=MAX_FRAME).contains(n)).ok_or_else(|| bad(CMD, "startFrame: expected a positive Blender frame number"))?,
};
let include_points = match p.get("includePoints") {
Some(v) if !v.is_boolean() => return Err(bad(CMD, "includePoints: expected a boolean")),
_ => b_p(p, "includePoints").unwrap_or(true),
};
Ok(Self { path, sensor, units, start, include_points })
}
}
struct Source<'a> {
footage: &'a Footage,
path: String,
fps: f64,
count: i64,
}
fn source<'a>(s: &'a Session, layer: &Layer, solve: &CameraSolve, path: &str) -> Result<Source<'a>> {
let footage = match layer.source {
LayerSource::Footage { item } => s.project.item(item).and_then(|i| match &i.kind {
ItemKind::Footage(f) => Some(f),
_ => None,
}),
_ => None,
}
.filter(|f| f.has_video && matches!(f.kind, FootageKind::Video | FootageKind::Still));
let footage = footage
.ok_or_else(|| bad(CMD, "Blender background export needs a video or still-footage layer; render precomps/sequences to a video and track that video"))?;
let source_path = media_path(s, &footage.path)?;
if Path::new(path) == Path::new(&source_path) {
return Err(bad(CMD, "the export path must differ from the source footage"));
}
if footage.width as f64 != solve.size[0] || footage.height as f64 != solve.size[1] {
return Err(bad(CMD, "the analysis image differs from the original footage dimensions; render the tracker's input to a video and track that video"));
}
let source_fps = footage.frame_rate.as_f64();
if footage.frame_rate.num <= 0 || footage.frame_rate.den <= 0 || !source_fps.is_finite() || source_fps <= 0.0 {
return Err(bad(CMD, "invalid source footage frame rate"));
}
// Same frame selection as the media pool: interpreted FPS, rounded per-loop frame count,
// clamp to the total loop range, then wrap. Native FPS does not change the frame index.
let divisor = (effectcraft_time::TICKS_PER_SECOND as i128).saturating_mul(footage.frame_rate.den as i128);
let source_count = (footage.duration.0 as i128).saturating_mul(footage.frame_rate.num as i128).saturating_add(divisor / 2) / divisor;
let source_count = if footage.kind == FootageKind::Still { 1 } else { source_count };
if !(1..=MAX_FRAME as i128).contains(&source_count) {
return Err(bad(CMD, "invalid footage duration or footage exceeds Blender's frame range; trim/render the source video first"));
}
let source_count = source_count as i64;
Ok(Source { footage, path: source_path, fps: source_fps, count: source_count })
}
fn scene_warnings(camera: Option<&Layer>, solve: &CameraSolve, footage: &Footage) -> Vec<String> {
let mut warnings = vec![
"Sensor width is assumed (36 mm by default); monocular tracking cannot recover physical sensor size or absolute scene scale.".to_string(),
"The original footage is referenced, not embedded or rendered: masks and effects before the tracker are not baked. Relink the video if moving this script.".to_string(),
];
if camera.is_none() {
warnings
.push("No linked tracker camera: focus uses the default scene depth, DOF is off; optical focus cannot be recovered from feature tracks.".into());
} else {
warnings.push("Focus/iris settings are sampled from the linked camera; pose and lens come from the tracker solve. Blender's physical DOF is an approximation of EffectCraft's blur model.".into());
}
if solve.distortion.is_some_and(|d| !d.is_none()) {
warnings.push("Radial distortion is preserved as metadata (Brown k1/k2, half-diagonal normalization), not applied to Blender's pinhole camera. Use an undistorted plate for exact alignment.".into());
}
if (footage.pixel_aspect - 1.0).abs() > 1e-6 {
warnings.push("The tracker solves square pixels: export uses square pixels; original footage pixel aspect is retained in metadata.".into());
}
warnings
}
/// Bake the solved camera and the linked camera's optical settings at exact composition ticks.
fn bake_frames(
ctx0: &EvalCtx<'_>,
layer: &Layer,
solve: &CameraSolve,
camera: Option<&Layer>,
options: &Options<'_>,
footage: &Footage,
source_count: i64,
) -> Result<(Vec<Value>, linalg::Similarity)> {
let comp = ctx0.comp;
let lo = ctx0.time;
let fd = comp.frame_duration();
let sensor = options.sensor;
let units = options.units;
let start = options.start;
let first = solve.frames.first().ok_or_else(|| bad(CMD, "the camera solve is empty"))?;
let m = ctx0.world_matrix(layer).0;
let layer_scale = m[0][0].hypot(m[1][0]);
finite([layer_scale])?;
if layer_scale <= 1e-9 {
return Err(bad(CMD, "the tracked layer has zero scale"));
}
let world = solve.world([comp.width as f64, comp.height as f64], first.focal * layer_scale);
finite([world.s * units])?;
let mut frames = Vec::with_capacity(solve.frames.len());
for (i, frame) in solve.frames.iter().enumerate() {
let time = Tick(lo.0.saturating_add(fd.0.saturating_mul(i as i64)));
let ctx = ctx0.at(time);
let pose = linalg::mm(&world.r, &linalg::mt(&frame.r()));
let right = position(linalg::mv(&pose, [1.0, 0.0, 0.0]), 1.0);
let up = position(linalg::mv(&pose, [0.0, -1.0, 0.0]), 1.0);
let back = position(linalg::mv(&pose, [0.0, 0.0, -1.0]), 1.0);
let location = position(world.apply(frame.center), units);
let lens = frame.focal * sensor / solve.size[0];
let opts = camera.and_then(|l| l.props.sub("cameraOptions").map(|g| (l, g)));
let value = |key: &str, fallback: f64| opts.map(|(l, g)| ctx.f(l, g, key, fallback)).unwrap_or(fallback);
let focus = value("focusDistance", world.s) * units;
let aperture = value("aperture", 0.0);
let zoom = value("zoom", frame.focal * layer_scale);
// Match Camera Settings' f-stop conversion: focal_for_zoom uses a 36 mm film width,
// and the aperture UI is mm scaled by comp_width / 1920 (not a world-space diameter).
let aperture_mm = aperture * 1920.0 / comp.width as f64;
let fstop = if aperture_mm > 0.0 { (zoom * 36.0 / comp.width as f64) / aperture_mm } else { 2.8 };
let rotation = value("irisRotation", 0.0).to_radians();
let ratio = value("irisAspectRatio", 1.0);
let shape = opts.map(|(l, g)| ctx.e(l, g, "irisShape")).unwrap_or(0);
let blades = match shape {
0 => 4,
1..=8 => shape.saturating_add(2),
_ => return Err(bad(CMD, "invalid iris shape")),
};
let dof = opts.is_some_and(|(l, g)| ctx.b(l, g, "dof")) && aperture > 0.0 && value("blurLevel", 100.0) > 0.0;
let source_time = ctx.source_time(layer);
let source_frame = if footage.kind == FootageKind::Still {
0
} else {
footage.frame_rate.frame_at(source_time).clamp(0, source_count.saturating_mul(footage.loop_count.max(1) as i64).saturating_sub(1)) % source_count
};
finite(location.into_iter().chain([focus, aperture, zoom, fstop, rotation, ratio, value("blurLevel", 100.0), value("irisRoundness", 0.0)]))?;
if focus <= 0.0 || !(0.01..=100.0).contains(&ratio) || !(0.01..=1e6).contains(&fstop) {
return Err(bad(CMD, "focus, f-stop or iris aspect is outside Blender's supported range"));
}
frames.push(json!({"frame": start + i as i64, "timeTicks": time.0, "sourceFrame": source_frame,
"location": location, "rotation": [right, up, back], "lens": lens, "focus": focus, "dof": dof,
"fstop": fstop, "blades": blades, "irisRotation": rotation, "irisRatio": ratio,
"sourceAperture": aperture, "sourceZoom": zoom,
"blurLevel": value("blurLevel", 100.0), "irisRoundness": value("irisRoundness", 0.0)}));
}
Ok((frames, world))
}
+71
View File
@@ -0,0 +1,71 @@
//! Validate solved geometry and its correspondence to the current layer timing.
use effectcraft_project::{Comp, Layer};
use effectcraft_time::Tick;
use effectcraft_track::camtrack::{CameraSolve, linalg};
use super::{CMD, MAX_FRAME, MAX_FRAMES, MAX_POINTS, bad, finite};
use crate::Result;
pub(super) fn timing(comp: &Comp, layer: &Layer, solve: &CameraSolve, start: i64) -> Result<(Tick, i64)> {
if solve.frames.len() > MAX_FRAMES || solve.points.len() > MAX_POINTS {
return Err(bad(CMD, "export is limited to 100,000 frames and 100,000 points; split the shot"));
}
if solve.size.iter().any(|v| !v.is_finite() || *v < 4.0 || *v > 65_536.0 || v.fract() != 0.0) {
return Err(bad(CMD, "invalid tracking resolution (expected 4..65536 whole pixels)"));
}
let fps = comp.frame_rate.as_f64();
if comp.frame_rate.num <= 0 || comp.frame_rate.den <= 0 || !fps.is_finite() || !(1.0..=1000.0).contains(&fps) {
return Err(bad(CMD, "invalid composition frame rate (expected 1..1000 fps)"));
}
let end = start
.checked_add(solve.frames.len() as i64)
.and_then(|n| n.checked_sub(1))
.filter(|n| *n <= MAX_FRAME)
.ok_or_else(|| bad(CMD, "the baked animation exceeds Blender's frame range"))?;
finite([solve.start, solve.frame_duration, solve.average_error, layer.stretch, comp.shutter_angle, comp.shutter_phase])?;
if layer.stretch == 0.0 || comp.width == 0 || comp.height == 0 {
return Err(bad(CMD, "invalid composition dimensions or zero layer stretch"));
}
let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO));
let hi = layer.out_point.min(comp.duration);
let fd = comp.frame_duration();
if fd.0 <= 0 || lo >= hi {
return Err(bad(CMD, "the tracked layer has no active frames"));
}
let expected = ((hi.0 as i128 - lo.0 as i128 + fd.0 as i128 - 1) / fd.0 as i128) as u128;
if expected != solve.frames.len() as u128 {
return Err(bad(CMD, "the solve no longer matches the layer's frame range; re-analyze the tracker"));
}
let dt = (layer.layer_time(lo + fd) - layer.layer_time(lo)).seconds();
if (solve.start - layer.layer_time(lo).seconds()).abs() > 1e-6 || solve.frames.len() > 1 && (solve.frame_duration - dt).abs() > 1e-6 {
return Err(bad(CMD, "the solve no longer matches the layer's timing; re-analyze the tracker"));
}
Ok((lo, end))
}
pub(super) fn geometry(solve: &CameraSolve, sensor: f64) -> Result<()> {
for frame in &solve.frames {
finite(frame.rot.into_iter().chain(frame.center).chain([frame.focal]))?;
if !frame.solved || frame.focal <= 0.0 {
return Err(bad(CMD, "the solve contains unsolved frames or invalid focal lengths; re-analyze the tracker"));
}
let mm = sensor * frame.focal / solve.size[0];
if !(1.0..=5000.0).contains(&mm) {
return Err(bad(CMD, "lens outside Blender's 1..5000 mm range; choose another sensorWidth"));
}
}
if let Some(ground) = &solve.ground {
finite(ground.origin.into_iter().chain(ground.normal).chain(ground.x_axis))?;
if (linalg::norm(ground.normal) - 1.0).abs() > 1e-4
|| (linalg::norm(ground.x_axis) - 1.0).abs() > 1e-4
|| linalg::dot(ground.normal, ground.x_axis).abs() > 1e-4
{
return Err(bad(CMD, "invalid ground-plane basis; set the ground plane again"));
}
}
if let Some(d) = &solve.distortion {
finite([d.k1, d.k2, d.radius])?;
}
Ok(())
}
+204
View File
@@ -0,0 +1,204 @@
# EffectCraft camera tracker → Blender, original code under MIT OR Apache-2.0.
# Run in Blender's Scripting workspace (Open, Run Script), or:
# blender --background --python "Tracked Scene.py"
# Creates a NEW scene; existing scenes and objects are preserved. Save the .blend yourself.
# Public API types: bpy.types.Camera, bpy.types.ImageUser
import json
import math
import os
import bpy
from mathutils import Matrix
DATA = json.loads(__EFFECTCRAFT_DATA__)
def _curves(owner):
"""Blender 4.0–4.3 legacy actions and 4.4+/5.x layered actions."""
anim = owner.animation_data
if anim is None or anim.action is None:
return
action = anim.action
if getattr(action, "is_action_layered", False):
for layer in action.layers:
for strip in layer.strips:
if strip.type == 'KEYFRAME':
bag = strip.channelbag(anim.action_slot)
if bag is not None:
yield from bag.fcurves
else:
yield from action.fcurves
def _interpolation(owner):
for curve in _curves(owner):
# Footage indices and discrete switches must never interpolate between frames.
stepped = ("frame_offset" in curve.data_path or "use_dof" in curve.data_path
or "aperture_blades" in curve.data_path)
for key in curve.keyframe_points:
key.interpolation = 'CONSTANT' if stepped else 'LINEAR'
def _media_path():
original = DATA["media"]["path"]
candidates = [original]
# When sharing, place the video alongside the script or the .blend and run again.
script_path = globals().get("__file__", "")
if script_path:
candidates.append(os.path.join(os.path.dirname(os.path.abspath(script_path)),
os.path.basename(original)))
if bpy.data.filepath:
candidates.append(os.path.join(os.path.dirname(bpy.data.filepath),
os.path.basename(original)))
return next((p for p in candidates if os.path.isfile(p)), None)
def import_scene():
if bpy.app.version < (4, 0, 0):
raise RuntimeError("EffectCraft scene import requires Blender 4.0 or newer")
scene = bpy.data.scenes.new(DATA["name"])
scene.frame_start = DATA["startFrame"]
scene.frame_end = DATA["endFrame"]
fps = DATA["fpsNumerator"] / DATA["fpsDenominator"]
scene.render.fps = max(1, round(fps))
scene.render.fps_base = scene.render.fps / fps
scene.render.resolution_x, scene.render.resolution_y = map(int, DATA["resolution"])
scene.render.resolution_percentage = 100
# The tracker assumes square pixels, regardless of the source's interpretation metadata.
scene.render.pixel_aspect_x = scene.render.pixel_aspect_y = 1.0
scene.render.film_transparent = True
scene.render.motion_blur_shutter = max(0.0, min(2.0, DATA["shutter"]))
scene.unit_settings.system = 'METRIC'
scene.unit_settings.scale_length = 1.0
scene["effectcraft_unit_scale"] = DATA["unitScale"]
scene["effectcraft_scale_is_arbitrary"] = True
scene["effectcraft_shutter_phase_degrees"] = DATA["shutterPhase"]
collection = bpy.data.collections.new("EffectCraft Tracking")
scene.collection.children.link(collection)
camera_data = bpy.data.cameras.new("3D Tracker Camera")
camera = bpy.data.objects.new("3D Tracker Camera", camera_data)
collection.objects.link(camera)
scene.camera = camera
camera.rotation_mode = 'QUATERNION'
camera_data.type = 'PERSP'
camera_data.sensor_fit = 'HORIZONTAL'
camera_data.sensor_width = DATA["sensorWidth"]
camera_data.sensor_height = (DATA["sensorWidth"] * DATA["resolution"][1]
/ DATA["resolution"][0])
camera_data.show_background_images = True
camera_data.show_limits = True
extent = max([DATA["groundSize"], 1.0]
+ [math.dist(p["location"], f["location"])
for p in DATA["points"][::max(1, len(DATA["points"]) // 100)]
for f in DATA["frames"][::max(1, len(DATA["frames"]) // 10)]])
camera_data.clip_start = max(0.00001, min(0.1, extent / 10000.0))
camera_data.clip_end = max(1000.0, extent * 10.0)
previous = None
for sample in DATA["frames"]:
frame = sample["frame"]
camera.location = sample["location"]
# Exported right/up/back vectors are COLUMNS of Blender's local → world basis.
quaternion = Matrix(sample["rotation"]).transposed().to_quaternion()
if previous is not None and previous.dot(quaternion) < 0:
quaternion.negate()
camera.rotation_quaternion = quaternion
previous = quaternion.copy()
camera.keyframe_insert(data_path="location", frame=frame)
camera.keyframe_insert(data_path="rotation_quaternion", frame=frame)
camera_data.lens = sample["lens"]
camera_data.keyframe_insert(data_path="lens", frame=frame)
camera_data.dof.use_dof = sample["dof"]
camera_data.dof.focus_distance = sample["focus"]
camera_data.dof.aperture_fstop = sample["fstop"]
camera_data.dof.aperture_blades = sample["blades"]
camera_data.dof.aperture_rotation = (sample["irisRotation"] + math.pi) % math.tau - math.pi
camera_data.dof.aperture_ratio = sample["irisRatio"]
for prop in ("use_dof", "focus_distance", "aperture_fstop", "aperture_blades",
"aperture_rotation", "aperture_ratio"):
camera_data.keyframe_insert(data_path="dof." + prop, frame=frame)
path = _media_path()
if path is not None:
try:
image = bpy.data.images.load(path, check_existing=False)
image.source = DATA["media"]["kind"]
background = camera_data.background_images.new()
background.source = 'IMAGE'
background.image = image
background.alpha = 1.0
background.display_depth = 'BACK'
background.frame_method = 'STRETCH'
user = background.image_user
user.use_auto_refresh = True
user.use_cyclic = False
user.frame_start = DATA["startFrame"]
user.frame_duration = max(1, min(1048574, image.frame_duration))
if image.source == 'MOVIE':
for sample in DATA["frames"]:
# Blender uses 1-based image frames; sourceFrame is 0-based.
user.frame_offset = sample["sourceFrame"] - (sample["frame"] - user.frame_start)
camera_data.keyframe_insert(
data_path="background_images[0].image_user.frame_offset",
frame=sample["frame"])
except (RuntimeError, ValueError, OSError) as error:
DATA["warnings"].append("Blender could not load the background video: " + str(error))
else:
DATA["warnings"].append("Video not found: " + DATA["media"]["path"]
+ ". Place it beside this script or relink the camera background.")
if DATA["points"]:
mesh = bpy.data.meshes.new("Solved Track Points")
mesh.from_pydata([p["location"] for p in DATA["points"]], [], [])
mesh.update()
cloud = bpy.data.objects.new("Solved Track Points", mesh)
collection.objects.link(cloud)
cloud.show_in_front = True
cloud.hide_render = True
for name, kind, key in (("track_id", 'INT', "id"), ("reprojection_error", 'FLOAT', "error"),
("first_frame", 'INT', "first"), ("last_frame", 'INT', "last")):
attr = mesh.attributes.new(name=name, type=kind, domain='POINT')
for dest, point in zip(attr.data, DATA["points"]):
# Track IDs are u32 in EffectCraft; keep the full IDs in the JSON text too.
dest.value = point[key] if kind == 'FLOAT' else min(2147483647, point[key])
if DATA["ground"]:
size = DATA["groundSize"]
mesh = bpy.data.meshes.new("Solved Ground Plane")
mesh.from_pydata([(-size, -size, 0), (size, -size, 0),
(size, size, 0), (-size, size, 0)], [], [(0, 1, 2, 3)])
mesh.update()
ground = bpy.data.objects.new("Solved Ground Plane", mesh)
collection.objects.link(ground)
ground.display_type = 'WIRE'
ground.hide_render = True
for marker in DATA["markers"]:
scene.timeline_markers.new(marker["name"], frame=marker["frame"])
_interpolation(camera)
_interpolation(camera_data)
metadata = bpy.data.texts.new("EffectCraft Tracking Metadata.json")
metadata.write(json.dumps(DATA, ensure_ascii=False, indent=2))
scene["effectcraft_metadata_text"] = metadata.name
camera_data["effectcraft_average_error_pixels"] = DATA["solve"]["averageError"]
camera_data["effectcraft_solve_method"] = DATA["solve"]["methodUsed"]
camera_data["effectcraft_focus_source"] = "See warnings in " + metadata.name
distortion = DATA["solve"].get("distortion")
if distortion:
for key in ("k1", "k2", "radius"):
camera_data["effectcraft_distortion_" + key] = distortion[key]
if bpy.context.window is not None:
bpy.context.window.scene = scene
for area in bpy.context.window.screen.areas:
if area.type == 'VIEW_3D':
area.spaces.active.region_3d.view_perspective = 'CAMERA'
scene.frame_set(scene.frame_start)
for warning in DATA["warnings"]:
print("EffectCraft: " + warning)
print("EffectCraft: imported", len(DATA["frames"]), "baked camera frames into", scene.name)
return scene
if __name__ == "__main__":
EFFECTCRAFT_SCENE = import_scene()
+14
View File
@@ -42,11 +42,25 @@ pub fn specs() -> Vec<CommandSpec> {
create_from_solve
),
cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))),
cmd!(
"camera.exportBlender",
"Export Scene to Blender...",
[],
None,
"{path: .py, comp?, layer?, effect?, sensorWidth?: mm (default 36), unitScale?: Blender units per comp pixel (default 0.01), startFrame?: positive integer (default 1), includePoints?: true}",
has_comp,
export_blender
),
cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground),
cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points),
]
}
fn export_blender(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = ct_p(s, p, "camera.exportBlender")?;
super::blender::export(s, p, cid, lid, uid)
}
fn with_kind(p: &Value, kind: &str) -> Value {
let mut q = p.clone();
if !q.is_object() {
+1 -1
View File
@@ -35,7 +35,7 @@ fn apply_settings(c: &mut Comp, p: &Value) {
let dx = (c.width as f64 - ow as f64) * (a % 3) as f64 / 2.0;
let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0;
if dx != 0.0 || dy != 0.0 {
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
for l in c.layers.iter_mut_where(|l| l.parent.is_none()) {
if let Some(tr) = l.transform_mut() {
super::layer::offset_position(tr, dx, dy);
}
+9 -6
View File
@@ -5,7 +5,7 @@ use effectcraft_project::Marker;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
use crate::prefs::CompPreset;
use crate::{EngineError, Result, Session, cmd, query};
@@ -25,7 +25,7 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &
let (old_w, old_h) = (c.width, c.height);
c.width = w;
c.height = h;
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
for l in c.layers.iter_mut_where(|l| l.parent.is_none()) {
if let Some(tr) = l.transform_mut() {
super::layer::offset_position(tr, -x, -y);
}
@@ -82,7 +82,7 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result<Vec
/// Save Frame As ▸ File with `queue`: a Render Queue item for the current frame, from the Frame
/// Default templates (Edit ▸ Templates).
fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan};
use effectcraft_project::render_queue::{Channels, RenderQueueItem, TimeSpan};
use effectcraft_project::render_templates::TemplateSlot;
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
@@ -91,6 +91,9 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still);
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
it.output = s.project.render_templates.default_output_module(TemplateSlot::Still);
if b_p(p, "transparent") == Some(false) {
it.output.channels = Channels::Rgb;
}
if let Some(path) = str_p(p, "path") {
it.output.output = path.to_string();
} else if !it.output.format.is_sequence() {
@@ -107,7 +110,7 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
}
fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("queue").and_then(Value::as_bool) == Some(true) {
if b_p(p, "queue") == Some(true) {
return queue_frame(s, p);
}
let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string();
@@ -117,7 +120,7 @@ fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
let bg = comp.background;
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
let rgba = img.to_rgba8_over(bg);
let rgba = if b_p(p, "transparent") == Some(false) { img.to_rgba8_over(bg) } else { img.to_rgba8() };
let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?;
s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved frame to {path}"));
@@ -235,7 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File...",
["Composition", "Save Frame As"],
Some("Cmd+Alt+S"),
"{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}",
"{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now), transparent?: bool (default true)}",
has_comp,
save_frame
),
+33
View File
@@ -170,6 +170,21 @@ pub struct FillPlan {
pub dir: String,
}
/// The fill holds every frame as float RGBA (16 bytes a pixel), its hole mask (1) and the filled
/// copy (16) until it finishes (#1789): when even that is more than `available`, the error to
/// report up front, instead of running out of memory partway through.
fn memory_shortfall(frames: usize, w: u32, h: u32, available: u64) -> Option<String> {
let need = (frames as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h)).saturating_mul(33);
(need > available).then(|| {
let gb = |b: u64| b as f64 / 1e9;
format!(
"Content-Aware Fill of {frames} frames at {w}×{h} needs at least {:.1} GB of memory, and {:.1} GB is free: fill a shorter range (set the work area and choose Work Area) or close other applications",
gb(need),
gb(available)
)
})
}
/// Run a fill: the layer's frames (rendered in passes with a job worker's GPU), the fill, the
/// PNG sequence written into `plan.dir` (`Services::store_file`). Returns the files.
#[allow(clippy::too_many_arguments)]
@@ -275,6 +290,9 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
}
let (fa, fb) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(b - Tick(1)));
let times: Vec<Tick> = (fa..=fb).map(|f| fr.tick_of(f)).collect();
if let Some(e) = crate::sysinfo::memory().and_then(|m| memory_shortfall(times.len(), w, h, m.available)) {
return Err(bad(c, e));
}
let opts = FillOpts {
method: FillMethod::from_name(&st.method).unwrap_or_default(),
lighting: lighting(&st.lighting_correction).unwrap_or(0.0),
@@ -424,3 +442,18 @@ pub fn specs() -> Vec<CommandSpec> {
},
]
}
#[cfg(test)]
mod tests {
use super::memory_shortfall;
/// #1789: 10 s of 1080p30 (300 frames) needs at least 20 GB, which 4 GB free can't hold.
#[test]
fn a_fill_that_cannot_fit_in_memory_is_refused_up_front() {
let e = memory_shortfall(300, 1920, 1080, 4_000_000_000).expect("refused");
assert!(e.contains("300 frames") && e.contains("Work Area"), "{e}");
assert_eq!(memory_shortfall(30, 1920, 1080, 4_000_000_000), None, "a second of it fits");
// Hostile sizes saturate instead of overflowing.
assert!(memory_shortfall(usize::MAX, u32::MAX, u32::MAX, u64::MAX - 1).is_some());
}
}
+69 -3
View File
@@ -1,14 +1,17 @@
//! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text,
//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage).
//! Create Masks from Text, Create Shapes from Vector Layer (SVG footage) and Create 3D Layer
//! Instance (a 2D copy of a 3D model layer that 2D effects can be applied to).
use effectcraft_color::Label;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{ItemKind, LayerSource, Solid};
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector";
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d";
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?;
@@ -19,6 +22,69 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
let time = s.time();
let mut created = vec![];
let mut skipped = vec![];
// All the selected models in one edit: one undo (or redo) removes (or restores) every
// instance at once, as one command in the History panel.
if op == "instance3d" {
// After Effects' Create 3D Layer Instance (Layer ▸ Create): 3D model layers take
// no masks or effects, so a comp-sized solid goes at the top of the layer stack
// with a 3D Layer Instance effect referencing the model — a 2D copy of its render,
// through the comp's camera, that 2D effects can be applied to. The model is switched
// off, as After Effects does (back on to keep its shadow).
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let mut eligible = vec![];
for lid in &lids {
match comp.layer(*lid) {
Some(src) if src.source.is_model() => eligible.push((*lid, src.clone())),
Some(src) => skipped.push(format!("{}: not a 3D model layer", src.name)),
None => {}
}
}
if !eligible.is_empty() {
let ids = s.edit("Create 3D Layer Instance", None, |proj, st| {
let mut ids = vec![];
for (lid, src) in &eligible {
let name = proj.comp(cid).ok_or(EngineError::NoComp)?.unique_layer_name(&format!("{} Instance", src.name));
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
let sid = proj.add_item(
&name,
src.label,
Some(folder),
ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }),
);
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None);
// A soloed source keeps its Solo switch with video off, and any_solo hides
// the non-soloed copy: the copy takes the Solo over to stay visible.
l.switches.solo = src.switches.solo;
// A 3D Layer Instance effect referencing the 3D layer: the solid shows its
// render (the 2D copy).
let fx = effectcraft_effects::find("ec.3d.instance3d").ok_or_else(|| bad("layer.create", "the 3D Layer Instance effect is missing"))?;
let mut g = effectcraft_effects::instantiate(fx, &mut Ids(&mut proj.next_id), fx.name, [comp.width as f64, comp.height as f64]);
if let Some(pr) = g.get_mut("layer") {
pr.value = KV::Layer(Some(lid.0));
}
if let Some(fxg) = l.props.sub_mut("effects") {
fxg.children.push(g.into());
}
// The model is switched off (After Effects turns the referenced layer off);
// the copies go at the top of the stack, selected.
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
if let Some(m) = c.layers.iter_mut().find(|x| x.id == *lid) {
m.switches.video = false;
}
let id = l.id;
c.layers.insert(0, l);
ids.push(id);
}
st.selected_layers = ids.clone();
Ok(ids)
})?;
created.extend(ids.iter().map(|i| i.0));
}
if created.is_empty() && !skipped.is_empty() {
return Err(bad("layer.create", skipped.join("; ")));
}
return Ok(json!({"layers": created, "skipped": skipped}));
}
for lid in lids {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let Some(src) = comp.layer(lid).cloned() else { continue };
@@ -198,5 +264,5 @@ fn insert_above(
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)]
vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector|instance3d, layers?}", has_layers, create)]
}
+15 -5
View File
@@ -74,9 +74,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(s.state.selected_layers.len()))
}
fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
// A deselected layer never keeps an open text edit: commit the edit first, exactly as
// `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut,
// script, MCP — leaves one final state.
// A deselected layer never keeps an open text edit: commit it first, exactly as `text.endEdit`,
// so every route — menu, shortcut, script, MCP — leaves one final state. An empty Type-tool
// layer survives, as it does in After Effects.
super::text_edit::end_edit(s, &Value::Null)?;
s.state.selected_layers.clear();
s.state.selected_props.clear();
@@ -90,7 +90,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
fn relink_copies(comp: &mut Comp, map: &[(LayerId, LayerId)]) {
let present: Vec<LayerId> = comp.layers.iter().map(|l| l.id).collect();
let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, c)| *c).or(present.contains(&id).then_some(id));
for l in comp.layers.iter_mut().filter(|l| map.iter().any(|(_, c)| *c == l.id)) {
for l in comp.layers.iter_mut_where(|l| map.iter().any(|(_, c)| *c == l.id)) {
l.parent = l.parent.and_then(link);
l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m }));
}
@@ -146,6 +146,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
st.selected_layers = created.clone();
Ok(created)
})?;
// Duplicating selects the copies, so an open text edit on a now-deselected layer ends
// (#2226): typing must not keep editing an unselected layer.
if s.state.text_edit.as_ref().is_some_and(|e| !s.state.selected_layers.contains(&e.layer)) {
super::text_edit::end_edit(s, &Value::Null)?;
}
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
@@ -263,6 +268,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 +335,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 +350,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 +359,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).
@@ -675,7 +685,7 @@ fn label(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if let Some(comp) = proj.comp_mut(cid) {
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
l.label = lab;
}
}
+53 -7
View File
@@ -136,7 +136,16 @@ fn warning(s: &mut Session, p: &Value) -> Result<Value> {
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?;
let t = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: t,
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))}))
}
@@ -160,7 +169,16 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|pr| pr.ui == effectcraft_project::ParamUi::Color)
.ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?;
let t = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: t,
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);
@@ -288,7 +306,16 @@ impl CryptoProbe {
let comp = r.project.comp(cid).ok_or("no composition")?;
let layer = comp.layer(lid).ok_or("no such layer")?;
let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?;
let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None };
let ctx = effectcraft_render::EvalCtx {
project: r.project,
comp_id: cid,
comp,
time,
expr: r.expr,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
@@ -351,7 +378,16 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick
let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?;
let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?;
let time = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time,
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let frame = probe_frame(&ctx, layer, pos);
let o = s.render_opts(Default::default());
let prefs = (o.nested_switches, o.draft_shadows);
@@ -503,7 +539,16 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Hit> {
let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) };
let (cid, lid, ..) = key.at;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time,
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string();
let driven = prop.expr.as_ref().is_some_and(|e| e.enabled);
let (aux, layer_name) = (b.aux.clone(), b.layer.clone());
@@ -700,7 +745,7 @@ fn effects_p(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid)>> {
Ok(sel)
}
fn effect_time_error(cmd: &str) -> EngineError {
pub(crate) fn effect_time_error(cmd: &str) -> EngineError {
bad(cmd, "an effect keyframe converts outside the supported time range")
}
@@ -736,7 +781,7 @@ fn effect_time(layer: &Layer, time: Tick, to_comp: bool, cmd: &str) -> Result<Ti
/// Change the key time coordinate system throughout an effect, including nested controls.
/// Ease speeds are per second in that coordinate system; reversed time swaps the key sides.
fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result<Tick>) -> Result<()> {
pub(crate) fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result<Tick>) -> Result<()> {
let mut result = Ok(());
group.walk_mut(&mut |pr| {
if result.is_err() {
@@ -804,6 +849,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;
+2
View File
@@ -163,6 +163,8 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)>
("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"),
("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"),
("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"),
("bezier(t, value1, value2, [x1, y1, x2, y2])", "bezier(t, value1, value2, [x1, y1, x2, y2])"),
("bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])", "bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])"),
],
),
(
+58 -18
View File
@@ -87,7 +87,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project
/// wouldn't read back.
pub(crate) fn file_text(s: &Session, path: &str) -> Result<String> {
let json = s.project.to_file_json_with_view(&s.saved_view())?;
let compact = s.prefs.project.compact_project_files && !crate::xml_project::is_xml_path(path);
let json = s.project.to_file_json_with_view(&s.saved_view(), compact)?;
if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) }
}
@@ -124,7 +125,8 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
}
/// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they
/// also become layers of the active comp ([`add_to_comp`]).
/// also become layers of the active comp ([`add_to_comp`]); with `newComp` (the empty viewer's
/// New Composition From Footage) they become one new composition instead ([`new_comp`]).
///
/// With `background` (files dropped on the window or picked in the Import dialog) the files are
/// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows
@@ -174,10 +176,10 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`;
/// what couldn't be added joins the result's errors.
/// After an import (its result `r`): with `newComp`, the new items become one new composition;
/// otherwise with `addToComp`, layers of `target`. What couldn't be added joins the result's errors.
fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Value) {
let errors = add_to_comp(s, r, target, p);
let errors = if b_p(p, "newComp").unwrap_or(false) { new_comp(s, r) } else { add_to_comp(s, r, target, p) };
if !errors.is_empty()
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
{
@@ -191,18 +193,7 @@ fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Val
/// Project panel) and the other footage. `target` stays open. Returns what couldn't be added.
pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p: &Value) -> Vec<String> {
let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] };
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
let comps = ids("comps");
let in_comps: std::collections::HashSet<u64> = comps
.iter()
.filter_map(|c| s.project.comp(ItemId(*c)))
.flat_map(|c| c.layers.iter())
.filter_map(|l| match l.source {
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0),
_ => None,
})
.collect();
let add: Vec<u64> = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect();
let add = imported(s, r);
let mut errors = vec![];
for (k, item) in add.into_iter().enumerate() {
let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")});
@@ -219,6 +210,55 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p:
errors
}
/// What an import (its result `r`) made that belongs in a comp: the comps of layered files
/// (their layers' footage, in their precomps too, stays in the Project panel), then the other items,
/// without those comps again or the files' folders.
fn imported(s: &Session, r: &Value) -> Vec<u64> {
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
let comps = ids("comps");
let mut in_comps = std::collections::HashSet::new();
let mut todo = comps.clone();
while let Some(c) = todo.pop() {
for l in s.project.comp(ItemId(c)).into_iter().flat_map(|c| c.layers.iter()) {
match l.source {
LayerSource::Comp { item } if in_comps.insert(item.0) => todo.push(item.0),
LayerSource::Footage { item } | LayerSource::Solid { item } => {
in_comps.insert(item.0);
}
_ => {}
}
}
}
let rest = ids("items").into_iter().filter(|i| !in_comps.contains(i) && !comps.contains(i) && !s.project.item(ItemId(*i)).is_some_and(|it| it.is_folder()));
comps.iter().copied().chain(rest).collect()
}
/// With `newComp`, make one composition of the footage and solids an import (its result `r`)
/// made, as New Comp from Selection does with `single` (first item on top, its settings). A comp
/// the import made (a layered file) already is the composition, so it stays out, as does data
/// footage; none left, no new comp. Returns what went wrong.
fn new_comp(s: &mut Session, r: &Value) -> Vec<String> {
let items: Vec<ItemId> = imported(s, r)
.into_iter()
.map(ItemId)
.filter(|i| {
s.project.item(*i).is_some_and(|it| match &it.kind {
ItemKind::Footage(f) => f.kind != FootageKind::Data,
ItemKind::Solid(_) => true,
ItemKind::Comp(_) | ItemKind::Folder => false,
})
})
.collect();
if items.is_empty() {
return vec![];
}
s.state.project_selection = items;
match s.execute("file.newCompFromSelection", json!({"single": true})) {
Ok(_) => vec![],
Err(e) => vec![e.to_string()],
}
}
/// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`].
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
let pending = prepare_import(s, p)?;
@@ -745,7 +785,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File...",
["File", "Import"],
Some("Cmd+I"),
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), newComp?: bool (make one new composition of the footage and solids imported, as file.newCompFromSelection with single; a layered file imported as a composition is that composition and gets no other; takes precedence over addToComp), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
always,
import_cmd
),
+193 -11
View File
@@ -5,7 +5,7 @@
use std::collections::{BTreeMap, BTreeSet};
use effectcraft_color::Label;
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@@ -501,7 +501,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))),
};
if id == "file.runScript" || id == "script.run" {
if super::scripts::RUNS_SCRIPTS.contains(&id.as_str()) {
return Err(bad("file.runScript", "scripts can't run scripts"));
}
let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) };
@@ -536,7 +536,7 @@ fn is_jsxbin(name: &str) -> bool {
}
/// A script error becomes a toast and a command error.
fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
pub(crate) fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
if let Some(e) = out.get("error").filter(|e| !e.is_null()) {
let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default();
let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error");
@@ -552,6 +552,13 @@ pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result<Va
report_js(s, name, out)
}
/// Run the code of the script file at `path` (its `#include`s and `$.fileName` use that path),
/// reported under its file name `name` (Startup and Shutdown scripts).
pub(crate) fn run_js_file(s: &mut Session, code: &str, path: &str, name: &str) -> Result<Value> {
let out = run_js(s, code, path, false)?;
report_js(s, name, out)
}
/// Run JavaScript through the session's scripting engine.
fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Value> {
let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?;
@@ -594,6 +601,131 @@ pub(crate) struct Interpretation {
preserve_rgb: Option<bool>,
/// Start Timecode.
start: Option<StartTimecode>,
/// Cineon Settings (DPX and Cineon footage).
cineon: CineonEdit,
}
/// Interpret Footage ▸ Cineon Settings parsed from command parameters (every field optional).
#[derive(Clone, Debug, Default)]
struct CineonEdit {
/// Logarithmic Conversion.
log: Option<bool>,
units: Option<CineonUnits>,
/// The units the Converted Black and White Points are given in (`pointUnits`, else `cineonUnits`,
/// else the footage's own Units).
point_units: Option<CineonUnits>,
converted_black: Option<f64>,
converted_white: Option<f64>,
/// 10 Bit Black and White Points.
black: Option<f64>,
white: Option<f64>,
gamma: Option<f64>,
rolloff: Option<f64>,
}
impl CineonEdit {
fn parse(p: &Value, cmd: &str) -> Result<CineonEdit> {
let units = |key: &str| -> Result<Option<CineonUnits>> {
match str_p(p, key) {
None => Ok(None),
Some(u) => CineonUnits::parse(u).map(Some).ok_or_else(|| bad(cmd, format!("{key}: 10bit|8bit|float, not `{u}`"))),
}
};
let num = |key: &str, lo: f64, hi: f64| -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|x| x.is_finite() && (lo..=hi).contains(x))
.map(Some)
.ok_or_else(|| bad(cmd, format!("{key}: a number from {lo} to {hi}"))),
}
};
let units_now = units("cineonUnits")?;
Ok(CineonEdit {
log: b_p(p, "logConversion"),
units: units_now,
point_units: units("pointUnits")?.or(units_now),
// Range-checked once their units are known (`settings_for`).
converted_black: num("convertedBlack", f64::MIN, f64::MAX)?,
converted_white: num("convertedWhite", f64::MIN, f64::MAX)?,
black: num("tenBitBlack", 0.0, 1023.0)?,
white: num("tenBitWhite", 0.0, 1023.0)?,
gamma: num("currentGamma", 0.1, 10.0)?,
rolloff: num("highlightRolloff", 0.0, 100.0)?,
})
}
fn is_empty(&self) -> bool {
self.log.is_none()
&& self.units.is_none()
&& self.converted_black.is_none()
&& self.converted_white.is_none()
&& self.black.is_none()
&& self.white.is_none()
&& self.gamma.is_none()
&& self.rolloff.is_none()
}
/// Footage `f`'s Cineon Settings after this edit; `None` when nothing changes them (no Cineon
/// parameters, or `f` is not a DPX or Cineon file).
fn settings_for(&self, f: &Footage, cmd: &str) -> Result<Option<CineonSettings>> {
if self.is_empty() || !f.is_cineon_file() {
return Ok(None);
}
let mut c = f.cineon;
let units = self.point_units.unwrap_or(c.units);
let point = |v: f64| -> Result<f32> {
let ten = units.to_ten_bit(v);
let max = units.from_ten_bit(f64::from(CineonSettings::MAX));
if (0.0..=f64::from(CineonSettings::MAX)).contains(&ten) {
Ok(ten as f32)
} else {
Err(bad(cmd, format!("converted points: 0 to {max} in {} units", units.label())))
}
};
if let Some(v) = self.converted_black {
c.converted_black = point(v)?;
}
if let Some(v) = self.converted_white {
c.converted_white = point(v)?;
}
if let Some(v) = self.log {
c.log_conversion = v;
}
if let Some(u) = self.units {
c.units = u;
}
if let Some(v) = self.black {
c.black = v as f32;
}
if let Some(v) = self.white {
c.white = v as f32;
}
if let Some(v) = self.gamma {
c.gamma = v as f32;
}
if let Some(v) = self.rolloff {
c.rolloff = v as f32;
}
if c.black >= c.white {
return Err(bad(cmd, "the 10 Bit Black Point must be below the 10 Bit White Point"));
}
Ok(Some(c))
}
}
/// Footage `f`'s Cineon Settings as commands report them (`null` for files they don't apply to).
fn cineon_json(f: &Footage) -> Value {
if !f.is_cineon_file() {
return Value::Null;
}
let c = &f.cineon;
json!({
"logConversion": c.log_conversion, "units": c.units.id(),
"convertedBlack": c.units.from_ten_bit(f64::from(c.converted_black)), "convertedWhite": c.units.from_ten_bit(f64::from(c.converted_white)),
"tenBitBlack": c.black, "tenBitWhite": c.white, "currentGamma": c.gamma, "highlightRolloff": c.rolloff,
})
}
/// Interpret Footage ▸ Start Timecode.
@@ -687,9 +819,23 @@ impl Interpretation {
v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?,
)),
};
it.cineon = CineonEdit::parse(p, cmd)?;
Ok(it)
}
/// Check the Cineon Settings this interpretation gives each of `footage`: an error names a
/// value out of range, or Cineon parameters when none of the footage is DPX or Cineon.
pub(crate) fn check<'a>(&self, footage: impl IntoIterator<Item = &'a Footage>, cmd: &str) -> Result<()> {
if self.cineon.is_empty() {
return Ok(());
}
let mut any = false;
for f in footage {
any |= self.cineon.settings_for(f, cmd)?.is_some();
}
if any { Ok(()) } else { Err(bad(cmd, "Cineon Settings apply to DPX and Cineon footage only")) }
}
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
let before = f.frame_rate;
self.apply_settings(f, guessed);
@@ -749,6 +895,10 @@ impl Interpretation {
if let Some(v) = self.preserve_rgb {
f.preserve_rgb = v;
}
// Checked before the edit (`Interpretation::check`).
if let Ok(Some(c)) = self.cineon.settings_for(f, "file.interpretFootage") {
f.cineon = c;
}
// After the frame rate: timecode is read at the footage's rate.
if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) {
f.start_timecode = start;
@@ -795,6 +945,11 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("file.interpretFootage", "select footage"));
}
let it = Interpretation::parse(p, "file.interpretFootage")?;
let footage = items.iter().filter_map(|i| match s.project.item(*i).map(|x| &x.kind) {
Some(ItemKind::Footage(f)) => Some(f),
_ => None,
});
it.check(footage, "file.interpretFootage")?;
let guesses: Vec<Option<(AlphaMode, [f32; 3])>> = items
.iter()
.map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) {
@@ -830,7 +985,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
"linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(),
"frames": f.sequence_frames(),
"frames": f.sequence_frames(), "cineon": cineon_json(f),
})),
_ => None,
})
@@ -861,6 +1016,9 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
f.linear_light = src.linear_light;
f.preserve_rgb = src.preserve_rgb;
f.color_profile = src.color_profile;
if f.is_cineon_file() && src.is_cineon_file() {
f.cineon = src.cineon;
}
if src.native_rate.is_some() {
if f.native_rate.is_none() {
f.native_rate = Some(f.frame_rate);
@@ -878,10 +1036,34 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string();
let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?;
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
// A numbered still brings its image sequence, as on import (`sequence: false` for one frame).
let opts = SequenceOptions::from_params(p);
// Replacing a still keeps it a still; replacing a sequence keeps bringing the new file's run.
// `sequence`/`alphabetical` override that (#2073).
let previous = match s.project.item(item).map(|it| &it.kind) {
Some(ItemKind::Footage(f)) if f.kind == FootageKind::Sequence => (true, true, f.alphabetical),
_ => (false, false, false),
};
// Only `sequence` decides whether the item keeps its kind; `alphabetical` is a sequence
// modifier, not a request to make one.
let inherited = b_p(p, "sequence").is_none();
let mut opts = SequenceOptions::from_params(p);
if inherited {
(opts.sequence, opts.folders) = (previous.0, previous.1);
if previous.0 && b_p(p, "alphabetical").is_none() {
opts.alphabetical = previous.2;
}
}
let src = crate::sequence::source_of(s.services.as_ref(), &path, opts);
let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
// A single-file source comes back as a still; an inherited sequence kind keeps it a
// one-frame sequence, so a later default replacement still brings numbered runs (#2073).
if inherited && previous.0 && matches!(src, crate::sequence::Source::File(_)) && f.kind == FootageKind::Still {
f.kind = FootageKind::Sequence;
f.sequence = vec![f.path.clone()];
f.alphabetical = opts.alphabetical;
f.frame_rate = s.sequence_rate();
f.native_rate = None;
f.sync_sequence_duration();
}
let name = src.name();
// Replacing a missing file relinks the other missing files that moved with it (as After Effects
// does when you locate one).
@@ -1066,7 +1248,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Main...",
["File", "Interpret Footage"],
Some("Cmd+Alt+G"),
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
"{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}",
has_footage_selection,
interpret
),
@@ -1084,7 +1266,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Interpretation",
["File", "Interpret Footage"],
Some("Cmd+Alt+V"),
"{items?}",
"{items?|item?}",
has_interpretation,
apply_interpretation
),
@@ -1093,7 +1275,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File...",
["File", "Replace Footage"],
Some("Cmd+H"),
"{path, item?, sequence?: bool (default true: a numbered still brings its image sequence), alphabetical?: bool}",
"{path, item?, sequence?: bool (a numbered still brings its image sequence; default: the item keeps its kind, so a still stays a still and a sequence stays a sequence), alphabetical?: bool}",
has_footage_selection,
replace_footage
),
@@ -1115,7 +1297,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_footage_selection,
replace_with_solid
),
cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?}", has_footage_selection, reload),
cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?|item?}", has_footage_selection, reload),
cmd!("file.revealInFinder", "Reveal in Finder", ["File"], None, "{item?}", has_footage_selection, reveal_in_finder),
]
}
+6
View File
@@ -78,6 +78,12 @@ fn set_to_layer(s: &mut Session, p: &Value) -> Result<Value> {
let c = "camera.setFocusToLayer";
let (cid, cam, target) = refs(s, p, c, true)?;
let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?;
// Only a finite depth can be stored: JSON has no infinity or NaN, so the project could no
// longer be saved. Extreme but finite positions can still produce one, and `d <= 0.0` is
// false for NaN.
if !d.is_finite() {
return Err(bad(c, "can't measure the distance to the layer (the camera or layer position is out of range)"));
}
if d <= 0.0 {
return Err(bad(c, "the layer is behind the camera"));
}
+7 -4
View File
@@ -56,6 +56,12 @@ pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time::
}
}
/// The frame the Footage panel shows: footage `f` at source time `t`, with its Cineon Settings
/// applied.
pub fn frame(s: &Session, item: ItemId, f: &effectcraft_project::Footage, t: Tick) -> Option<std::sync::Arc<effectcraft_raster::Image>> {
s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f))
}
fn source_time(s: &Session, p: &Value, item: ItemId) -> Option<Tick> {
let (_, fr) = source_timing(s, item);
if let Some(t) = f_p(p, "time") {
@@ -207,10 +213,7 @@ fn info(s: &mut Session, _: &Value) -> Result<Value> {
let item = s.project.item(v.item);
let name = item.map(|i| i.name.clone()).unwrap_or_default();
let frame = fr.frame_at(v.time);
let problem = match item.map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => s.footage.problem(f),
_ => None,
};
let problem = item.and_then(|i| s.footage.item_problem(i));
Ok(
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}),
)
+4 -4
View File
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text",
["Layer", "New"],
Some("Cmd+Alt+Shift+T"),
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)",
has_comp,
new_text
),
@@ -121,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Track Matte",
[],
None,
"{layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted}",
"{layers? | layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted|preserve}",
has_layers,
track_matte
),
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add (Shape)",
[],
None,
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|wiggler|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
has_layers,
add_shape_item
),
@@ -192,7 +192,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Text",
[],
None,
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}",
has_layers,
set_text
),
+1 -1
View File
@@ -161,7 +161,7 @@ pub(super) fn text_range_p(p: &Value) -> Option<std::ops::Range<usize>> {
/// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40
/// characters of the first text line, else "Text". `edit: true` also opens a new editing
/// session with anchor `0` and the caret after the text, selecting all initial text so the next
/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer.
/// insertion replaces it. The session has `created: true`; leaving it empty keeps the layer, as After Effects does.
pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
+263 -60
View File
@@ -1,4 +1,4 @@
use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer};
use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer, unlocked};
/// Re-express a layer in its new parent space without moving its anchor.
pub(crate) struct ParentFix {
@@ -9,10 +9,20 @@ pub(crate) struct ParentFix {
rotation: f64,
/// Multiply Scale.
scale: [f64; 2],
/// 3D AV layers retain their world-space anchor, including Z.
/// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also
/// re-express their Point of Interest.
position_3d: Option<effectcraft_geom::Mat4>,
/// A uniform relative parent transform can preserve static Orientation and Scale.
pose_3d: Option<([f64; 3], f64)>,
/// A uniform relative parent transform re-expresses static Orientation and Scale.
pose_3d: Option<Pose3d>,
}
/// A uniform relative parent transform, as it applies to a 3D layer's Orientation and Scale.
#[derive(Clone, Copy)]
struct Pose3d {
/// Rotation that Orientation is re-expressed under: `Orientation' = correction * Orientation`.
correction: effectcraft_geom::Mat4,
/// Uniform scale factor, multiplied into Scale (negative for a mirrored parent).
factor: f64,
}
impl ParentFix {
@@ -22,7 +32,7 @@ impl ParentFix {
if let Some(m) = self.position_3d {
// Spatial Position speed measures distance along its path. Its parent-space
// unit changes even when the child's Orientation cannot be re-expressed.
let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs());
let position_speed = uniform_rotation(m).map(|(_, factor)| factor.abs());
let map_position = |v: &KV| {
let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3()));
KV::Vec3([q.x, q.y, q.z])
@@ -44,7 +54,8 @@ impl ParentFix {
}
}
}
if let Some(pr) = tr.get_mut("position") {
// Position and a camera's or light's Point of Interest are both points in the parent's space.
let map_point = |pr: &mut effectcraft_project::Property| {
pr.value = map_position(&pr.value);
for key in &mut pr.keys {
key.value = map_position(&key.value);
@@ -64,34 +75,19 @@ impl ParentFix {
}
}
}
};
if let Some(pr) = tr.get_mut("position") {
map_point(pr);
}
// Cameras and lights aim with Point of Interest: move it by the same parent-space
// change, so a two-node rig keeps its world frame (#1010, #1020).
if let Some(pr) = tr.get_mut("poi") {
pr.value = map_position(&pr.value);
for key in &mut pr.keys {
key.value = map_position(&key.value);
for tangent in [&mut key.spatial_in, &mut key.spatial_out] {
let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent));
*tangent = [v.x, v.y, v.z];
}
}
if let Some(factor) = position_speed
.filter(|_| pr.spatial)
.filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite()))
{
for key in &mut pr.keys {
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
ease.speed *= factor;
}
}
}
map_point(pr);
}
if let Some((orientation, factor)) = self.pose_3d {
if let Some(pose) = self.pose_3d {
if let Some(pr) = tr.get_mut("orientation") {
pr.value = KV::Vec3(orientation);
reexpress_orientation(pose.correction, pr);
}
if let Some(pr) = tr.get_mut("scale") {
let factor = pose.factor;
let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor));
pr.value = scaled(&pr.value);
for key in &mut pr.keys {
@@ -154,13 +150,10 @@ impl ParentFix {
}
}
/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal
/// rotation to static Orientation using the existing Rz * Ry * Rx order.
fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> {
use effectcraft_geom::{Mat4, Vec3, vec3};
if !orientation.iter().all(|v| v.is_finite()) {
return None;
}
/// The rotation and signed scale factor of a uniform parent transform's linear part (negative for a
/// mirrored parent). `None` for a non-uniform or degenerate transform.
fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4, f64)> {
use effectcraft_geom::{Mat4, vec3};
let a = m.0;
let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])];
let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z);
@@ -179,15 +172,188 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti
rotation.0[row][col] = a[row][col] / factor;
}
}
if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) {
return Some((orientation, factor));
}
let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0;
Some((rotation, factor))
}
/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation.
fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option<f64> {
let m = correction.0;
let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9;
about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees())
}
/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock.
fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] {
// atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact.
let cy = r[0][0].hypot(r[1][0]);
let y = (-r[2][0]).atan2(cy);
let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) };
Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor))
[x.to_degrees(), y.to_degrees(), z.to_degrees()]
}
/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the
/// `(x + 180, 180 - y, z + 180)` form when that is nearer.
fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) };
let natural = near(euler);
let flipped = near([euler[0] + 180.0, 180.0 - euler[1], euler[2] + 180.0]);
let distance = |v: [f64; 3]| v.iter().zip(reference.iter()).map(|(a, b)| (a - b).powi(2)).sum::<f64>();
if distance(flipped) < distance(natural) { flipped } else { natural }
}
/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`.
fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o));
nearest_euler(euler_zyx(world.0), reference)
}
/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys.
const BAKE_TOLERANCE_DEGREES: f64 = 0.05;
/// How many times a segment may be halved while baking, and the most keys a property may end up with.
const BAKE_MAX_DEPTH: u32 = 10;
const BAKE_MAX_KEYS: usize = 4096;
/// The angle (degrees) between two rotations.
fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 {
let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum();
((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees()
}
/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend
/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out`
/// never grows past `budget` keys: what the whole property may still add, not what one segment may.
fn bake_between(
correction: effectcraft_geom::Mat4,
authored: &effectcraft_project::Property,
(ta, ea): (Tick, [f64; 3]),
(tb, eb): (Tick, [f64; 3]),
depth: u32,
budget: usize,
out: &mut Vec<effectcraft_keyframe::Keyframe>,
) {
use effectcraft_geom::{Mat4, Vec3};
if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget {
return;
}
let tm = Tick(ta.0 + (tb.0 - ta.0) / 2);
let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0];
let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3()));
// Symmetric easing crosses the straight blend at its midpoint. Check both sides too before
// accepting a segment whose original easing will be replaced with a linear blend.
let quarter = (tb.0 - ta.0) / 4;
if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| {
let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64;
let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u);
let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3()));
rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES
}) {
return;
}
let em = nearest_euler(euler_zyx(exact.0), blend);
bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out);
if out.len() >= budget {
return;
}
out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em)));
bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out);
}
/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the
/// value and every key. For any other correction a static value is converted directly; keys are
/// converted, and keys are added between them wherever the Euler blend between two keys would drift
/// from the exact rotation (the segments become linear, with the original easing baked in).
fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) {
if let Some(turn) = z_only_turn(correction) {
// No turn (an identity correction) leaves every authored value as it is.
if turn.abs() <= 1e-12 {
return;
}
let shift = |value: &KV| {
let [x, y, z] = value.as_vec3();
KV::Vec3([x, y, z + turn])
};
pr.value = shift(&pr.value);
for key in &mut pr.keys {
key.value = shift(&key.value);
}
return;
}
let value = pr.value.as_vec3();
pr.value = KV::Vec3(reexpress_euler(correction, value, value));
if pr.keys.is_empty() {
return;
}
let authored = pr.clone();
// Each key keeps the authored change from the previous key on top of its converted value.
let mut converted: Vec<[f64; 3]> = vec![];
let mut prev: Option<([f64; 3], [f64; 3])> = None;
for key in &authored.keys {
let o = key.value.as_vec3();
let reference = match prev {
Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]],
None => o,
};
let n = reexpress_euler(correction, o, reference);
converted.push(n);
prev = Some((o, n));
}
use effectcraft_keyframe::Interp;
let mut keys: Vec<effectcraft_keyframe::Keyframe> = vec![];
let mut after_baked_segment = false;
for (i, key) in authored.keys.iter().enumerate() {
let mut k = key.clone();
k.value = KV::Vec3(converted[i]);
if after_baked_segment {
k.in_interp = Interp::Linear;
k.in_ease.clear();
k.auto_bezier = false;
k.continuous = false;
}
after_baked_segment = false;
let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold);
let mut between = vec![];
if let Some(next) = segment {
// What the whole property may still add: the keys it will have already (the ones written
// and the authored ones still to come) count against the cap.
let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i);
bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between);
// With the cap reached the segment can't be checked or subdivided: it keeps its authored easing between
// the converted keys, rather than becoming a straight blend that was never checked against the original.
if between.len() >= budget {
between.clear();
// An eased segment, or one that gained keys, becomes a run of straight blends.
} else if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear {
k.out_interp = Interp::Linear;
k.out_ease.clear();
k.auto_bezier = false;
k.continuous = false;
after_baked_segment = true;
}
}
keys.push(k);
keys.extend(between);
}
pr.keys = keys;
}
/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is
/// `rotation * aim`, so the roll is re-expressed against that aim. `None` for a degenerate aim.
fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option<effectcraft_geom::Mat4> {
use effectcraft_geom::vec3;
use effectcraft_render::three_d::camera::basis_rotation;
let [x, y, z] = aim;
let length = (x * x + y * y + z * z).sqrt();
if !length.is_finite() || length <= 1e-9 {
return None;
}
let dir = vec3(x / length, y / length, z / length);
let turned = rotation.apply_vec(dir);
// A view along the up axis has no roll to keep, and its basis is undefined.
if 1.0 - dir.y * dir.y < 1e-12 || 1.0 - turned.y * turned.y < 1e-12 {
return None;
}
let up = vec3(0.0, 1.0, 0.0);
Some(basis_rotation(turned, up).transpose() * rotation * basis_rotation(dir, up))
}
/// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position,
@@ -196,8 +362,16 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti
/// time.
pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option<LayerId>) -> Vec<ParentFix> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ectx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ectx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: s.time(),
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
};
@@ -235,31 +409,58 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op
})
};
if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) {
// Cameras and lights compensate like 3D layers (Position, and Point of Interest for
// two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no
// Scale, so `pose_3d` below stays `None` and Orientation is kept as authored.
for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) {
let m = inv * space_3d(layer.parent);
// A one-node camera or light has no Scale to carry a mirror, and its frame (forward and
// down) would come out reversed: compensate none of it, so Position and Orientation stay
// as authored instead of half-following the parent.
let rig = layer.is_camera() || layer.is_light();
if rig && !effectcraft_render::three_d::camera::is_two_node(layer) && uniform_rotation(m).is_some_and(|(_, factor)| factor <= 0.0) {
continue;
}
if m.0.iter().flatten().all(|v| v.is_finite()) {
let pose_3d = layer.transform().and_then(|tr| {
let orientation = tr.get("orientation")?;
let scale = tr.get("scale")?;
// Animated Euler Orientation and auto-orient cannot be re-expressed by
// prepending a constant Euler value. Expression-driven values stay authored.
if !orientation.keys.is_empty()
|| orientation.expr.is_some()
|| scale.expr.is_some()
|| layer.auto_orient != effectcraft_project::AutoOrient::Off
// Cameras and lights have no Scale; their Orientation is re-expressed alone.
let scale = tr.get("scale");
// Expression-driven values stay authored. Auto-orient replaces Orientation, except
// a two-node rig, whose roll is re-expressed against its new aim.
let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer);
if orientation.expr.is_some()
|| scale.is_some_and(|scale| scale.expr.is_some())
|| (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node)
{
return None;
}
let pose = uniform_parent_pose(m, orientation.value.as_vec3())?;
let factor = pose.1;
let values_finite = std::iter::once(&scale.value)
.chain(scale.keys.iter().map(|key| &key.value))
.all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite()));
let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite());
(values_finite && speeds_finite).then_some(pose)
let (rotation, factor) = uniform_rotation(m)?;
let correction = if two_node {
// A mirrored parent flips the aim, which this correction does not model.
if factor <= 0.0 {
return None;
}
let (w, h) = (comp.width as f64, comp.height as f64);
let pos = ectx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]);
let poi = ectx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]);
aim_correction(rotation, [poi[0] - pos[0], poi[1] - pos[1], poi[2] - pos[2]])?
} else {
rotation
};
let orientation_finite = std::iter::once(&orientation.value)
.chain(orientation.keys.iter().map(|key| &key.value))
.all(|value| value.as_vec3().iter().all(|v| v.is_finite()));
if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite {
return None;
}
if let Some(scale) = scale {
let values_finite = std::iter::once(&scale.value)
.chain(scale.keys.iter().map(|key| &key.value))
.all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite()));
let speeds_finite = scale.keys.iter().all(|key| key.in_ease.iter().chain(&key.out_ease).all(|ease| (ease.speed * factor).is_finite()));
if !values_finite || !speeds_finite {
return None;
}
}
Some(Pose3d { correction, factor })
});
fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d });
}
@@ -309,6 +510,8 @@ fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option<LayerId>, max
pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
// Locked layers keep their parent (several at once: the others are parented).
let ids = unlocked(s, cid, ids, "layer.setParent")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let parent = match p.get("parent") {
None | Some(Value::Null) => None,
@@ -323,7 +526,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] };
s.edit("Parent", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
l.parent = parent;
if let Some(f) = fixes.iter().find(|f| f.layer == l.id) {
f.apply(l);
+107 -18
View File
@@ -1,6 +1,6 @@
use super::{
BlendMode, EngineError, FrameBlend, ItemId, KV, Layer, LayerId, MatteKind, Quality, Result, Session, TrackMatte, Value, b_p, bad, json, layer_mut, layer_p,
layers_p, merge_p, resolve_layer, str_p,
layers_p, merge_p, resolve_layer, str_p, unlocked,
};
pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
@@ -19,7 +19,7 @@ pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false));
let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
let target = v.unwrap_or(!first);
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
set_switch_value(l, &name, target);
last = target;
}
@@ -55,7 +55,7 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str,
alone &= on == (ids.contains(&l.id) == active);
}
let others = value.unwrap_or(alone == active);
for l in comp.layers.iter_mut().filter(|l| applies(l)) {
for l in comp.layers.iter_mut_where(|l| applies(l)) {
let v = if ids.contains(&l.id) { active } else { others };
set_switch_value(l, name, v);
}
@@ -152,7 +152,7 @@ pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_p(p, "comment").ok_or_else(|| bad("layer.setComment", "missing `comment`"))?.to_string();
s.edit("Layer Comment", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
l.comment = text.clone();
}
Ok(())
@@ -169,7 +169,7 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result<Value> {
}
s.edit("Blending Mode", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
l.blend_mode = match mode {
Some(m) => m,
None if step > 0 => l.blend_mode.next(),
@@ -181,33 +181,122 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// `layer.setTrackMatte`: `layers` (or `layer`, else the first selected layer; locked ones left
/// as they are) use `matte` as their track matte (none when null), in one undo step. `kind` sets the mode; `preserve` keeps
/// each layer's own (Alpha for a layer without a matte), so changing the matte of several layers
/// at once keeps their modes.
pub(super) fn track_matte(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?;
let (cid, ids) = match p.get("layers") {
Some(_) => layers_p(s, p)?,
None => layer_p(s, p, "layer.setTrackMatte").map(|(c, l)| (c, vec![l]))?,
};
// Locked layers keep their matte (several at once: the others get it).
let ids = unlocked(s, cid, ids, "layer.setTrackMatte")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let matte = match p.get("matte") {
None | Some(Value::Null) => None,
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?),
};
let kind = str_p(p, "kind")
.map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
if ids.is_empty() {
return Err(bad("layer.setTrackMatte", "no layers"));
}
if matte.is_some_and(|m| ids.contains(&m)) {
return Err(bad("layer.setTrackMatte", "a layer can't be its own matte"));
}
// None: each layer keeps its own mode.
let kind = match str_p(p, "kind").map(|k| k.to_ascii_lowercase().replace([' ', '_', '-'], "")) {
Some(k) if k == "preserve" => None,
Some(k) => Some(match k.as_str() {
"alphainverted" | "alphainv" => MatteKind::AlphaInverted,
"luma" => MatteKind::Luma,
"lumainverted" | "lumainv" => MatteKind::LumaInverted,
_ => MatteKind::Alpha,
})
.unwrap_or(MatteKind::Alpha);
}),
None => Some(MatteKind::Alpha),
};
s.edit("Track Matte", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
if let Some(m) = matte {
if m == lid {
return Err(bad("layer.setTrackMatte", "a layer can't be its own matte"));
}
if let Some(ml) = comp.layer_mut(m) {
ml.switches.video = false;
}
if let Some(ml) = matte.and_then(|m| comp.layer_mut(m)) {
ml.switches.video = false;
}
for lid in &ids {
let l = comp.layer_mut(*lid).ok_or(EngineError::NoComp)?;
let kind = kind.unwrap_or_else(|| l.track_matte.map(|m| m.kind).unwrap_or_default());
l.track_matte = matte.map(|layer| TrackMatte { layer, kind });
}
comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind });
Ok(())
})?;
Ok(Value::Null)
}
#[cfg(test)]
mod track_matte_batch_tests {
use super::*;
use std::sync::Arc;
fn matte_of(s: &Session, id: u64) -> Option<(u64, MatteKind)> {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().track_matte.map(|m| (m.layer.0, m.kind))
}
/// #1260: several layers take one matte layer at once, in one undo step; `kind: "preserve"`
/// keeps each one's mode; refused calls change nothing; one `layer` still works.
#[test]
fn several_layers_take_one_matte_and_keep_their_modes() {
let mut s = Session::default();
s.execute("comp.new", json!({"name":"Mattes","width":16,"height":16,"duration":1})).unwrap();
let [a, b, c, x, y] = ["A", "B", "C", "X", "Y"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.setTrackMatte", json!({"layer": a, "matte": x, "kind": "luma"})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": b, "matte": x})).unwrap();
assert_eq!((matte_of(&s, a), matte_of(&s, b)), (Some((x, MatteKind::Luma)), Some((x, MatteKind::Alpha))), "one layer, as before");
let (before, undo) = (s.project.clone(), s.history.undo.len());
s.execute("layer.setTrackMatte", json!({"layers": [a, b, c], "matte": y, "kind": "preserve"})).unwrap();
assert_eq!(
[matte_of(&s, a), matte_of(&s, b), matte_of(&s, c)],
[Some((y, MatteKind::Luma)), Some((y, MatteKind::Alpha)), Some((y, MatteKind::Alpha))],
"each keeps its mode (Alpha without one)"
);
assert!(!s.active_comp().unwrap().layer(LayerId(y)).unwrap().switches.video, "the matte is hidden");
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.same_content(&before));
// Refused: a layer as its own matte, an unknown layer, no layers. Nothing changes.
for p in [
json!({"layers": [a, y], "matte": y}),
json!({"layers": [a, 9999], "matte": y}),
json!({"layers": [], "matte": y}),
json!({"layers": [a], "matte": 9999}),
] {
let (project, revision) = (s.project.clone(), s.revision);
assert!(s.execute("layer.setTrackMatte", p.clone()).is_err(), "{p}");
assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{p} changed nothing");
}
s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": null})).unwrap();
assert_eq!((matte_of(&s, a), matte_of(&s, b)), (None, None), "both lose it");
}
/// Locked layers keep their parent and matte when they are among several (the others change),
/// and alone they are an error that changes nothing.
#[test]
fn locked_layers_keep_their_parent_and_matte() {
let mut s = Session::default();
s.execute("comp.new", json!({"name":"Locks","width":16,"height":16,"duration":1})).unwrap();
let [a, b, p, m] = ["A", "B", "P", "M"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
let layer = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
s.execute("layer.setParent", json!({"layers": [a, b], "parent": p})).unwrap();
s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": m, "kind": "preserve"})).unwrap();
s.execute("layer.trackMatte", json!({"layers": [a, b], "op": "luma"})).unwrap();
assert_eq!((layer(&s, a).parent, matte_of(&s, a)), (Some(LayerId(p)), Some((m, MatteKind::Luma))));
assert_eq!((layer(&s, b).parent, matte_of(&s, b)), (None, None), "the locked layer is left as it was");
for (cmd, p) in [
("layer.setParent", json!({"layers": [b], "parent": p})),
("layer.setTrackMatte", json!({"layers": [b], "matte": m})),
("layer.trackMatte", json!({"layers": [b], "op": "alpha"})),
] {
let (project, revision) = (s.project.clone(), s.revision);
let err = s.execute(cmd, p).unwrap_err();
assert!(err.to_string().contains("is locked"), "{cmd}: {err}");
assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{cmd} changed nothing");
}
}
}
+6 -4
View File
@@ -18,7 +18,7 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fd = comp.frame_duration();
let comp_dur = comp.duration;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
let shift = |l: &mut Layer, d: Tick| {
l.start_time += d;
l.in_point += d;
@@ -42,7 +42,9 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result<Value> {
shift(l, d);
}
"trimInToTime" => l.in_point = t.min(l.out_point - fd),
"trimOutToTime" => l.out_point = t.max(l.in_point + fd),
// Alt+]: the Out edge lands one frame after the playhead, so its frame stays the
// layer's last visible one (as in After Effects), never before the In point.
"trimOutToTime" => l.out_point = t.max(l.in_point) + fd,
_ => {
if let Some(d) = delta {
shift(l, d);
@@ -159,14 +161,14 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
for i in 1..n {
if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) {
let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n);
comp.layers[i - 1..run_end].rotate_left(1);
comp.layers.rotate_range_left(i - 1..run_end, 1);
}
}
} else {
for i in (0..n.saturating_sub(1)).rev() {
if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) {
let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1);
comp.layers[run_start..=i + 1].rotate_right(1);
comp.layers.rotate_range_right(run_start..=i + 1, 1);
}
}
}
+34 -5
View File
@@ -74,14 +74,39 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
let op = str_p(p, "op").unwrap_or("reset").to_string();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let t = s.time();
let sizes: Vec<(LayerId, (u32, u32))> =
comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect();
// A shape or text layer has no source item, so source_size is (0, 0): the fit ops size it by
// its painted bounds at the current time instead, and anchor at the bounds' centre — content
// bounds live in layer space, where a shape is centred on the origin and text sits above its
// baseline, so (w/2, h/2) would push it off-centre (#2198). Reset and the other ops keep the
// source size.
let ectx = effectcraft_render::EvalCtx {
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
..effectcraft_render::EvalCtx::new(&s.project, cid, &comp, t)
};
let fitting = matches!(op.as_str(), "fit" | "fitWidth" | "fitHeight");
let sizes: Vec<(LayerId, [f64; 2], [f64; 2])> = comp
.layers
.iter()
.filter(|l| ids.contains(&l.id))
.map(|l| {
let (w, h) = effectcraft_render::source_size(&s.project, l);
if fitting && (w == 0 || h == 0) {
match effectcraft_render::content_bounds(&ectx, l).filter(|b| b.iter().all(|v| v.is_finite()) && b[2] > b[0] && b[3] > b[1]) {
Some(b) => (l.id, [b[2] - b[0], b[3] - b[1]], [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0]),
None => (l.id, [0.0; 2], [0.0; 2]),
}
} else {
(l.id, [w as f64, h as f64], [w as f64 / 2.0, h as f64 / 2.0])
}
})
.collect();
s.edit("Transform", None, |proj, _| {
for (lid, (w, h)) in &sizes {
for (lid, size, anchor) in &sizes {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let (cw, ch) = (comp.width as f64, comp.height as f64);
let (w, h) = (*w as f64, *h as f64);
let (w, h) = (size[0], size[1]);
let Some(tr) = l.transform_mut() else { continue };
let set = |tr: &mut PropGroup, m: &str, v: KV| {
if let Some(pr) = tr.get_mut(m) {
@@ -105,11 +130,15 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
"fitHeight" => (ch / h, ch / h),
_ => (cw / w, ch / h),
};
// A tiny shape's bounds can overflow the scale; refuse rather than write infinity.
if !(sx * 100.0).is_finite() || !(sy * 100.0).is_finite() {
return Err(EngineError::Other(format!("layer {} is too small to fit to the composition", lid.0)));
}
let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d);
let (sz, az) = (z("scale", 100.0), z("anchor", 0.0));
set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz]));
update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]);
set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az]));
set(tr, "anchor", KV::Vec3([anchor[0], anchor[1], az]));
}
"flipH" | "flipV" => {
let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]);
+9 -8
View File
@@ -61,7 +61,7 @@ fn quality(s: &mut Session, p: &Value) -> Result<Value> {
q => return Err(bad("layer.quality", format!("quality must be best|draft|wireframe, not `{q}`"))),
};
s.edit("Layer Quality", None, |proj, _| {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) {
l.switches.quality = q;
}
Ok(())
@@ -84,7 +84,7 @@ fn sampling(s: &mut Session, p: &Value) -> Result<Value> {
q => q,
};
s.edit("Layer Sampling", None, |proj, _| {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) {
l.switches.sampling = q;
}
Ok(())
@@ -101,7 +101,7 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result<Value> {
m => return Err(bad("layer.frameBlending", format!("mode must be off|frameMix|pixelMotion, not `{m}`"))),
};
s.edit("Frame Blending", None, |proj, _| {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) {
l.switches.frame_blend = m;
}
Ok(())
@@ -116,7 +116,7 @@ fn hide_other_video(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Hide Other Video", None, |proj, _| {
// Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's
// eye cell shows): the selection stays shown.
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| !ids.contains(&l.id)) {
l.switches.video = false;
}
Ok(())
@@ -130,7 +130,7 @@ fn show_all_video(s: &mut Session, p: &Value) -> Result<Value> {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
// Track mattes stay hidden (their video switch is off by design).
let mattes: Vec<LayerId> = c.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect();
for l in c.layers.iter_mut().filter(|l| !mattes.contains(&l.id)) {
for l in c.layers.iter_mut_where(|l| !mattes.contains(&l.id)) {
l.switches.video = true;
}
Ok(())
@@ -154,7 +154,7 @@ fn expressions(s: &mut Session, p: &Value) -> Result<Value> {
let on = b_p(p, "enabled").unwrap_or(true);
let n = s.edit(if on { "Enable Expressions" } else { "Disable Expressions" }, None, |proj, _| {
let mut n = 0;
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) {
l.props.walk_mut(&mut |pr| {
if let Some(e) = &mut pr.expr {
e.enabled = on;
@@ -586,7 +586,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result<Value> {
let r = s.edit("Lock Markers", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let target = v.unwrap_or_else(|| !c.layers.iter().find(|l| ids.contains(&l.id)).is_some_and(|l| l.markers_locked));
for l in c.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in c.layers.iter_mut_where(|l| ids.contains(&l.id)) {
l.markers_locked = target;
}
Ok(target)
@@ -597,7 +597,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result<Value> {
fn delete_all_markers(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
s.edit("Delete All Markers", None, |proj, _| {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id) && !l.markers_locked) {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id) && !l.markers_locked) {
l.markers.clear();
}
Ok(())
@@ -609,6 +609,7 @@ fn delete_all_markers(s: &mut Session, p: &Value) -> Result<Value> {
fn track_matte_op(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, "layer.trackMatte")?;
let op = str_p(p, "op").ok_or_else(|| bad("layer.trackMatte", "missing `op`"))?.to_string();
let kind = match op.as_str() {
"alpha" => Some(MatteKind::Alpha),
+12 -3
View File
@@ -34,7 +34,7 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
let fd = comp_rate.frame_duration();
s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
let old = l.stretch;
if reverse {
// Keep the in/out points; play the visible span backwards.
@@ -109,7 +109,7 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut on = false;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id) && remappable(l)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id) && remappable(l)) {
let has = l.props.get("timeRemap").is_some();
let target = want.unwrap_or(!has);
on |= target;
@@ -185,7 +185,16 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer"));
}
// What each layer shows now (honouring an existing remap) — evaluated before the edit.
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ectx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: cti,
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
.layers
.iter()
+36 -7
View File
@@ -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
),
+14 -6
View File
@@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the
/// layer (layer time is the nested comp's time), skipping ones already there.
/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers, or the
/// markers a footage file carries (a WAV's cue points), onto the layer (layer time is the source's
/// time), skipping ones already there.
fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let src: Vec<(LayerId, Vec<Marker>)> = {
@@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
let l = comp.layer(*id)?;
match &l.source {
LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())),
LayerSource::Footage { item } => match &s.project.item(*item)?.kind {
effectcraft_project::ItemKind::Footage(f) => Some((*id, f.markers.clone())),
_ => None,
},
_ => None,
}
})
.collect()
};
if src.is_empty() {
return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer"));
return Err(bad("layer.updateMarkersFromSource", "select a precomposition or footage layer"));
}
let n = s.edit("Update Markers From Source", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
@@ -323,8 +328,11 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
if l.markers_locked {
continue;
}
// One set of the times already on the layer: a file with thousands of markers must not
// scan the layer's list for each of them.
let mut times: std::collections::HashSet<_> = l.markers.iter().map(|x| x.time).collect();
for m in ms {
if !l.markers.iter().any(|x| x.time == m.time) {
if times.insert(m.time) {
l.markers.push(m.clone());
n += 1;
}
@@ -339,10 +347,10 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
let c = s.active_comp().ok_or("no composition")?;
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) {
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })) {
Ok(())
} else {
Err("select a precomposition layer".into())
Err("select a precomposition or footage layer".into())
}
}
+1
View File
@@ -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");
+11 -5
View File
@@ -64,6 +64,12 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result<Value> {
if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) {
s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer");
}
if r == Renderer::Classic3D {
let env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: effectcraft_project::LightKind::Environment });
if s.project.comp(cid).is_some_and(|c| c.layers.iter().any(env)) {
s.toast("Classic 3D doesn't light Environment light layers: they light with the Advanced 3D renderer");
}
}
Ok(json!({"renderer": renderer_id(r)}))
}
@@ -77,7 +83,7 @@ fn renderer_id(r: Renderer) -> &'static str {
/// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a
/// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether
/// the renderer changed.
fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {
pub(crate) fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let switched = c.renderer != Renderer::Advanced3D;
c.renderer = Renderer::Advanced3D;
@@ -86,10 +92,10 @@ fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Resu
}
/// Tell the user when [`use_advanced_3d`] changed the comp's renderer.
fn report_switch(s: &mut Session, cid: ItemId, switched: bool) {
pub(crate) fn report_switch(s: &mut Session, cid: ItemId, switched: bool, why: &str) {
if switched {
let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string();
s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models"));
s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, {why}"));
}
}
@@ -156,7 +162,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
let id = insert_layer_at(proj, st, cid, l, index)?;
Ok((id, use_advanced_3d(proj, cid)?))
})?;
report_switch(s, cid, switched);
report_switch(s, cid, switched, "which draws 3D models");
Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true}))
}
@@ -226,7 +232,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
let id = insert_layer(proj, st, cid, l)?;
Ok((id, use_advanced_3d(proj, cid)?))
})?;
report_switch(s, cid, switched);
report_switch(s, cid, switched, "which draws 3D models");
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true}))
}
+20 -3
View File
@@ -78,7 +78,16 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<P
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let pr = layer.props.find(uid).ok_or_else(|| bad(cmd, "no such property"))?;
let t = s.time();
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: t,
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let KV::Path(path) = ctx.value(layer, pr) else { return Err(bad(cmd, "that property is not a path")) };
if path.vertices.is_empty() {
return Err(bad(cmd, "the path has no vertices"));
@@ -147,8 +156,16 @@ fn pin_targets(s: &Session, p: &Value, cmd: &str) -> Result<Option<(ItemId, Vec<
};
let cid = pins.first().map(|x| x.0).ok_or_else(|| bad(cmd, "no puppet pins"))?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: s.time(),
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let mut out = vec![];
for (_, lid, uid) in pins {
let Some(layer) = comp.layer(lid) else { continue };
+47 -24
View File
@@ -5,10 +5,11 @@
use effectcraft_keyframe::{ShapePath, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSource, MaskMotionBlur, Node, PropGroup, Uid};
use effectcraft_render::EvalCtx;
use serde_json::{Value, json};
use super::layer_menu::target_masks;
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, str_p};
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, merge_p, str_p};
use crate::{EngineError, Result, Session, VertexRef, cmd};
// ---------------------------------------------------------------- RotoBezier
@@ -111,18 +112,30 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result<Value> {
/// The parametric shape paths that Convert To Bezier Path replaces with an editable path.
const PARAMETRIC: [&str; 3] = ["rect", "ellipse", "star"];
/// The path a parametric shape group draws, in the group's own space, read at `lt`. This is the
/// geometry the renderer builds from the same values (`render::shapes::collect`), so the converted
/// path is the shape that was on screen rather than an approximation of it; the defaults match
/// that code for a property a group doesn't carry (a polygon has no inner radius or roundness).
fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option<ShapePath> {
let f = |id: &str, d: f64| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_f64());
let v2 = |id: &str, d: [f64; 2]| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_vec2());
/// The path a parametric shape group draws, in the group's own space. This is the geometry the
/// renderer builds from the same values (`render::shapes::collect`), expressions included, so the
/// converted path is the shape that was on screen rather than an approximation of it; the defaults
/// match that code for a property a group doesn't carry (a polygon has no inner radius or
/// roundness).
fn parametric_path(ctx: &EvalCtx, layer: &effectcraft_project::Layer, g: &PropGroup) -> Option<ShapePath> {
let f = |id: &str, d: f64| ctx.f(layer, g, id, d);
let v2 = |id: &str, d: [f64; 2]| ctx.v2(layer, g, id, d);
let path = match g.match_id.as_str() {
"rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)),
"rect" => {
let (size, pos) = (v2("size", [100.0; 2]), v2("position", [0.0; 2]));
if ctx.b(layer, g, "perCorner") {
effectcraft_path::rect_radii(
size,
pos,
[f("topLeftRoundness", 0.0), f("topRightRoundness", 0.0), f("bottomRightRoundness", 0.0), f("bottomLeftRoundness", 0.0)],
)
} else {
effectcraft_path::rect(size, pos, f("roundness", 0.0))
}
}
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
"star" => effectcraft_path::polystar(effectcraft_path::Polystar {
star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
star: ctx.e(layer, g, "type") == 0,
points: f("points", 5.0),
pos: v2("position", [0.0; 2]),
rotation: f("rotation", 0.0),
@@ -160,28 +173,38 @@ fn convert_to_bezier(s: &mut Session, p: &Value) -> Result<Value> {
}
let t = s.time();
let n = shapes.len();
// Read the geometry before editing, with the same expression-aware context the renderer
// uses: an expression-driven corner radius, size or position must convert to what is on
// screen, not to the property's underlying value.
let prepared = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
let mut prepared = Vec::new();
for (lid, uid) in &shapes {
let Some(l) = comp.layer(*lid) else { continue };
let Some(old) = l.props.sub("contents").and_then(|c| c.find_group(*uid)) else { continue };
let Some(path) = parametric_path(&ctx, l, old) else { continue };
// Path Direction is the shape's own, not a default: reversing is a separate command.
prepared.push((*lid, *uid, path, ctx.e(l, old, "direction"), old.enabled));
}
prepared
};
let made = s.edit("Convert To Bezier Path", None, |proj, st| {
let mut next = proj.next_id;
let mut made = Vec::new();
for (lid, uid) in &shapes {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
for (lid, uid, path, direction, enabled) in prepared {
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?;
let Some(old) = contents.find_group(*uid) else { continue };
let Some(path) = parametric_path(old, lt) else { continue };
// Path Direction is the shape's own, not a default: reversing is a separate command.
let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum());
let enabled = old.enabled;
let mut g = build::shape_path(&mut Ids(&mut next), path);
if let Some(pr) = g.prop_mut("direction") {
pr.value = KV::Enum(direction);
}
g.enabled = enabled;
let new_uid = g.uid;
let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue };
let parent = contents.parent_of_mut(uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
let Some(at) = parent.children.iter().position(|c| c.uid() == uid) else { continue };
parent.children[at] = Node::Group(g);
made.push((*lid, new_uid));
made.push((lid, new_uid));
}
proj.next_id = next;
// The new paths are what is selected, so the vertices can be edited straight away.
@@ -308,7 +331,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
let y = v[1] * scale[1];
[x * c - y * sn, x * sn + y * c]
};
let n = s.edit("Free Transform Points", None, |proj, _| {
let n = s.edit("Free Transform Points", merge_p(p), |proj, _| {
let mut n = 0;
for (lid, uid) in &targets {
let l = layer_mut(proj, cid, *lid)?;
@@ -360,7 +383,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
const SHAPE_ITEMS: &[&str] = &[
"group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag",
"wiggle", "merge", "front", "bevel", "side", "back",
"wiggle", "wiggler", "merge", "front", "bevel", "side", "back",
];
/// Selected shape items (contents entries) of shape layers: (layer, uid).
@@ -542,7 +565,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Free Transform Points",
["Layer", "Mask and Shape Path"],
None,
"{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y]}",
"{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y], merge?: string}",
has_layers,
free_transform
),
+20 -2
View File
@@ -1189,6 +1189,15 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
})
}
fn set_muted(s: &mut Session, p: &Value) -> Result<Value> {
let muted = b_p(p, "muted").unwrap_or(true);
let label = if muted { "Mute Keyframes" } else { "Unmute Keyframes" };
edit_keys(s, label, None, move |keys, i, _| {
keys[i].muted = muted;
false
})
}
/// A number or a per-dimension array of numbers.
fn nums_p(p: &Value, k: &str, scale: f64) -> Option<Vec<f64>> {
match p.get(k)? {
@@ -1285,8 +1294,16 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res
let fd = comp.frame_duration();
let mut t = layer.in_point;
while t < layer.out_point {
let ctx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp: &comp,
time: t,
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
t += fd;
}
@@ -1373,6 +1390,7 @@ pub fn specs() -> Vec<CommandSpec> {
&json!({"which": "out"})
)),
cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold),
cmd!("keys.setMuted", "Mute Keyframes", ["Animation"], None, "{muted?: bool (default true)}", has_keys, set_muted),
cmd!(
"keys.interpolation",
"Keyframe Interpolation...",
+2 -1
View File
@@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "the selected items have no proxy"));
}
let it = super::file_more::Interpretation::parse(p, C)?;
it.check(items.iter().filter_map(|i| s.project.item(*i).and_then(|x| x.proxy.as_ref()).map(|px| &px.footage)), C)?;
let guesses: Vec<_> = items
.iter()
.map(|i| {
@@ -229,7 +230,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Proxy...",
["File", "Interpret Footage"],
None,
"{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}",
"{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage), logConversion?, cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff? (Cineon Settings of DPX and Cineon proxies)}",
has_proxy,
interpret_proxy
),
+2 -1
View File
@@ -38,7 +38,8 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
let fx = layer.effects()?;
let index = fx.groups().position(|g| g.uid == fx_uid)?;
let g = fx.groups().nth(index)?;
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx =
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);
+3 -5
View File
@@ -46,11 +46,9 @@ fn summary(s: &mut Session, _: &Value) -> Result<Value> {
o["layers"] = json!(c.layers.len());
o["frameRate"] = json!(c.frame_rate.as_f64());
}
// Footage that shows no picture although its file is there (a codec or profile the
// decoders lack): why, so an agent can tell the user.
if let ItemKind::Footage(f) = &i.kind
&& let Some(why) = s.footage.problem(f)
{
// Footage (or the proxy in use) that shows no picture although its file is there (a
// codec or profile the decoders lack): why, so an agent can tell the user.
if let Some(why) = s.footage.item_problem(i) {
o["problem"] = json!(why);
}
o
+4 -4
View File
@@ -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<()> {
@@ -619,7 +619,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = item.settings.output_size(comp);
let mut crop_only = om.clone();
crop_only.resize.enabled = false;
let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0));
let (cw, ch) = crop_only.frame_size(w, h, item.settings.effective_scale(comp));
om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000);
}
let post = match str_p(p, "postRenderAction") {
@@ -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
),
+12 -3
View File
@@ -48,7 +48,16 @@ impl Env {
Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() }
}
fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> {
EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) }
EvalCtx {
project: proj,
comp_id: cid,
comp,
time: self.time,
expr: self.expr.as_deref(),
footage: Some(self.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
}
}
}
@@ -268,7 +277,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result<Value> {
set_expr(&mut cam, "cameraOptions/zoom", ze);
cam.auto_orient = AutoOrient::TowardsPointOfInterest;
let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?;
comp.layers = vec![cam, nest];
comp.layers = vec![cam, nest].into();
eye_comps.push(eid);
}
// The output comp: both eyes through 3D Glasses.
@@ -291,7 +300,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result<Value> {
fxg.children.push(g.into());
}
let (lid, rid) = (left.id, right.id);
proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right];
proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right].into();
st.project_selection = vec![oid];
Ok(json!({
"controls": ctl_id.0,
+371 -15
View File
@@ -5,7 +5,11 @@
//! 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`.
//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by
//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft
//! quits, after the project is closed (`file.runShutdownScripts`).
//! * **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 +26,23 @@ 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";
/// The settings-store folders whose scripts run at launch and as EffectCraft quits.
pub const STARTUP_DIR: &str = "Scripts/Startup";
pub const SHUTDOWN_DIR: &str = "Scripts/Shutdown";
/// Commands that run script files or script code (a ScriptUI panel runs its script when it
/// opens). Scripts and batches can't run them, and confined automation sessions refuse them.
pub const RUNS_SCRIPTS: &[&str] =
&["script.run", "file.runScript", "file.runStartupScripts", "file.runShutdownScripts", "window.scriptPanel", "window.restoreScriptPanels"];
/// Largest script file the Startup and Shutdown folders run (bytes), as `#include` and
/// `$.evalFile` allow.
const MAX_SCRIPT_BYTES: usize = 16 << 20;
/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled.
const MAX_SHUTDOWN_DIALOGS: usize = 64;
/// [`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 +132,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 +140,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 +183,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" });
}
}
}
}
@@ -203,6 +236,7 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result<Value> {
let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?;
let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR };
cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?;
s.scripts_changed();
// After Effects asks for a restart; our menus pick it up at once.
s.toast(if panel {
format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu")
@@ -216,12 +250,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] {
@@ -231,6 +272,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
removed = true;
}
}
s.scripts_changed();
if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) }
}
@@ -312,17 +354,98 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"opened": opened, "failed": failed}))
}
/// Run the scripts in `dir` of the settings store one after another, in alphabetical order (case
/// ignored), as After Effects runs its Startup and Shutdown folders. A script that fails is
/// reported in a toast and the log and listed in `failed`; the next one still runs. A script waiting in a
/// modal dialog is listed in `waiting` and goes on when the dialog closes; the next one runs
/// meanwhile. With `quitting`, no window is left to show a dialog in, so each dialog closes as
/// if canceled (`show()` returns 2) and the script runs on.
fn run_folder(s: &mut Session, dir: &str, quitting: bool) -> Value {
let mut names: Vec<String> = s.config.as_ref().map(|c| c.list(dir)).unwrap_or_default().into_iter().filter(|n| is_script(n) && is_script_name(n)).collect();
names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
let (mut ran, mut waiting, mut failed) = (vec![], vec![], vec![]);
for name in names {
let code = match s.config.as_ref().map(|c| c.read_bounded(&format!("{dir}/{name}"), MAX_SCRIPT_BYTES)) {
Some(Ok(Some(code))) => code,
read => {
let why = match read {
Some(Err(e)) => format!(": {e}"),
_ => String::new(),
};
let error = format!("{name}: cannot read the script{why}");
log::warn!("{dir}: {error}");
s.events.push(Event::Toast { message: error.clone(), error: true });
failed.push(json!({"name": name, "error": error}));
continue;
}
};
let is_waiting = |r: &Result<Value>| r.as_ref().is_ok_and(|v| v.get("waiting").and_then(Value::as_bool) == Some(true));
// In a settings folder on disk, the script runs as its file, so `#include`, `$.fileName`
// and `$.evalFile` find the files beside it.
let path = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(dir).join(&name).to_string_lossy().into_owned()).unwrap_or_else(|| name.clone());
// Windows open before this script runs belong to other scripts, perhaps of the same name.
let before: Vec<u32> = s.script_ui.windows.iter().map(|w| w.id).collect();
let hosts_before = s.script_ui.task_hosts.clone();
let mut r = super::file_more::run_js_file(s, &code, &path, &name);
if !quitting {
// The Startup script's context: the one that scheduled tasks or holds the windows it
// opened (a task scheduled after its dialog closes runs there too).
let tasks = s.script_ui.task_hosts.iter().filter(|h| !hosts_before.contains(h)).copied();
let windows = s.script_ui.windows.iter().filter(|w| !before.contains(&w.id)).map(|w| w.host);
let hosts: Vec<u32> = tasks.chain(windows).collect();
for h in hosts {
if !s.script_ui.startup_hosts.contains(&h) {
s.script_ui.startup_hosts.push(h);
}
}
}
let mut closed = 0;
while quitting && closed < MAX_SHUTDOWN_DIALOGS && is_waiting(&r) {
let Some(id) = s.script_ui.windows.iter().rev().find(|w| w.modal && w.script == path && !before.contains(&w.id)).map(|w| w.id) else { break };
// report_js reports an error the script throws after its dialog closes, as it does for an
// error in the script's first run.
r = scriptui::close(s, &json!({"window": id})).and_then(|out| super::file_more::report_js(s, &name, out));
closed += 1;
}
match r {
Ok(_) if is_waiting(&r) => waiting.push(name),
Ok(_) => ran.push(name),
Err(e) => {
// Also in the log: no frame draws a Shutdown script's toast.
log::warn!("{dir}: {e}");
failed.push(json!({"name": name, "error": e.to_string()}));
}
}
}
json!({"ran": ran, "waiting": waiting, "failed": failed})
}
/// `file.runStartupScripts`: run the scripts in `Scripts/Startup` (frontends call it once at
/// launch, before a project opens).
fn run_startup(s: &mut Session, _: &Value) -> Result<Value> {
Ok(run_folder(s, STARTUP_DIR, false))
}
/// `file.runShutdownScripts`: close the project, then run the scripts in `Scripts/Shutdown`
/// (frontends call it as the app quits, after the unsaved-changes prompt).
fn run_shutdown(s: &mut Session, _: &Value) -> Result<Value> {
s.replace_project(effectcraft_project::Project::default(), None);
Ok(run_folder(s, SHUTDOWN_DIR, true))
}
/// Window ▸ `<ScriptUI panel>`: run the panel script (or bring its panel forward).
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
let c = "window.scriptPanel";
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string();
let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
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 +461,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 +482,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
),
@@ -378,6 +510,24 @@ pub fn specs() -> Vec<CommandSpec> {
always,
restore_panels
),
cmd!(
"file.runStartupScripts",
"Run Startup Scripts",
[],
None,
"{} → runs the scripts in Scripts/Startup in alphabetical order (frontends call it at launch) → {ran: [name], waiting: [name] (in a modal dialog), failed: [{name, error}]}",
always,
run_startup
),
cmd!(
"file.runShutdownScripts",
"Run Shutdown Scripts",
[],
None,
"{} → closes the project, then runs the scripts in Scripts/Shutdown in alphabetical order (frontends call it as the app quits; their modal dialogs close as if canceled) → {ran: [name], waiting: [name], failed: [{name, error}]}",
always,
run_shutdown
),
crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get),
cmd!(
"script.settings.save",
@@ -427,10 +577,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Mouse Event on Script Window Control",
[],
None,
"{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent",
"{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), buttons? (held: 1 left, 2 right, 4 middle), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent",
always,
scriptui::mouse
),
cmd!(
"scriptui.resize",
"Resize Script Window",
[],
None,
"{window?, size: [width, height] (pixels)} → the window takes the size, lays itself out again and its onResizing / onResize run (a docked ScriptUI panel gets its dock's size)",
always,
scriptui::resize
),
cmd!(
"scriptui.close",
"Close Script Window",
@@ -504,13 +663,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 +689,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");
@@ -775,4 +1071,64 @@ mod tests {
assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed");
let _ = std::fs::remove_dir_all(&root);
}
/// Edit ▸ Keyboard Shortcuts lists the scripts File ▸ Scripts lists (not ScriptUI panels),
/// as soon as one is installed and until it is uninstalled; a key given to one is saved with
/// the preset.
#[test]
fn file_scripts_entries_take_keyboard_shortcuts() {
use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, binding_key};
let (mut s, root) = session_in_tmp("shortcuts");
let (script, panel) = (root.join("Hello.jsx"), root.join("Hello Panel.jsx"));
std::fs::write(&script, "hello").unwrap();
std::fs::write(&panel, "panel").unwrap();
let listed = |s: &Session, name: &str| {
s.shortcuts().find(&binding_key("file.runScript", &json!({"name": name}))).map(|b| (b.label.clone(), b.path.join(" ▸ "), b.defaults.clone()))
};
assert_eq!(listed(&s, "Rename Layers.jsx"), Some(("Rename Layers.jsx".into(), "File ▸ Scripts".into(), vec![])), "the bundled samples");
assert_eq!(listed(&s, "Hello.jsx"), None);
s.execute("file.installScript", json!({"path": script.to_string_lossy()})).unwrap();
s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap();
assert_eq!(listed(&s, "Hello.jsx"), Some(("Hello.jsx".into(), "File ▸ Scripts".into(), vec![])));
assert_eq!(listed(&s, "Hello Panel.jsx"), None, "panels are in the Window menu");
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Hello.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap();
let key = binding_key("file.runScript", &json!({"name": "Hello.jsx"}));
assert_eq!((&r["command"], &r["keys"]), (&json!(key), &json!(["Cmd+Alt+Shift+F7"])), "{r}");
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"name": "Hello.jsx"})), Some("Cmd+Alt+Shift+F7"));
let saved = Keymaps::from_json(&std::fs::read_to_string(root.join("settings").join(SHORTCUTS_FILE)).unwrap());
assert_eq!(saved.active_preset().and_then(|p| p.overrides.get(&key)), Some(&vec!["Cmd+Alt+Shift+F7".to_string()]));
s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap();
assert_eq!(listed(&s, "Hello.jsx"), None);
let _ = std::fs::remove_dir_all(&root);
}
/// `shortcuts.list` and `shortcuts.set` see a script copied into the Scripts folder by hand
/// after the shortcut table was built; a script the folder doesn't hold (never there, or
/// removed by hand) can't take a key, and Run Script File… keeps its own keys.
#[test]
fn shortcut_commands_find_scripts_copied_into_the_folder() {
let (mut s, root) = session_in_tmp("copied");
assert!(s.shortcuts().find("file.runScript").is_some(), "the table is built");
std::fs::create_dir_all(root.join("settings").join(SCRIPTS_DIR)).unwrap();
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx"), "copied").unwrap();
let list = s.execute("shortcuts.list", json!({"query": "copied.jsx"})).unwrap();
assert_eq!(list["count"], 1, "{list}");
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap();
assert_eq!(r["command"], r#"file.runScript {"name":"Copied.jsx"}"#, "{r}");
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Missing.jsx"}, "keys": "Cmd+Alt+Shift+F8"}));
assert!(bad_params(&r), "{r:?}");
// The binding-key form finds a script copied in since the last lookup, too.
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx"), "second").unwrap();
let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F10"})).unwrap();
assert_eq!(r["command"], r#"file.runScript {"name":"Second.jsx"}"#, "{r}");
// Removed by hand: no longer in the folder.
std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx")).unwrap();
std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx")).unwrap();
let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F11"}));
assert!(bad_params(&r), "{r:?}");
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F9"}));
assert!(bad_params(&r), "{r:?}");
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &Value::Null), None, "Run Script File… has no key");
let _ = std::fs::remove_dir_all(&root);
}
}
+14 -4
View File
@@ -73,13 +73,21 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result<Value> {
// ---------------------------------------------------------------- shortcuts
/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`).
fn bindable(s: &Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {
/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). A
/// File ▸ Scripts entry (`file.runScript {"name": …}`) is looked up in File ▸ Scripts' list as it
/// is now (the Scripts folder and the bundled scripts), and a script it doesn't list is an error,
/// not Run Script File….
fn bindable(s: &mut Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {
let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
let t = s.shortcuts();
let key = binding_key(c, &params);
t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)")))
let script = (c == "file.runScript" && params.get("name").is_some()) || c.starts_with("file.runScript {");
if script {
s.scripts_changed();
}
let t = s.shortcuts();
let found = if script { t.find(&key) } else { t.find(&key).or_else(|| t.find(c)) };
found.cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)")))
}
fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
@@ -93,6 +101,8 @@ fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
}
fn sc_list(s: &mut Session, p: &Value) -> Result<Value> {
// Scripts copied into the Scripts folder since the table was built are listed too.
s.scripts_changed();
let q = str_p(p, "query").unwrap_or("").to_lowercase();
let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false);
let keys_q = str_p(p, "keys").and_then(normalize);
+7 -6
View File
@@ -180,16 +180,17 @@ fn rgba(c: [f32; 3]) -> [f64; 4] {
[c[0] as f64, c[1] as f64, c[2] as f64, 1.0]
}
/// The Rounded Rectangle tool's corner roundness for `size`.
fn roundness(size: [f64; 2]) -> f64 {
size[0].min(size[1]) * 0.15
/// The Rounded Rectangle tool's corner roundness: a fixed default, as in After Effects
/// (it does not grow with the drawn size).
fn roundness() -> f64 {
20.0
}
/// The Polygon and Star tools' Polystar for `size`: (star, points, outer radius, inner radius).
fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> {
match kind {
"star" => Some((true, 5.0, size[0] / 2.0, size[0] / 4.0)),
"polygon" => Some((false, 6.0, size[0] / 2.0, 0.0)),
"polygon" => Some((false, 5.0, size[0] / 2.0, 0.0)),
_ => None,
}
}
@@ -199,7 +200,7 @@ fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> {
pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &ShapeTool) -> Option<PropGroup> {
let (path, gname) = match kind {
"rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"),
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness(size)), "Rectangle 1"),
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness()), "Rectangle 1"),
"ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"),
k => {
let (star, points, outer, inner) = polystar_of(k, size)?;
@@ -218,7 +219,7 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePa
let bez = match kind {
"rect" | "rectangle" => return Some(ShapePath::rect(centre, size[0], size[1])),
"ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])),
"rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness(size)),
"rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness()),
k => {
let (star, points, outer, inner) = polystar_of(k, size)?;
effectcraft_path::polystar(effectcraft_path::Polystar {
+5 -1
View File
@@ -142,6 +142,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result<Value> {
}
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
let auid = animator_uid(s, &layer, p, C)?;
let mut index = 0;
s.edit("Add Text Animator Property", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
@@ -151,10 +152,13 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result<Value> {
for k in &kinds {
add_props(&mut ids, anim, k, three);
}
// The requested property's 1-based position in the Properties group. A companion (such as
// Tracking Type) is added before it, so the first new child is not always the one asked for.
index = kinds.first().and_then(|k| anim.sub("properties")?.children.iter().position(|n| n.match_id() == k.as_str())).map_or(0, |i| i + 1);
proj.next_id = next;
Ok(())
})?;
Ok(json!({"animator": auid}))
Ok(json!({"animator": auid, "index": index}))
}
pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
+9 -12
View File
@@ -71,7 +71,8 @@ pub struct TextEdit {
/// Character panel changes made with only a caret: the style the next typed text takes.
#[serde(default)]
pub pending: Option<CharStyle>,
/// The layer was made by a Type tool click; leaving it empty deletes it.
/// The layer was made by a Type tool click. Kept when left empty, as After Effects does, so this
/// only records how the edit began.
#[serde(default)]
pub created: bool,
/// Line position kept by repeated up / down moves (layout space).
@@ -372,18 +373,14 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
Ok(state_json(s))
}
/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted).
/// `text.endEdit`: stop editing.
///
/// A Type-tool layer the user clicked away from without typing is **kept**: After Effects leaves it in
/// the timeline as `<空白のテキストレイヤー>`, and the layer came from the user's click rather than from a
/// mistake to clean up. Deleting it took away something they could still type into.
pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) };
if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
s.edit("Delete Empty Text Layer", None, |proj, st| {
if let Some(c) = proj.comp_mut(cid) {
c.layers.retain(|l| l.id != e.layer);
}
st.selected_layers.retain(|l| *l != e.layer);
Ok(())
})?;
if s.state.text_edit.take().is_none() {
return Ok(Value::Null);
}
s.bump();
Ok(Value::Null)
+23 -10
View File
@@ -12,6 +12,7 @@ use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::layer::{color_p, insert_layer};
use super::model3d::{report_switch, use_advanced_3d};
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, layer_mut, layer_p, merge_p, str_p, time_p};
use crate::{EngineError, Result, Session, cmd, query};
@@ -227,12 +228,17 @@ fn new_light(s: &mut Session, p: &Value) -> Result<Value> {
};
let name = str_p(p, "name").unwrap_or("Light 1").to_string();
let t = time_p(s, p, Some(&comp));
let id = s.edit("New Light", None, |proj, st| {
// Only the Advanced 3D renderer lights with Environment lights, so adding one switches a
// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step).
let env = kind == LightKind::Environment;
let (id, switched) = s.edit("New Light", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Light { kind }, (comp.width, comp.height), None);
apply_light(&mut l, p, t, "layer.newLight")?;
insert_layer(proj, st, cid, l)
let id = insert_layer(proj, st, cid, l)?;
Ok((id, if env { use_advanced_3d(proj, cid)? } else { false }))
})?;
Ok(json!({"layer": id.0}))
report_switch(s, cid, switched, "which Environment lights need to light the scene");
Ok(json!({"layer": id.0, "advanced3d": env}))
}
fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
@@ -248,7 +254,7 @@ fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
),
None => None,
};
s.edit("Light Settings", None, |proj, _| {
let switched = s.edit("Light Settings", None, |proj, _| {
let old = proj.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?;
let mut l = old.clone();
if let Some(k) = kind
@@ -288,9 +294,14 @@ fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
l.name = n.clone();
}
apply_light(&mut l, p, t, "layer.lightSettings")?;
// A light that becomes an Environment light switches a Classic 3D comp to Advanced 3D,
// like adding one does; editing one that already is leaves the renderer to the user.
let was_env = matches!(old.source, LayerSource::Light { kind: LightKind::Environment });
let is_env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment });
*layer_mut(proj, cid, lid)? = l;
Ok(())
Ok(if !was_env && is_env { use_advanced_3d(proj, cid)? } else { false })
})?;
report_switch(s, cid, switched, "which Environment lights need to light the scene");
Ok(Value::Null)
}
@@ -413,7 +424,8 @@ fn get_3d(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = (comp.width as f64, comp.height as f64);
let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default();
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx =
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx));
let f = cam.forward();
let lights: Vec<Value> = comp
@@ -532,7 +544,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
};
s.edit("Auto-Orient", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
let rig = l.is_camera() || l.is_light();
let ok = match mode {
AutoOrient::Off | AutoOrient::AlongPath => true,
@@ -555,7 +567,8 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
/// through each layer's world matrix; cameras and lights contribute their position).
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx =
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
let mut out = vec![];
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
let m = ctx.world_matrix(l);
@@ -681,7 +694,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Light...",
["Layer", "New"],
Some("Cmd+Alt+Shift+L"),
"{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer, see layer.environment), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",
"{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer or a flat default environment of its colour and intensity, see layer.environment; an Environment light switches a Classic 3D comp to Advanced 3D), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",
has_comp,
new_light
),
@@ -708,7 +721,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Light Settings...",
[],
None,
"{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",
"{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment (changing to Environment switches a Classic 3D comp to Advanced 3D), color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",
has_comp,
light_settings
),
+4
View File
@@ -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)]
+260 -32
View File
@@ -9,31 +9,35 @@
//! render reads the new pixels.
//!
//! The automatic part compares each footage file's size and modification time with what they
//! were when the file was last seen. The desktop app takes those stamps every two seconds (and
//! when its window comes back to the front) on a worker thread, so a file on a slow network share
//! never holds up the interface ([`start_scan`], [`poll_scan`]); `footage.reloadChanged` takes them
//! on the spot (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload
//! records the state of the disk: it is not an undo step and doesn't modify the project.
//! were when the file was last seen. The desktop app keeps a [`Watcher`]: a worker thread that
//! stamps the files every two seconds while the window is in front (every ten seconds while it
//! is not, and at once when it comes back to the front) and wakes the interface only when a stamp
//! changed, so a file on a slow network share never holds up the interface and an idle app never
//! redraws for footage that didn't change; `footage.reloadChanged` takes the stamps on the spot
//! (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload records
//! the state of the disk: it is not an undo step and doesn't modify the project.
use std::collections::BTreeSet;
use std::sync::{Arc, Mutex};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, SourceLayer};
use serde_json::{Value, json};
use crate::Session;
use crate::{Services, Session};
/// A file's size and modification time (see [`crate::Services::stamp`]).
pub type Stamp = (u64, u128);
/// Stamps taken on a worker thread, waiting for [`poll_scan`].
pub type Scan = Arc<Mutex<Option<Vec<(String, Option<Stamp>)>>>>;
/// The stamps of a set of files, in path order.
pub type Stamps = Vec<(String, Option<Stamp>)>;
/// The files footage items read: each item's file and every frame of an image sequence. Image
/// sequences count only with Automatically Reload Footage set to All Footage (After Effects'
/// default, Non-Sequence Footage, leaves them alone: thousands of frames on a share add up).
/// Items with a stored path that is not followed ([`crate::doc_path`]) are not watched.
fn watched(s: &Session) -> BTreeSet<String> {
pub fn watched(s: &Session) -> BTreeSet<String> {
let sequences = s.prefs.import.auto_reload_footage == "all";
let mut paths = BTreeSet::new();
for it in s.project.items.values() {
@@ -81,6 +85,8 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
nf.codec = probed.codec;
nf.has_video = probed.has_video;
nf.has_audio = probed.has_audio;
// The markers the file carries now (a WAV's cue points), for Update Markers From Source.
nf.markers = probed.markers;
if matches!(f.kind, FootageKind::Video | FootageKind::Audio) {
nf.duration = probed.duration;
// Interpret Footage ▸ Conform to frame rate stays; the file's own rate is refreshed.
@@ -265,31 +271,253 @@ pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result<Value> {
apply_stamps(s, now)
}
/// Start stamping the watched files on a worker thread, unless a scan is still running (or the
/// build has no threads: the web app, whose footage lives in browser storage).
pub fn start_scan(s: &mut Session) {
if s.footage_scan.is_some() || cfg!(target_arch = "wasm32") {
return;
/// How often the watched files are stamped while the window is in front.
pub const FOCUSED_INTERVAL: Duration = Duration::from_secs(2);
/// How often they are stamped while another app has the front: a file saved there is noticed
/// as soon as the window comes back, so this only covers a second screen.
pub const UNFOCUSED_INTERVAL: Duration = Duration::from_secs(10);
enum Watch {
Paths(BTreeSet<String>),
Focused(bool),
}
/// The desktop app's footage watcher: a worker thread that stamps the watched files and hands
/// the stamps over ([`Watcher::take`]) only when one of them changed since it last looked,
/// waking the interface through the callback given to [`Watcher::start`]. The interface tells it
/// what to watch and whether the window is in front; nothing watched means the thread sleeps.
/// Dropping the watcher ends the thread.
pub struct Watcher {
tx: Sender<Watch>,
result: Arc<Mutex<Option<Stamps>>>,
paths: BTreeSet<String>,
focused: Option<bool>,
}
impl Watcher {
/// Start the worker, stamping through `services`; `wake` runs on the worker whenever new
/// stamps are ready to [`Watcher::take`]. `None` where there are no threads (the web app,
/// whose footage lives in browser storage) or one couldn't be started.
pub fn start(services: Arc<dyn Services>, wake: impl Fn() + Send + Sync + 'static) -> Option<Watcher> {
Self::start_with(services, wake, FOCUSED_INTERVAL, UNFOCUSED_INTERVAL)
}
let paths = watched(s);
if paths.is_empty() {
return;
/// [`Watcher::start`] with its own stamping intervals (tests).
pub fn start_with(services: Arc<dyn Services>, wake: impl Fn() + Send + Sync + 'static, focused: Duration, unfocused: Duration) -> Option<Watcher> {
if cfg!(target_arch = "wasm32") {
return None;
}
let (tx, rx) = std::sync::mpsc::channel();
let result: Arc<Mutex<Option<Stamps>>> = Arc::default();
let out = Arc::clone(&result);
std::thread::Builder::new().name("footage-watch".into()).spawn(move || watch(&rx, &*services, &out, &wake, focused, unfocused)).ok()?;
Some(Watcher { tx, result, paths: BTreeSet::new(), focused: None })
}
let services = s.services.clone();
let slot: Scan = Arc::default();
let out = slot.clone();
let spawned = std::thread::Builder::new().name("footage-watch".into()).spawn(move || {
let stamps = paths.into_iter().map(|p| (p.clone(), services.stamp(&p))).collect();
*out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stamps);
});
if spawned.is_ok() {
s.footage_scan = Some(slot);
/// The files to watch ([`watched`]); a changed set is stamped at once.
pub fn set_watched(&mut self, paths: BTreeSet<String>) {
if paths != self.paths {
self.paths = paths.clone();
let _ = self.tx.send(Watch::Paths(paths));
}
}
/// Whether the window is in front; coming back to the front stamps the files at once.
pub fn set_focused(&mut self, focused: bool) {
if self.focused != Some(focused) {
self.focused = Some(focused);
let _ = self.tx.send(Watch::Focused(focused));
}
}
/// Stamps the worker found changed (or the first ones for a new set of files), once; for
/// [`apply_stamps`]. `None` while nothing changed.
pub fn take(&self) -> Option<Stamps> {
self.result.lock().unwrap_or_else(PoisonError::into_inner).take()
}
}
/// Apply a finished scan (`None` while there is none, or it is still running).
pub fn poll_scan(s: &mut Session) -> Option<crate::Result<Value>> {
let stamps = s.footage_scan.as_ref()?.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?;
s.footage_scan = None;
Some(apply_stamps(s, stamps))
/// The watcher's worker: wait for a message or the interval, stamp, report only changes.
fn watch(
rx: &Receiver<Watch>,
services: &dyn Services,
out: &Mutex<Option<Stamps>>,
wake: &(dyn Fn() + Send + Sync),
focused_every: Duration,
unfocused_every: Duration,
) {
let mut paths = BTreeSet::new();
let mut focused = true;
let mut last: Option<Stamps> = None;
loop {
let msg = if paths.is_empty() {
rx.recv().map_err(|_| RecvTimeoutError::Disconnected)
} else {
rx.recv_timeout(if focused { focused_every } else { unfocused_every })
};
match msg {
Ok(Watch::Paths(p)) => {
paths = p;
// A new set is reported whole, so the session forgets files no longer watched.
last = None;
}
Ok(Watch::Focused(f)) => {
let regained = f && !focused;
focused = f;
if !regained {
continue;
}
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return,
}
if paths.is_empty() {
continue;
}
let stamps: Stamps = paths.iter().map(|p| (p.clone(), services.stamp(p))).collect();
if last.as_ref() != Some(&stamps) {
last = Some(stamps.clone());
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps);
wake();
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod watcher_tests {
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
/// Files whose stamps the test sets; counts how often they were stamped.
#[derive(Default)]
struct Disk {
stamps: Mutex<HashMap<String, Stamp>>,
looks: AtomicUsize,
}
impl Disk {
fn set(&self, path: &str, stamp: Stamp) {
self.stamps.lock().unwrap_or_else(PoisonError::into_inner).insert(path.into(), stamp);
}
}
impl Services for Disk {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
}
fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> {
Err(std::io::Error::other("read-only"))
}
fn stamp(&self, path: &str) -> Option<Stamp> {
self.looks.fetch_add(1, Ordering::SeqCst);
self.stamps.lock().unwrap_or_else(PoisonError::into_inner).get(path).copied()
}
}
const TICK: Duration = Duration::from_millis(20);
const LONG: Duration = Duration::from_secs(60);
fn wait_for(w: &Watcher, wakes: &AtomicUsize, n: usize) -> Stamps {
let t0 = std::time::Instant::now();
loop {
if wakes.load(Ordering::SeqCst) >= n
&& let Some(s) = w.take()
{
return s;
}
assert!(t0.elapsed() < Duration::from_secs(10), "no report {n} within 10 s");
std::thread::sleep(Duration::from_millis(5));
}
}
/// Wait (at most 10 s) until `ready`: a slow machine's worker gets there late, never early.
fn wait_until(what: &str, mut ready: impl FnMut() -> bool) {
let t0 = std::time::Instant::now();
while !ready() {
assert!(t0.elapsed() < Duration::from_secs(10), "{what} within 10 s");
std::thread::sleep(Duration::from_millis(5));
}
}
fn start(disk: &Arc<Disk>, focused: Duration, unfocused: Duration) -> Option<(Watcher, Arc<AtomicUsize>)> {
let wakes = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&wakes);
let w = Watcher::start_with(
Arc::clone(disk) as Arc<dyn Services>,
move || {
counter.fetch_add(1, Ordering::SeqCst);
},
focused,
unfocused,
)?;
Some((w, wakes))
}
/// The interface is woken for the first stamps of a set and for every change, and for
/// nothing else: an unchanged file polled many times is no wakeup at all.
#[test]
fn the_watcher_wakes_the_interface_only_when_a_stamp_changes() {
let disk = Arc::new(Disk::default());
disk.set("/a.psd", (10, 1));
let (mut w, wakes) = start(&disk, TICK, LONG).unwrap();
w.set_focused(true);
w.set_watched(["/a.psd".to_string()].into());
assert_eq!(wait_for(&w, &wakes, 1), vec![("/a.psd".to_string(), Some((10, 1)))]);
let looked = disk.looks.load(Ordering::SeqCst);
wait_until("it keeps stamping while the window is in front", || disk.looks.load(Ordering::SeqCst) > looked + 3);
assert_eq!(wakes.load(Ordering::SeqCst), 1, "an unchanged file is no wakeup");
assert!(w.take().is_none());
disk.set("/a.psd", (12, 2));
assert_eq!(wait_for(&w, &wakes, 2), vec![("/a.psd".to_string(), Some((12, 2)))]);
assert_eq!(wakes.load(Ordering::SeqCst), 2);
}
/// Behind another app the files are stamped only at the slow interval; the window coming
/// back to the front stamps them at once.
#[test]
fn behind_another_app_the_watcher_rests_until_the_window_is_back() {
let disk = Arc::new(Disk::default());
disk.set("/b.psd", (1, 1));
let (mut w, wakes) = start(&disk, TICK, LONG).unwrap();
w.set_focused(false);
w.set_watched(["/b.psd".to_string()].into());
wait_for(&w, &wakes, 1);
let looked = disk.looks.load(Ordering::SeqCst);
disk.set("/b.psd", (2, 2));
std::thread::sleep(TICK * 15);
assert_eq!(disk.looks.load(Ordering::SeqCst), looked, "no stamping at the front interval while behind");
assert_eq!(wakes.load(Ordering::SeqCst), 1);
w.set_focused(true);
assert_eq!(wait_for(&w, &wakes, 2), vec![("/b.psd".to_string(), Some((2, 2)))]);
}
/// Nothing to watch: the worker sleeps, stamping nothing; a new set is stamped at once and
/// reported whole, and dropping the watcher ends the worker.
#[test]
fn nothing_watched_is_nothing_stamped_and_a_new_set_is_reported_whole() {
let disk = Arc::new(Disk::default());
disk.set("/c.psd", (1, 1));
disk.set("/d.psd", (2, 2));
let (mut w, wakes) = start(&disk, TICK, TICK).unwrap();
w.set_focused(true);
std::thread::sleep(TICK * 10);
assert_eq!(disk.looks.load(Ordering::SeqCst), 0);
w.set_watched(["/c.psd".to_string(), "/d.psd".to_string()].into());
assert_eq!(wait_for(&w, &wakes, 1), vec![("/c.psd".to_string(), Some((1, 1))), ("/d.psd".to_string(), Some((2, 2)))]);
w.set_watched(["/d.psd".to_string()].into());
assert_eq!(wait_for(&w, &wakes, 2), vec![("/d.psd".to_string(), Some((2, 2)))], "the set is reported whole, so the session forgets /c.psd");
w.set_watched(BTreeSet::new());
// The worker may already be past its wait with the old set: that one round of stamps
// (one file) can still come, and nothing after it.
let looked = disk.looks.load(Ordering::SeqCst);
std::thread::sleep(TICK * 10);
assert!(disk.looks.load(Ordering::SeqCst) <= looked + 1, "nothing watched, nothing stamped");
let result = Arc::downgrade(&w.result);
drop(w);
let t0 = std::time::Instant::now();
while result.upgrade().is_some() {
assert!(t0.elapsed() < Duration::from_secs(5), "the worker is still running");
std::thread::sleep(Duration::from_millis(5));
}
}
}
+11 -2
View File
@@ -197,7 +197,6 @@ fn sort_entries(entries: &mut [Entry]) {
entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase()));
}
const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"];
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"];
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"];
@@ -207,7 +206,8 @@ const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"];
pub fn kind_of(path: &str) -> Option<&'static str> {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
let e = ext.as_str();
if VIDEO.contains(&e) {
// (An animated GIF plays as a movie.)
if e == "gif" || effectcraft_project::VIDEO_EXTENSIONS.contains(&e) {
Some("video")
} else if IMAGE.contains(&e) {
Some("image")
@@ -431,6 +431,15 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
/// Every movie type the importer reads lists as video, as an animated GIF does.
#[test]
fn movies_list_as_video() {
for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain(&["gif"]) {
assert_eq!(kind_of(&format!("/clips/A.{}", ext.to_uppercase())), Some("video"), "{ext}");
}
assert_eq!(kind_of("/clips/capture.avi"), Some("video"));
}
#[test]
fn virtual_tree_lists_like_a_folder() {
let mut t = VirtualTree::default();
+3 -4
View File
@@ -45,14 +45,13 @@ impl SequenceOptions {
/// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After
/// Effects, while a folder's numbered stills import as sequences unless `sequence: false`
/// (Alt-drag of a folder). The Import dialog passes its `"<format> Sequence"` checkbox (off
/// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered
/// still brings its run, as it always has.
/// unless ticked) as `sequence`. Without `sequence` (scripts, agents) a numbered still imports
/// as itself, as `ImportOptions.sequence` defaults to false in After Effects (#2018).
pub fn for_import(p: &serde_json::Value) -> SequenceOptions {
let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool);
let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false));
let dropped = flag("drag").unwrap_or(false);
// Force Alphabetical Order is part of the Sequence option.
SequenceOptions { sequence: sequence.unwrap_or(alphabetical || !dropped), folders: sequence.unwrap_or(true), alphabetical }
SequenceOptions { sequence: sequence.unwrap_or(alphabetical), folders: sequence.unwrap_or(true), alphabetical }
}
}
+3 -3
View File
@@ -121,7 +121,7 @@ fn lower_third(p: &mut Project) -> Comp {
[440.0, 914.0],
);
anim(&mut title, "transform/opacity", keys(&[(0.4, Value::Scalar(0.0)), (0.9, Value::Scalar(100.0))]));
c.layers = vec![title, bar];
c.layers = vec![title, bar].into();
c
}
@@ -270,7 +270,7 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp {
);
anim(&mut caption, "transform/opacity", keys(&[(0.3, Value::Scalar(0.0)), (1.2, Value::Scalar(100.0))]));
c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor];
c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor].into();
c
}
@@ -463,7 +463,7 @@ pub fn demo_project() -> Project {
lower.out_point = t(9.5);
// Stack: top first.
comp.layers = vec![lower, title, sub, line, burst, rings, null, bg];
comp.layers = vec![lower, title, sub, line, burst, rings, null, bg].into();
for l in &mut comp.layers {
if l.name == "Controller" {
l.switches.video = false;
+81 -3
View File
@@ -645,6 +645,21 @@ fn base_name(path: &str) -> String {
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("footage").to_string()
}
/// A template entry's name, made safe to write inside a folder: a template is a file like any
/// other and can name its entries anything. Nothing a platform reads as a separator, a drive or
/// the folder above survives, and a name that leaves nothing usable becomes `footage` (#2123).
fn safe_file_name(name: &str) -> String {
let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
// `.` and `..`, and names of dots and spaces alone (Windows drops the trailing ones).
if name.trim_matches(['.', ' ']).is_empty() { "footage".to_string() } else { name }
}
/// What two entry names must differ by to be sure of two files: a filesystem may ignore case, and
/// Win32 ignores trailing dots and spaces.
fn same_file_key(name: &str) -> String {
name.trim_end_matches(['.', ' ']).to_lowercase()
}
/// What [`embed_footage`] did.
#[derive(Default)]
struct Embedded {
@@ -738,9 +753,21 @@ fn extract_footage(s: &Session, proj: &mut Project, entries: &[(String, Vec<u8>)
let mut used = std::collections::HashSet::new();
for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) {
// `media/<item>/<file>` → one folder; a name another item took gets the item's id.
let file = base_name(k);
let item = k.trim_start_matches("media/").split('/').next().unwrap_or("0");
let name = if used.insert(file.clone()) { file } else { format!("{item}-{file}") };
let file = safe_file_name(&base_name(k));
// The item's id, and only that: it is written as one ([`embed_footage`]), but a template
// from elsewhere can hold anything there, and `..\` in it walks out of `dir` on Windows.
let item = k.trim_start_matches("media/").split('/').next().filter(|i| i.bytes().all(|b| b.is_ascii_digit())).unwrap_or("0");
// A name another entry has taken gets the item's id, and a number if that is taken too:
// the names an entry can ask for are not distinct, and two files must not become one.
// They are compared the way a filesystem might: `CLIP.png` is `clip.png` on a
// case-insensitive volume (Windows and macOS as they come), and Win32 drops a trailing
// dot or space, so `a.png.` is `a.png` there. The name written stays as it is.
let mut name = file.clone();
let mut n = 0usize;
while !used.insert(same_file_key(&name)) {
n += 1;
name = if n == 1 { format!("{item}-{file}") } else { format!("{item}-{n}-{file}") };
}
let out = format!("{dir}/{name}");
s.services.store_file(&out, v).map_err(|e| EngineError::Other(format!("cannot write {out}: {e}")))?;
if let Some(imp) = &s.importer {
@@ -970,6 +997,57 @@ mod tests {
assert_eq!(l.iter().find(|t| t.id == "lower-third").unwrap().controls, ["Name", "Title", "Bar Color"]);
}
/// #2123: a template holds its footage as `media/<item id>/<file>` entries, but a template
/// from anywhere else can name them anything — and `..\\`, which only this platform reads as
/// a plain character, is a separator on Windows, so a second entry with a base name already
/// taken wrote `<dir>/..\\..\\..\\Startup\\x-evil.bat`. Whatever an entry is called, the file
/// lands in the footage folder.
#[test]
fn extracted_footage_stays_in_its_folder() {
let s = Session::default();
let mut proj = effectcraft_project::Project::default();
let dir = std::env::temp_dir().join(format!("ec-templates-media-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let ds = dir.to_string_lossy().into_owned();
let entry = |k: &str| (k.to_string(), b"x".to_vec());
let entries = [
entry("media/0/clip.mp4"),
// The base name is taken, so this one's "item id" is used — and it is not one.
entry("media/..\\..\\..\\Startup\\x/clip.mp4"),
entry("media/1/C:evil.bat"),
// Three that collide once the first is made safe: each still gets its own file.
entry("media/1/a:b.png"),
entry("media/2/a_b.png"),
entry("media/3/2-a_b.png"),
// The same file on a case-insensitive volume, and on Windows, which drops the dot.
entry("media/4/clip.png"),
entry("media/5/CLIP.png"),
entry("media/6/a.png"),
entry("media/7/a.png."),
];
let written = extract_footage(&s, &mut proj, &entries, &ds).unwrap();
assert_eq!(written.len(), entries.len());
for w in &written {
let name = w.strip_prefix(&format!("{ds}/")).unwrap_or_else(|| panic!("{w} is outside {ds}"));
assert!(!name.contains(['/', '\\', ':']), "{w}");
assert!(!name.trim_matches(['.', ' ']).is_empty(), "{w}");
}
// One file each, all of them in the folder, none overwritten by the next.
assert_eq!(written.iter().collect::<std::collections::BTreeSet<_>>().len(), entries.len(), "{written:?}");
assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), entries.len());
let _ = std::fs::remove_dir_all(&dir);
// And the names that would leave nothing to write.
for n in ["..", ".", " .. ", "...", ""] {
assert_eq!(safe_file_name(n), "footage", "{n:?}");
}
assert_eq!(safe_file_name("clip.mp4"), "clip.mp4");
assert_eq!(safe_file_name(".hidden.mp4"), ".hidden.mp4");
assert_eq!(safe_file_name("a/b\\c:d"), "a_b_c_d");
// Names a filesystem may not tell apart (only the case half of this shows on macOS).
assert_eq!(same_file_key("A.PNG. "), same_file_key("a.png"));
assert_ne!(same_file_key("a.png"), same_file_key("b.png"));
}
#[test]
fn create_opens_an_untitled_copy() {
let mut s = Session::default();
+2
View File
@@ -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"),
];
+1 -3
View File
@@ -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;
@@ -240,8 +241,6 @@ pub struct Session {
/// Each footage file's size and modification time when last seen (Settings ▸ Import ▸
/// Reload Footage Changed on Disk, [`footage_reload`]).
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
/// The stamps being taken on a worker thread ([`footage_reload::start_scan`]).
pub footage_scan: Option<footage_reload::Scan>,
/// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]).
pub disk_salt: Option<(u64, u64)>,
/// How many script-issued commands are running ([`Session::execute_for_script`]): while one
@@ -350,7 +349,6 @@ impl Default for Session {
stored_paths: Default::default(),
footage_stamps: Default::default(),
disk_salt: None,
footage_scan: None,
script_calls: ScriptCalls::default(),
}
}
+29 -1
View File
@@ -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 {
@@ -1010,6 +1036,8 @@ Animation
---
Add Keyframe | anim.addKeyframe
Toggle Hold Keyframe | keys.toggleHold
Mute Keyframes | keys.setMuted
Unmute Keyframes | keys.setMuted {"muted":false}
Keyframe Interpolation... | keys.interpolation
Keyframe Velocity... | keys.velocity
Keyframe Assistant
Loaded 100 of 503 files, more files were not shown because too many files have changed in this diff. Show more