mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 19:47:12 -06:00
Merge branch 'main' into expr-property-group-1619
This commit is contained in:
@@ -18,16 +18,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
Generated
+1
@@ -1455,6 +1455,7 @@ dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
||||
@@ -92,6 +92,9 @@ filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a
|
||||
filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned
|
||||
# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that.
|
||||
symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] }
|
||||
|
||||
# gpu (the same wgpu eframe / egui-wgpu use)
|
||||
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
|
||||
|
||||
@@ -263,8 +263,18 @@ fn value_arg(s: &str) -> Value {
|
||||
serde_json::from_str(s).unwrap_or_else(|_| json!(s))
|
||||
}
|
||||
|
||||
/// The command line as text. `std::env::args` panics on an argument that is not Unicode (a
|
||||
/// Latin-1 file name on Linux). Every argument is used as a string, and a lossy copy would read or
|
||||
/// write another file than the one named, so such an argument is a usage error naming it.
|
||||
fn utf8_args(raw: impl IntoIterator<Item = std::ffi::OsString>) -> Result<Vec<String>, String> {
|
||||
raw.into_iter().map(|a| a.into_string().map_err(|a| format!("argument `{}` is not valid Unicode", a.to_string_lossy()))).collect()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||
let raw = match utf8_args(std::env::args_os().skip(1)) {
|
||||
Ok(raw) => raw,
|
||||
Err(e) => fail_usage(&e),
|
||||
};
|
||||
if matches!(raw.first().map(String::as_str), Some("--version" | "-V" | "version")) {
|
||||
say!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
@@ -493,6 +503,9 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
};
|
||||
let mut b = backend(args, true)?;
|
||||
let r = b.exec(&id, params)?;
|
||||
if let Some(e) = failed_render(&id, &r) {
|
||||
return Err(Failure::Error(e));
|
||||
}
|
||||
let saved = maybe_save(&mut b, args)?;
|
||||
emit(&with_saved(r, saved), json_out);
|
||||
}
|
||||
@@ -515,6 +528,9 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
};
|
||||
i += 1;
|
||||
let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?;
|
||||
if let Some(e) = failed_render(id, &r) {
|
||||
return Err(Failure::Error(format!("{id}: {e}")));
|
||||
}
|
||||
if !json_out {
|
||||
emit(&r, false);
|
||||
}
|
||||
@@ -637,6 +653,21 @@ fn positional<const N: usize>(args: &Args, usage: &str) -> Result<[String; N], F
|
||||
Ok(std::array::from_fn(|i| args.pos[i].clone()))
|
||||
}
|
||||
|
||||
/// `renderQueue.render` reports a failed item in its result, not as an error. Under `run`/`exec`
|
||||
/// that item fails the command (#1426), as it fails `render --queue`.
|
||||
fn failed_render(id: &str, r: &Value) -> Option<String> {
|
||||
if id != "renderQueue.render" {
|
||||
return None;
|
||||
}
|
||||
let failed: Vec<String> = r["items"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|i| Some(format!("{}: {}", i["compName"].as_str().unwrap_or("?"), i["status"].get("Failed")?.as_str().unwrap_or("failed"))))
|
||||
.collect();
|
||||
(!failed.is_empty()).then(|| failed.join("; "))
|
||||
}
|
||||
|
||||
fn with_saved(v: Value, saved: Option<String>) -> Value {
|
||||
match saved {
|
||||
None => v,
|
||||
@@ -660,11 +691,10 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
let mut s = session(args)?;
|
||||
if args.opt("--template").is_some() {
|
||||
template::prepare(&mut s, args)?;
|
||||
} else {
|
||||
match &args.project {
|
||||
Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?,
|
||||
None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?,
|
||||
};
|
||||
} else if let Some(p) = &args.project {
|
||||
s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?;
|
||||
} else if args.flag("--demo") || !args.flag("--empty") {
|
||||
s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?;
|
||||
}
|
||||
let err = |e: effectcraft_engine::EngineError| Failure::Error(e.to_string());
|
||||
if !args.flag("--queue") {
|
||||
@@ -1270,6 +1300,18 @@ mod tests {
|
||||
assert!(a.flag("--work-area") && a.flag("--json"));
|
||||
}
|
||||
|
||||
/// `std::env::args` panicked on an argument that is not Unicode; it is a usage error naming
|
||||
/// it, and Unicode arguments pass through unchanged.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_arguments_are_refused() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let args = [OsString::from("render-frame"), OsString::from("--out"), OsString::from_vec(b"caf\xe9.png".to_vec())];
|
||||
assert_eq!(utf8_args(args).unwrap_err(), "argument `caf\u{fffd}.png` is not valid Unicode");
|
||||
assert_eq!(utf8_args([OsString::from("info"), OsString::from("caf\u{e9}.png")]).unwrap(), ["info", "caf\u{e9}.png"]);
|
||||
}
|
||||
|
||||
/// Every `--option` the CLI reads is declared in VALUED or FLAGS, so none is refused.
|
||||
#[test]
|
||||
fn every_option_read_is_declared() {
|
||||
|
||||
@@ -36,7 +36,20 @@ pub(super) fn validate(cmd: &str, args: &Args) -> Result<(), Failure> {
|
||||
{
|
||||
for input in ["--template", "--values"] {
|
||||
if let Some(path) = args.opt(input)
|
||||
&& std::fs::canonicalize(path).is_ok_and(|path| path == output)
|
||||
&& std::fs::canonicalize(path).is_ok_and(|path| {
|
||||
if path == output {
|
||||
return true;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// Distinct canonical paths can still name the same file through hard links.
|
||||
if let (Ok(input), Ok(output)) = (std::fs::metadata(&path), std::fs::metadata(&output)) {
|
||||
return input.dev() == output.dev() && input.ino() == output.ino();
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
{
|
||||
return Err(Failure::Error(format!("--out {out} would overwrite {input} {path}")));
|
||||
}
|
||||
|
||||
@@ -25,12 +25,19 @@ fn ok_json(args: &[&str]) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
/// `std::fs::canonicalize` without Windows' `\\?\` verbatim prefix, which a child's `current_dir()` never reports.
|
||||
fn canonical(path: &std::path::Path) -> std::io::Result<PathBuf> {
|
||||
let path = std::fs::canonicalize(path)?;
|
||||
let plain = path.to_str().and_then(|s| s.strip_prefix(r"\\?\")).filter(|s| s.as_bytes().get(1) == Some(&b':'));
|
||||
Ok(plain.map(PathBuf::from).unwrap_or(path))
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
// macOS's `/var` is a symlink to `/private/var`: a child process resolves its working
|
||||
// directory through `current_dir()`, which is canonical, so compare canonical paths.
|
||||
std::fs::canonicalize(&d).unwrap_or(d).join(name)
|
||||
canonical(&d).unwrap_or(d).join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -160,6 +167,21 @@ fn unknown_options_are_usage_errors() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An argument that is not Unicode (a Latin-1 file name on Linux) is a usage error (exit 2)
|
||||
/// naming it, before anything runs: `std::env::args` panicked on one (exit 101).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_arguments_are_usage_errors() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png");
|
||||
let out = bin().args(["render-frame", "--out"]).arg(name).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "{err}");
|
||||
assert!(err.contains("argument `caf\u{fffd}.png` is not valid Unicode"), "{err}");
|
||||
assert!(!err.contains("panicked"), "{err}");
|
||||
assert!(out.stdout.is_empty(), "nothing ran");
|
||||
}
|
||||
|
||||
/// A reader that closes stdout before the CLI writes (`| head`) is not a crash: the command
|
||||
/// still does its work and exits 0, without a panic (#167).
|
||||
#[test]
|
||||
@@ -260,7 +282,7 @@ fn render_out_is_relative_to_the_working_directory() {
|
||||
std::fs::create_dir_all(&proj).unwrap();
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
// current_dir resolves aliases such as macOS /var -> /private/var.
|
||||
let root = root.canonicalize().unwrap();
|
||||
let root = canonical(&root).unwrap();
|
||||
let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere"));
|
||||
let project = proj.join("x.ecproj");
|
||||
let p = project.to_str().unwrap();
|
||||
@@ -462,6 +484,32 @@ fn mcp_autosave_is_opt_in_and_rejects_invalid_combinations() {
|
||||
std::fs::remove_dir_all(config).unwrap();
|
||||
}
|
||||
|
||||
/// `run`/`exec renderQueue.render` (#1426): a failed item is a command error, exit 1 like
|
||||
/// `render --queue`, not a success with the failure buried in the JSON.
|
||||
#[test]
|
||||
fn a_failed_render_under_run_and_exec_exits_1() {
|
||||
let root = tmp("render-fails");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
// The output's folder is a file, so the encoder cannot open the output.
|
||||
let blocker = root.join("not-a-folder");
|
||||
std::fs::write(&blocker, "x").unwrap();
|
||||
let out = blocker.join("out.gif");
|
||||
let add = json!({"comp": "T", "format": "gif", "output": out.to_str().unwrap()}).to_string();
|
||||
let project = root.join("q.ecproj");
|
||||
let steps = ["comp.new", r#"{"name":"T","width":16,"height":16,"duration":0.1}"#, "layer.newSolid", "{}", "renderQueue.add", &add];
|
||||
ok_json(&[&["run", "--empty", "--save-as", project.to_str().unwrap()][..], &steps[..]].concat());
|
||||
|
||||
let (code, v) = run_json(&[&["run", "--empty"][..], &steps[..], &["renderQueue.render", r#"{"wait":true}"#][..]].concat());
|
||||
assert_eq!(code, 1, "{v}");
|
||||
let e = v["error"].as_str().unwrap_or_else(|| panic!("{v}"));
|
||||
assert!(e.starts_with("renderQueue.render: T: "), "{e}");
|
||||
|
||||
let (code, v) = run_json(&["exec", "renderQueue.render", "--project", project.to_str().unwrap()]);
|
||||
assert_eq!(code, 1, "{v}");
|
||||
assert!(v["error"].as_str().is_some_and(|e| e.starts_with("T: ")), "{v}");
|
||||
}
|
||||
|
||||
/// `render --bitrate` (#440) targets the bitrate in VP9 WebM.
|
||||
#[test]
|
||||
fn render_bitrate_applies_to_vp9_webm() {
|
||||
@@ -491,3 +539,57 @@ fn render_bitrate_applies_to_vp9_webm() {
|
||||
assert!(high > low, "100 kbps: {low} bytes, 8000 kbps: {high} bytes");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Render uses the same empty/project/demo selection as the other CLI commands.
|
||||
#[test]
|
||||
fn render_empty_does_not_load_the_demo() {
|
||||
let root = tmp("render-empty");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let project = root.join("control.ecproj");
|
||||
let template = root.join("control.ectemplate");
|
||||
let project_path = project.to_str().unwrap();
|
||||
let template_path = template.to_str().unwrap();
|
||||
let build = r#"
|
||||
var comp = app.project.items.addComp("Empty control", 64, 36, 1, 1, 30);
|
||||
var solid = comp.layers.addSolid([0, 1, 0], "Green", 64, 36, 1);
|
||||
solid.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Opacity");
|
||||
"#;
|
||||
ok_json(&["script", "--eval", build, "--save-as", project_path]);
|
||||
let params = json!({"comp": "Empty control", "path": template_path}).to_string();
|
||||
let exported = ok_json(&["exec", "essential.exportTemplate", ¶ms, "--project", project_path]);
|
||||
assert_eq!(exported["controls"], 1, "{exported}");
|
||||
let empty = ok_json(&["info", "--empty"]);
|
||||
assert!(empty["project"]["items"].as_array().unwrap().is_empty(), "{empty}");
|
||||
|
||||
let render = |options: &[&str], name: &str| {
|
||||
let output = root.join(name);
|
||||
assert!(!output.exists());
|
||||
let mut args = vec!["render", "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", output.to_str().unwrap()];
|
||||
args.extend_from_slice(options);
|
||||
let reply = ok_json(&args);
|
||||
assert_eq!(reply["rendered"].as_array().unwrap().len(), 1, "{reply}");
|
||||
assert_eq!(PathBuf::from(reply["rendered"][0]["output"].as_str().unwrap()), output);
|
||||
let bytes = std::fs::read(&output).unwrap();
|
||||
assert!(bytes.len() >= 10 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")), "{reply}");
|
||||
let dimensions = (u16::from_le_bytes([bytes[6], bytes[7]]), u16::from_le_bytes([bytes[8], bytes[9]]));
|
||||
(reply["rendered"][0]["comp"].clone(), dimensions)
|
||||
};
|
||||
// Defaults still render the demo, and an explicit demo wins over --empty.
|
||||
let demo = render(&[], "default.gif");
|
||||
assert_eq!(render(&["--demo", "--empty"], "demo.gif"), demo);
|
||||
// A supplied project wins over both flags, while template mode also accepts --empty.
|
||||
let project_result = render(&["--project", project_path, "--demo", "--empty"], "project.gif");
|
||||
assert_eq!(project_result, (json!("Empty control"), (16, 9)));
|
||||
assert_eq!(render(&["--template", template_path, "--empty"], "template.gif").1, (16, 9));
|
||||
|
||||
let output = root.join("empty.gif");
|
||||
assert!(!output.exists());
|
||||
let (code, reply) =
|
||||
run_json(&["render", "--empty", "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", output.to_str().unwrap()]);
|
||||
let output_exists = output.exists();
|
||||
assert!(!output_exists, "--empty must not render the demo into the requested output");
|
||||
assert_eq!(code, 1, "{reply}");
|
||||
assert_eq!(reply["error"], "command `renderQueue.add` is not available right now: select or open a composition", "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ fn template_output_alias_fixture(case: &str) -> PathBuf {
|
||||
dir
|
||||
}
|
||||
|
||||
fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) {
|
||||
fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) -> Value {
|
||||
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
let values_before = std::fs::read(dir.join("values.json")).unwrap();
|
||||
// Read the inputs before interpreting the subprocess status or error text.
|
||||
@@ -272,7 +272,8 @@ fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) {
|
||||
);
|
||||
assert!(template_preserved, "template rendering must preserve its template input when --out aliases that file");
|
||||
assert!(values_preserved, "template rendering must preserve its values input when --out aliases that file");
|
||||
assert!(!result.status.success(), "an output alias must fail before rendering overwrites an input");
|
||||
assert_eq!(result.status.code(), Some(1), "an output alias must fail before rendering overwrites an input");
|
||||
serde_json::from_slice(&result.stdout).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -652,3 +653,39 @@ fn template_missing_output_does_not_replace_extracted_media() {
|
||||
assert!(media_preserved, "missing --out must fail before overwriting previously extracted media");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn template_frame_output_cannot_alias_its_template_through_a_hard_link() {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let dir = template_output_alias_fixture("hard-linked-template");
|
||||
let input = dir.join("lt.ectemplate");
|
||||
let output = dir.join("hard-linked-template.png");
|
||||
std::fs::hard_link(&input, &output).unwrap();
|
||||
let input_metadata = std::fs::metadata(&input).unwrap();
|
||||
let output_metadata = std::fs::metadata(&output).unwrap();
|
||||
assert_eq!((input_metadata.dev(), input_metadata.ino()), (output_metadata.dev(), output_metadata.ino()), "actual hard-link file identity");
|
||||
assert_ne!(input.canonicalize().unwrap(), output.canonicalize().unwrap(), "the hard link has a distinct canonical path");
|
||||
let reply = assert_template_output_preserves_inputs(&dir, "hard-linked-template.png");
|
||||
assert_eq!(reply["error"], "--out hard-linked-template.png would overwrite --template lt.ectemplate", "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn template_frame_output_cannot_alias_its_values_through_a_hard_link() {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let dir = template_output_alias_fixture("hard-linked-values");
|
||||
let input = dir.join("values.json");
|
||||
let output = dir.join("hard-linked-values.png");
|
||||
std::fs::hard_link(&input, &output).unwrap();
|
||||
let input_metadata = std::fs::metadata(&input).unwrap();
|
||||
let output_metadata = std::fs::metadata(&output).unwrap();
|
||||
assert_eq!((input_metadata.dev(), input_metadata.ino()), (output_metadata.dev(), output_metadata.ino()), "actual hard-link file identity");
|
||||
assert_ne!(input.canonicalize().unwrap(), output.canonicalize().unwrap(), "the hard link has a distinct canonical path");
|
||||
let reply = assert_template_output_preserves_inputs(&dir, "hard-linked-values.png");
|
||||
assert_eq!(reply["error"], "--out hard-linked-values.png would overwrite --values values.json", "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -144,6 +144,10 @@ impl Services for WebServices {
|
||||
let dir = dir.trim_end_matches('/');
|
||||
list().into_iter().map(|(p, _)| p).filter(|p| p.rsplit_once('/').is_some_and(|(d, _)| d == dir)).collect()
|
||||
}
|
||||
/// The table's files (the default asks `std::fs`, which has no files on wasm32).
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
get(path).is_some()
|
||||
}
|
||||
fn is_dir(&self, _path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -441,6 +441,18 @@ try {
|
||||
const comps = await js(`effectcraft.inspect().then(i => i.activeComp)`);
|
||||
if (!comps) throw new Error("reopened project has no active comp");
|
||||
report.steps.reopen = { ...opened, activeComp: comps };
|
||||
|
||||
// Files the page holds (browser storage, no std::fs): a script opens one next to the project,
|
||||
// and the footage check finds the imported media instead of marking it missing.
|
||||
const hf = await js(`(async () => {
|
||||
const p = await effectcraft.addFile(new File(["from the page"], "script-data.txt"));
|
||||
await effectcraft.saveToBrowser("/host-files.ecproj");
|
||||
const run = await effectcraft.execute("script.run", {code: "var f = new File(" + JSON.stringify(p) + "); writeLn(f.open('r') ? f.read() : f.error);"});
|
||||
return {output: run.output, footage: await effectcraft.execute("footage.check", {wait: true})};
|
||||
})()`);
|
||||
report.steps.hostFiles = hf;
|
||||
check(hf.output === "from the page", `a script opens a file the page holds (${hf.output})`);
|
||||
check(hf.footage.checked > 0 && hf.footage.missing === 0, `stored footage isn't missing (${JSON.stringify(hf.footage)})`);
|
||||
report.steps.final = { screenshot: await shot("02-after") };
|
||||
|
||||
// Persistence: the session, imported media, settings and a project saved to browser storage
|
||||
|
||||
@@ -75,6 +75,15 @@ sync_github_name = true
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.moecr7]
|
||||
[people.onlymoein]
|
||||
real_name = "Moein Saharkhiz"
|
||||
display_name = "onlymoein"
|
||||
|
||||
[people.arnav-hiwarkar]
|
||||
display_name = "arnav-hiwarkar"
|
||||
sync_github_name = false
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -98,6 +98,9 @@ pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
|
||||
match id {
|
||||
"ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false,
|
||||
"ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false,
|
||||
// #1195: CC Particle World's motion trails (the averaged shutter samples) render on the
|
||||
// CPU kernel until the GPU particle pass supports them.
|
||||
"ec.sim.ccparticleworld" if ctx.env.shutter.filter(|(a, _, _)| *a > 0.0).is_some() => return false,
|
||||
_ => {}
|
||||
}
|
||||
let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true };
|
||||
@@ -199,6 +202,14 @@ mod tests {
|
||||
assert!(ok("ec.color.lumetri", &[]));
|
||||
assert!(!ok("ec.color.lumetri", &[("hslSecondary/correction/saturation", Value::Scalar(50.0))]));
|
||||
assert!(!ok("ec.noise.addgrain", &[("preset", Value::Enum(2))]));
|
||||
// #1195: CC Particle World's motion trails (shutter samples) render on the CPU.
|
||||
{
|
||||
let params = ctx_with("ec.sim.ccparticleworld", &[]);
|
||||
let env = crate::EffectEnv { frame_rate: 30.0, shutter: Some((180.0, -90.0, 16)), ..Default::default() };
|
||||
let ctx = crate::EffectCtx { params: ¶ms, time: 0.0, layer_size: [64.0, 64.0], seed: 0, adjustment: false, env };
|
||||
assert!(!super::gpu_supported("ec.sim.ccparticleworld", &ctx));
|
||||
}
|
||||
assert!(ok("ec.sim.ccparticleworld", &[]));
|
||||
assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))]));
|
||||
assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))]));
|
||||
assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))]));
|
||||
|
||||
@@ -326,20 +326,27 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 {
|
||||
v.abs().powf(p).copysign(v)
|
||||
}
|
||||
|
||||
/// Black & White: a pixel's gray is its smallest channel plus its chroma (largest − smallest
|
||||
/// channel) times the colour sliders' weight for its hue. A neutral pixel keeps its value, a pure
|
||||
/// hue becomes its slider's percentage (Reds 40 % turns pure red into 40 % gray), and the gray
|
||||
/// never moves further from the smallest channel than the pixel's own chroma times the weight.
|
||||
/// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted
|
||||
/// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright
|
||||
/// and black blocks (#1463), and 32-bpc overbrights divided by zero.
|
||||
fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")]
|
||||
.map(|v| v as f32 / 100.0);
|
||||
let tint_on = ctx.params.b("tint");
|
||||
let tc = ctx.params.color("tintColor");
|
||||
b.img.map_straight(|c| {
|
||||
let (h, s, _) = rgb_to_hsl(c[0], c[1], c[2]);
|
||||
let (h, _, _) = rgb_to_hsl(c[0], c[1], c[2]);
|
||||
// Interpolate the six hue weights around the colour wheel.
|
||||
let hp = h * 6.0;
|
||||
let i = hp.floor() as usize % 6;
|
||||
let f = hp - hp.floor();
|
||||
let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f;
|
||||
let base = luminance(c[0], c[1], c[2]);
|
||||
let gray = (base + (wt - 0.5) * s * 0.6).max(0.0);
|
||||
let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2]));
|
||||
let gray = (min + (max - min) * wt).max(0.0);
|
||||
if tint_on {
|
||||
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
|
||||
let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0));
|
||||
@@ -1220,6 +1227,66 @@ mod tests {
|
||||
assert!(go(0.0, 100.0, 64) >= 30);
|
||||
}
|
||||
|
||||
/// #1463: Black & White's sliders set the gray of each pure hue and blend between
|
||||
/// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright
|
||||
/// colour stays finite (it used to divide by zero).
|
||||
#[test]
|
||||
fn black_white_sliders_set_the_gray_of_each_hue() {
|
||||
let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]);
|
||||
let gray = |v: f32| [v, v, v, 1.0];
|
||||
// The defaults: Reds 40, Yellows 60, Greens 40, Cyans 60, Blues 20, Magentas 80 (%).
|
||||
let hues = [
|
||||
([1.0, 0.0, 0.0], 0.4),
|
||||
([1.0, 1.0, 0.0], 0.6),
|
||||
([0.0, 1.0, 0.0], 0.4),
|
||||
([0.0, 1.0, 1.0], 0.6),
|
||||
([0.0, 0.0, 1.0], 0.2),
|
||||
([1.0, 0.0, 1.0], 0.8),
|
||||
// Half as bright, half the gray; orange is halfway from Reds to Yellows.
|
||||
([0.5, 0.0, 0.0], 0.2),
|
||||
([1.0, 0.5, 0.0], 0.5),
|
||||
// Neutral pixels keep their value.
|
||||
([0.3, 0.3, 0.3], 0.3),
|
||||
([4.0, 4.0, 4.0], 4.0),
|
||||
// An overbright red: its smallest channel plus its chroma at Reds' 40 %.
|
||||
([1.5, 0.5, 0.5], 0.9),
|
||||
];
|
||||
for (c, want) in hues {
|
||||
let o = bw(&[], c);
|
||||
assert!(close(o, gray(want), 1e-5), "{c:?}: {o:?}, want {want}");
|
||||
}
|
||||
assert!(close(bw(&[("reds", num(120.0))], [1.0, 0.0, 0.0]), gray(1.2), 1e-5));
|
||||
assert!(close(bw(&[("reds", num(-200.0))], [1.0, 0.0, 0.0]), gray(0.0), 1e-5));
|
||||
assert!(close(bw(&[("blues", num(300.0))], [0.1, 0.1, 0.3]), gray(0.7), 1e-5));
|
||||
}
|
||||
|
||||
/// #1463: faint colour noise stays faint. The shadows of compressed footage carry a code or
|
||||
/// two of colour noise, which Black & White turned into bright and black blocks (the HSL
|
||||
/// saturation it scaled is near 1 for any tinted near-black pixel). At 8, 16 and 32 bpc the
|
||||
/// gray of a dark ramp with ±2 steps of colour noise stays within 2 steps of the clean ramp's.
|
||||
#[test]
|
||||
fn black_white_keeps_faint_colour_noise_faint() {
|
||||
// A step of 8 bpc, of 16 bpc (After Effects' 0..32768), and a small 32-bpc float one.
|
||||
for step in [1.0 / 255.0, 1.0 / 32768.0, 1e-3_f32] {
|
||||
let (w, h) = (64u32, 4u32);
|
||||
let (mut clean, mut noisy) = (Image::new(w, h), Image::new(w, h));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// A dark ramp up to a quarter gray, in whole steps...
|
||||
let v = (x as f32 / w as f32 * 0.25 / step).round() * step;
|
||||
// ...and up to ±2 steps of noise per channel, mixed differently in each pixel.
|
||||
let n = |k: u32| (((x * 7 + y * 13 + k * 2) % 5) as f32 - 2.0) * step;
|
||||
clean.set(x, y, [v, v, v, 1.0]);
|
||||
noisy.set(x, y, [(v + n(0)).max(0.0), (v + n(1)).max(0.0), (v + n(2)).max(0.0), 1.0]);
|
||||
}
|
||||
}
|
||||
let bw = |img| run_fx("ec.color.blackwhite", &[], img, 0.0, EffectEnv::default()).img;
|
||||
let (a, b) = (bw(clean), bw(noisy));
|
||||
let worst = a.data.iter().zip(&b.data).map(|(p, q)| (p[0] - q[0]).abs()).fold(0.0, f32::max);
|
||||
assert!(worst <= 2.0 * step + 1e-6, "step {step}: the noise moved the gray by {} steps", worst / step);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_clips_black_and_white_independently() {
|
||||
let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0];
|
||||
|
||||
@@ -589,7 +589,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("meshColor", "Mesh Color", col(0.5, 0.5, 0.5), ParamUi::Color),
|
||||
// The strokes (see `LiquifyStroke`); written by the `liquify.stroke` command.
|
||||
p("distortionMesh", "Distortion Mesh", Value::Str(String::new()), ParamUi::Hidden),
|
||||
p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Hidden),
|
||||
p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Point),
|
||||
p("distortionPercentage", "Distortion Percentage", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("distortionDisplayQuality", "Distortion Display Quality", Value::Enum(1), popup(&["Draft", "Smooth"])),
|
||||
],
|
||||
@@ -672,6 +672,35 @@ mod tests {
|
||||
assert_eq!(zero.img.data, img.data);
|
||||
let again = run_fx("ec.distort.liquify", &[("distortionMesh", Value::Str(s.to_line()))], img.clone(), 0.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
// #1194: Distortion Mesh Offset is an exposed, editable control (AE's Distortion ▸
|
||||
// Offset) and it moves the mesh: the pattern rendered at x renders at x + offset.
|
||||
let spec = crate::find("ec.distort.liquify").unwrap();
|
||||
assert!(
|
||||
spec.params.iter().any(|ps| ps.id == "distortionMeshOffset" && ps.ui == effectcraft_project::ParamUi::Point),
|
||||
"offset is shown in Effect Controls"
|
||||
);
|
||||
let shifted = run_fx(
|
||||
"ec.distort.liquify",
|
||||
&[("distortionMesh", Value::Str(s.to_line())), ("distortionMeshOffset", Value::Vec2([20.0, 0.0]))],
|
||||
img.clone(),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
assert_ne!(shifted.img.data, out.img.data, "the offset changes the render");
|
||||
// The displaced region's centroid moves right by the offset (the mesh is sampled at
|
||||
// layer minus offset), and the row far left of the shifted mesh is back to untouched.
|
||||
let centroid = |o: &Image| -> f32 {
|
||||
let (mut wsum, mut msum) = (0.0f32, 0.0f32);
|
||||
for (i, (p, q)) in o.data.iter().zip(img.data.iter()).enumerate() {
|
||||
let d: f32 = (0..4).map(|k| (p[k] - q[k]).abs()).sum();
|
||||
wsum += d;
|
||||
msum += d * (i % 64) as f32;
|
||||
}
|
||||
if wsum > 1e-6 { msum / wsum } else { 0.0 }
|
||||
};
|
||||
let (co, cs) = (centroid(&out.img), centroid(&shifted.img));
|
||||
assert!((cs - co - 20.0).abs() < 5.0, "centroids {co} -> {cs}");
|
||||
assert_eq!(shifted.img.get(5, 32), img.get(5, 32), "left of the shifted mesh: untouched");
|
||||
// Reconstruction undoes the warp; freeze protects.
|
||||
let mut rec = s.clone();
|
||||
rec.tool = 9;
|
||||
|
||||
@@ -138,7 +138,10 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let n = (effectcraft_raster::hash_noise(x as u32, y as u32, seed) - 0.5) * jitter * (8.0 / 255.0);
|
||||
c = c.map(|v| v + n);
|
||||
}
|
||||
*px = gen_blend(*px, [c[0] * op, c[1] * op, c[2] * op, op], mode);
|
||||
// None keeps the layer's alpha (#1381), as the Gradient Ramp does: the gradient fills
|
||||
// a shape or text, not its bounding box.
|
||||
let a = if gen_mode(mode).is_none() { op * px[3] } else { op };
|
||||
*px = gen_blend(*px, [c[0] * a, c[1] * a, c[2] * a, a], mode);
|
||||
}
|
||||
});
|
||||
b
|
||||
@@ -420,6 +423,25 @@ mod generator_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_color_none_stays_inside_the_layer_alpha() {
|
||||
// #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box.
|
||||
let mut img = Image::filled(40, 24, [1.0, 0.0, 0.0, 1.0]);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
let a = [0.0, 0.5, 1.0][i % 40 / 14];
|
||||
*p = p.map(|c| c * a);
|
||||
}
|
||||
for op in [100.0, 50.0] {
|
||||
let o = run_fx("ec.generate.fourcolor", &[("opacity", num(op))], img.clone(), 0.0, EffectEnv::default());
|
||||
let k = op as f32 / 100.0;
|
||||
for (src, out) in img.data.iter().zip(&o.img.data) {
|
||||
assert!((src[3] * k - out[3]).abs() < 1e-6, "opacity {op}: alpha {} became {}", src[3], out[3]);
|
||||
assert!(out[..3].iter().all(|&c| c <= out[3] + 1e-6), "opacity {op}: premultiplied colour above alpha");
|
||||
}
|
||||
assert!(o.img.data.iter().any(|p| p[1] > 0.1), "opacity {op}: the gradient replaces the layer's colour");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkerboard_blending_none_shows_pattern_only_and_size_from_width() {
|
||||
let set = [("width", num(8.0)), ("height", num(2.0))];
|
||||
|
||||
@@ -239,6 +239,8 @@ fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) {
|
||||
// A path typed as text, or set only by commands, that got a Choose… button (Apply Color
|
||||
// LUT's LUT, the OCIO files, #514).
|
||||
(ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => pr.ui = ps.ui.clone(),
|
||||
// An internal point that became a control (Liquify's Distortion Mesh Offset, #1194).
|
||||
(ParamUi::Hidden, ParamUi::Point) => pr.ui = ParamUi::Point,
|
||||
// Same kind of value: take the spec's hints (units, ranges).
|
||||
(a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(),
|
||||
_ => {}
|
||||
@@ -514,6 +516,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1194: Liquify's Distortion Mesh Offset was hidden; old projects show it, keeping its value.
|
||||
#[test]
|
||||
fn a_hidden_point_that_became_a_control_is_shown() {
|
||||
let mut next = 1;
|
||||
let (spec, mut g) = instance("ec.distort.liquify", &mut next);
|
||||
let pr = g.get_mut("distortionMeshOffset").unwrap();
|
||||
pr.ui = ParamUi::Hidden;
|
||||
pr.value = Value::Vec2([20.0, -5.0]);
|
||||
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 100.0]));
|
||||
let pr = g.get("distortionMeshOffset").unwrap();
|
||||
assert_eq!((&pr.ui, &pr.value), (&ParamUi::Point, &Value::Vec2([20.0, -5.0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_moves_params_into_twirl_down_groups() {
|
||||
let spec = find(crate::warp_stab::ID).unwrap();
|
||||
|
||||
@@ -238,9 +238,22 @@ impl SpritePlan {
|
||||
}
|
||||
|
||||
/// Rasterise (after [`SpritePlan::resolve`]) and meet the original layer `b`.
|
||||
pub fn finish(&self, mut b: Buf) -> Buf {
|
||||
pub fn finish(&self, b: Buf) -> Buf {
|
||||
let fx = splat(b.img.width, b.img.height, &self.sprites, self.acc);
|
||||
b.img = match self.post {
|
||||
self.finish_with(b, fx)
|
||||
}
|
||||
|
||||
/// [`finish`](Self::finish) with a prepared sprite image (the motion-blurred average of
|
||||
/// several frames, #1195).
|
||||
pub fn finish_with(&self, b: Buf, fx: Image) -> Buf {
|
||||
self.post.apply(b, fx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Post {
|
||||
/// Meet the sprite image `fx` with the original layer `b` (see [`SpritePlan::finish`]).
|
||||
fn apply(self, mut b: Buf, fx: Image) -> Buf {
|
||||
b.img = match self {
|
||||
Post::Combine(m) => combine(&b.img, &fx, m),
|
||||
Post::Replace => fx,
|
||||
Post::Lerp(t) => {
|
||||
@@ -1069,6 +1082,29 @@ fn pw_phys(ctx: &EffectCtx) -> Phys {
|
||||
}
|
||||
|
||||
fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
// #1195: with the comp's motion-blur shutter open, particles draw as motion trails: the
|
||||
// sprite fields of samples across the shutter are averaged, as Transform averages its
|
||||
// matrix samples. (The GPU compositor runs this case on the CPU: catalog::gpu_supported.)
|
||||
if let Some((angle, phase, n)) = ctx.env.shutter.filter(|(a, _, _)| *a > 0.0) {
|
||||
let n = (n.clamp(2, 64) as usize).max(2);
|
||||
let fd = 1.0 / ctx.fps().max(0.001);
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
let mut post: Option<Post> = None;
|
||||
for k in 0..n {
|
||||
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
|
||||
let sub = EffectCtx { params: ctx.params, time: t, layer_size: ctx.layer_size, seed: ctx.seed, adjustment: ctx.adjustment, env: ctx.env };
|
||||
if let Some(plan) = particle_world_plan(&sub, &b) {
|
||||
let fx = splat(b.img.width, b.img.height, &plan.sprites, plan.acc);
|
||||
acc.data.iter_mut().zip(&fx.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
post.get_or_insert(plan.post);
|
||||
}
|
||||
}
|
||||
acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= n as f32));
|
||||
return match post {
|
||||
Some(p) => p.apply(b, acc),
|
||||
None => b,
|
||||
};
|
||||
}
|
||||
match particle_world_plan(ctx, &b) {
|
||||
Some(plan) => plan.finish(b),
|
||||
None => b,
|
||||
@@ -1715,6 +1751,31 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
/// #1195: with the comp's shutter open, the particles smear across it: the average of the
|
||||
/// sampled sprite fields differs from the sharp frame, touches more pixels and keeps the
|
||||
/// alpha energy.
|
||||
#[test]
|
||||
fn particle_world_motion_blur_trails() {
|
||||
let v = pw_vals(21.0);
|
||||
let blank = Image::new(40, 40);
|
||||
let sharp = run("ec.sim.ccparticleworld", &v, blank.clone(), 2.0);
|
||||
let blurred = crate::run_fx(
|
||||
"ec.sim.ccparticleworld",
|
||||
&v,
|
||||
blank.clone(),
|
||||
2.0,
|
||||
EffectEnv { frame_rate: 10.0, shutter: Some((360.0, -180.0, 8)), ..Default::default() },
|
||||
)
|
||||
.img;
|
||||
assert!(sharp.data.iter().any(|p| p[3] > 0.0), "sharp frame has particles");
|
||||
assert_ne!(sharp.data, blurred.data, "the shutter smears the particles");
|
||||
let lit = |img: &Image| img.data.iter().filter(|p| p[3] > 0.005).count();
|
||||
let sum = |img: &Image| img.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
assert!(lit(&blurred) > lit(&sharp), "trails light more pixels: {} vs {}", lit(&blurred), lit(&sharp));
|
||||
let (sa, ba) = (sum(&sharp), sum(&blurred));
|
||||
assert!((ba - sa).abs() < sa * 0.6 + 1.0, "alpha energy roughly kept: {ba} vs {sa}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_world_seek_consistent() {
|
||||
let v = pw_vals(11.0);
|
||||
|
||||
@@ -245,7 +245,8 @@ fn has_footage(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
/// `File.execute()` from scripts (and agents): open a file with its default application.
|
||||
/// Needs Allow Scripts to Write Files and Access Network; with Warn User When Executing Files on,
|
||||
/// the frontend asks first (`confirmed: true` skips the question).
|
||||
/// the frontend asks first. `confirmed: true` is the frontend's answer to that question: a
|
||||
/// script can't give it for itself (#1246), so a script's request always asks.
|
||||
fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.executeFile", "missing `path`"))?;
|
||||
if !s.prefs.scripting.allow_scripts_write_files {
|
||||
@@ -257,7 +258,8 @@ fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(bad("file.executeFile", format!("no file {path}")));
|
||||
}
|
||||
if s.prefs.scripting.warn_executing_files && !b_p(p, "confirmed").unwrap_or(false) {
|
||||
let confirmed = b_p(p, "confirmed").unwrap_or(false) && !s.running_for_script();
|
||||
if s.prefs.scripting.warn_executing_files && !confirmed {
|
||||
s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) });
|
||||
return Ok(json!({"needsConfirmation": path}));
|
||||
}
|
||||
|
||||
@@ -228,9 +228,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Keyframes selected → copy keys (pasted at the CTI).
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
s.state.link_clipboard = None;
|
||||
s.state.effect_clipboard.clear();
|
||||
s.state.contents_clipboard.clear();
|
||||
clear_clipboards(s);
|
||||
return s.execute("keys.copy", json!({}));
|
||||
}
|
||||
// Effects selected (Effect Controls / timeline) → copy the effects.
|
||||
@@ -256,12 +254,14 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Edit ▸ Copy with properties selected (Position, or the Transform group), as in After
|
||||
/// Effects: an animated property copies its keyframes (the selected ones, else all of them) and
|
||||
/// an unanimated one its value, for Paste onto the same properties of other layers (#493).
|
||||
/// Effects: an animated property copies its keyframes and an unanimated one its value, for Paste
|
||||
/// onto the same properties of other layers (#493). With keyframes selected only those are copied,
|
||||
/// even across several highlighted properties (#1552); a selected Transform group copies them all.
|
||||
/// `None` when no property (or Transform group) is selected.
|
||||
fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
let Some(comp) = s.active_comp() else { return Ok(None) };
|
||||
let mut leaves = vec![];
|
||||
let mut copy_all_keys = s.state.selected_keys.is_empty();
|
||||
for (lid, uid) in &s.state.selected_props {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
if l.props.find(*uid).is_some() {
|
||||
@@ -269,6 +269,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
} else if let Some(g) = l.props.find_group(*uid)
|
||||
&& l.props.match_path_of(*uid).as_deref() == Some("transform")
|
||||
{
|
||||
copy_all_keys = true;
|
||||
g.walk("", &mut |_, pr| leaves.push((*lid, pr.uid)));
|
||||
}
|
||||
}
|
||||
@@ -286,7 +287,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
{
|
||||
values.push((path, pr.value.clone()));
|
||||
}
|
||||
} else if !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) {
|
||||
} else if copy_all_keys && !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) {
|
||||
keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +391,7 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
||||
Ok(b) if effectcraft_psd::is_psd(&b) => b,
|
||||
Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => {
|
||||
// PDF / Illustrator / EPS: one layer per file layer.
|
||||
match import_vector_comp(s, &path, &b, page) {
|
||||
match import_vector_comp(s, &path, &b, page, retain) {
|
||||
Ok((comp, made)) => {
|
||||
comps.push(comp);
|
||||
items.extend(made);
|
||||
@@ -540,7 +540,7 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec<u8>, retai
|
||||
|
||||
/// Import page `page` of a PDF / Illustrator / EPS file as a composition (one undo step).
|
||||
/// Returns (comp, items).
|
||||
fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> Result<(u64, Vec<u64>)> {
|
||||
fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32, retain: bool) -> Result<(u64, Vec<u64>)> {
|
||||
let mut name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into());
|
||||
if page > 0 {
|
||||
name = format!("{name} (Page {})", page + 1);
|
||||
@@ -549,7 +549,7 @@ fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> R
|
||||
let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
|
||||
let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs));
|
||||
let (comp, folder, items) = s.edit("Import", None, |proj, st| {
|
||||
let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, rate, duration).map_err(EngineError::Other)?;
|
||||
let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, retain, (rate, duration)).map_err(EngineError::Other)?;
|
||||
st.project_selection = vec![r.0];
|
||||
Ok(r)
|
||||
})?;
|
||||
|
||||
@@ -356,13 +356,12 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"effects": found}))
|
||||
}
|
||||
"fonts" => {
|
||||
let families = crate::text_families();
|
||||
let mut found = vec![];
|
||||
for (cid, c) in s.project.comps() {
|
||||
for l in &c.layers {
|
||||
if let Some(pr) = l.props.prop("text/sourceText")
|
||||
&& let Some(doc) = pr.value.as_text()
|
||||
&& !families.iter().any(|f| f.eq_ignore_ascii_case(&doc.font))
|
||||
&& effectcraft_text::resolve(&doc.font, &doc.style).missing
|
||||
{
|
||||
found.push(json!({"comp": cid.0, "layer": l.id.0, "font": doc.font}));
|
||||
}
|
||||
|
||||
@@ -417,6 +417,8 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str,
|
||||
let applies = |l: &Layer| match name {
|
||||
"audio" => l.props.sub("audio").is_some(),
|
||||
"lock" | "locked" => true,
|
||||
// Nulls have a Video switch too (it hides their outline in the viewer).
|
||||
"video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id),
|
||||
_ => l.source.is_av() && !mattes.contains(&l.id),
|
||||
};
|
||||
let mut alone = true;
|
||||
|
||||
@@ -115,7 +115,7 @@ fn hide_other_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Hide Other Video", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() {
|
||||
if !ids.contains(&l.id) && l.source.is_av() {
|
||||
if !ids.contains(&l.id) && (l.source.is_av() || matches!(l.source, LayerSource::Null)) {
|
||||
l.switches.video = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ fn edit_path_counted<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(
|
||||
let roto = super::paths::is_roto(g);
|
||||
let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?;
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) };
|
||||
// A path read from a file may have fewer tangents than vertices; the edits below index
|
||||
// all three arrays by vertex (#1250).
|
||||
sp.fit_tangents();
|
||||
let (r, op) = f(&mut sp)?;
|
||||
if roto {
|
||||
super::paths::roto_smooth(&mut sp);
|
||||
@@ -287,6 +290,7 @@ fn edit_vertices_counted(s: &mut Session, p: &Value, label: &str, removes: bool,
|
||||
let roto = super::paths::is_roto(g);
|
||||
let Some(pr) = g.get_mut("path") else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
sp.fit_tangents();
|
||||
f(&mut sp, idx);
|
||||
if roto {
|
||||
super::paths::roto_smooth(&mut sp);
|
||||
|
||||
@@ -126,17 +126,18 @@ pub(crate) fn find_moved(services: &dyn Services, project_dir: &str, f: &Footage
|
||||
})
|
||||
}
|
||||
|
||||
/// The folder a project file is in.
|
||||
/// The folder a project file is in, with the path's own separator and its leading `/` or UNC `\\`.
|
||||
pub(crate) fn project_dir(project_path: &str) -> Option<String> {
|
||||
let p = parts(project_path);
|
||||
if p.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut dir = join(if project_path.starts_with('/') { "/" } else { "" }, p.get(..p.len() - 1)?);
|
||||
if !project_path.starts_with('/') {
|
||||
dir = dir.trim_start_matches(['/', '\\']).to_string();
|
||||
}
|
||||
Some(dir)
|
||||
let dir = p.get(..p.len().checked_sub(1)?).filter(|d| !d.is_empty())?;
|
||||
let root = if project_path.starts_with("\\\\") {
|
||||
"\\\\"
|
||||
} else if project_path.starts_with('/') {
|
||||
"/"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
Some(format!("{root}{}", dir.join(&sep_of(project_path).to_string())))
|
||||
}
|
||||
|
||||
/// Relink `found` in one undo step and say how many.
|
||||
@@ -262,6 +263,10 @@ mod tests {
|
||||
);
|
||||
assert_eq!(project_dir("/x/y/p.ecproj").as_deref(), Some("/x/y"));
|
||||
assert_eq!(project_dir("p.ecproj"), None);
|
||||
// Windows paths keep their separator, and a UNC share its `\\`.
|
||||
assert_eq!(project_dir("C:\\Work\\Job\\p.ecproj").as_deref(), Some("C:\\Work\\Job"));
|
||||
assert_eq!(project_dir("\\\\server\\share\\Job\\p.ecproj").as_deref(), Some("\\\\server\\share\\Job"));
|
||||
assert_eq!(moved_candidates("\\\\server\\share\\Job", "D:\\Old\\Job\\shots\\a.png")[2], "\\\\server\\share\\Job\\shots\\a.png");
|
||||
// Hostile input doesn't panic.
|
||||
for (a, b) in [("", ""), ("/", "/"), ("x", ""), ("\\\\", "//"), ("/a", "/a/b/c")] {
|
||||
let _ = moved_root(a, b);
|
||||
@@ -302,11 +307,11 @@ mod tests {
|
||||
let b = add(&mut s, "/gone/proj/shots/b.mov");
|
||||
let m = add(&mut s, "/gone/proj/audio/m.wav");
|
||||
let other = add(&mut s, "/elsewhere/c.mov");
|
||||
let target = new.join("shots/a.mov").to_string_lossy().to_string();
|
||||
let target = new.join("shots").join("a.mov").to_string_lossy().to_string();
|
||||
let r = s.execute("footage.relink", json!({"item": a.0, "path": target})).unwrap();
|
||||
assert_eq!(r["relinked"], json!([a.0, b.0, m.0]), "{r}");
|
||||
assert_eq!(r["stillMissing"], 1);
|
||||
assert_eq!(path_of(&s, m), new.join("audio/m.wav").to_string_lossy());
|
||||
assert_eq!(path_of(&s, m), new.join("audio").join("m.wav").to_string_lossy());
|
||||
assert_eq!(path_of(&s, other), "/elsewhere/c.mov");
|
||||
// One undo step brings every path back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
@@ -6,6 +6,7 @@ use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Layer, LayerSource, Node, PropGroup};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_comp, layer_mut, layer_p, str_p};
|
||||
use crate::{Result, Session, cmd, query};
|
||||
@@ -13,8 +14,10 @@ use crate::{Result, Session, cmd, query};
|
||||
/// The original text animator presets (`presets/text_animators.json`).
|
||||
pub const PRESETS_JSON: &str = include_str!("../../presets/text_animators.json");
|
||||
|
||||
fn presets() -> Vec<Value> {
|
||||
serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
|
||||
fn presets() -> &'static [Value] {
|
||||
static PRESETS: OnceLock<Vec<Value>> = OnceLock::new();
|
||||
PRESETS
|
||||
.get_or_init(|| serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// (id, name) of every preset.
|
||||
@@ -303,7 +306,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?.to_string();
|
||||
let preset = presets()
|
||||
.into_iter()
|
||||
.iter()
|
||||
.find(|x| x.get("id").and_then(Value::as_str) == Some(&want) || x.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case(&want)))
|
||||
.ok_or_else(|| bad(C, format!("no preset {want}")))?;
|
||||
let name = preset.get("name").and_then(Value::as_str).unwrap_or("Preset").to_string();
|
||||
|
||||
@@ -235,7 +235,8 @@ mod tests {
|
||||
paths.sort();
|
||||
// Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed
|
||||
// separators; `Path` equality compares components, so both spellings are the same file.
|
||||
let expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"];
|
||||
let mut expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"];
|
||||
expected.sort();
|
||||
assert_eq!(paths.len(), expected.len(), "{paths:?}");
|
||||
for (got, want) in paths.iter().zip(expected) {
|
||||
assert_eq!(std::path::Path::new(got), std::path::Path::new(want), "{paths:?}");
|
||||
@@ -254,7 +255,7 @@ mod tests {
|
||||
std::fs::write(d.join("Job/shots/a.png"), b"x").unwrap();
|
||||
// Saved on another machine: the footage sat next to the project in /Users/x/Job.
|
||||
let mut s = session_with(&["/Users/x/Job/shots/a.png", "/Users/x/elsewhere/b.png"]);
|
||||
let path = d.join("Job/p.ecproj");
|
||||
let path = d.join("Job").join("p.ecproj");
|
||||
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let mut o = Session::default();
|
||||
o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
@@ -269,7 +270,7 @@ mod tests {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(paths.contains(&d.join("Job/shots/a.png").to_string_lossy().to_string()), "{paths:?}");
|
||||
assert!(paths.contains(&d.join("Job").join("shots").join("a.png").to_string_lossy().to_string()), "{paths:?}");
|
||||
assert!(!o.is_dirty(), "finding the files is not an edit");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,12 @@ fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u
|
||||
Some((u32::try_from(found.index).ok()?, size))
|
||||
}
|
||||
|
||||
fn vector_size(s: &Session, f: &Footage) -> Option<(u32, u32)> {
|
||||
let bytes = s.services.read_file(&f.path).ok()?;
|
||||
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)?.ok()?;
|
||||
(!doc.root.children.is_empty()).then(|| doc.pixel_size())
|
||||
}
|
||||
|
||||
/// Footage `f` as its file is now, given the importer's fresh look at the file (`probed`).
|
||||
fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
|
||||
let mut nf = f.clone();
|
||||
@@ -89,6 +95,7 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
|
||||
match &f.layer {
|
||||
// A smart object's size is its embedded file's, recorded at import.
|
||||
Some(l) if l.embedded.is_some() => (w, h) = (f.width, f.height),
|
||||
Some(_) if crate::vector::is_vector(f) => (w, h) = vector_size(s, f).unwrap_or((f.width, f.height)),
|
||||
Some(l) => match psd_layer(s, &f.path, l) {
|
||||
Some((index, size)) => {
|
||||
if let Some(nl) = nf.layer.as_mut() {
|
||||
|
||||
@@ -173,7 +173,7 @@ impl VirtualTree {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
sort_entries(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,10 @@ pub struct Entry {
|
||||
pub kind: Option<&'static str>,
|
||||
}
|
||||
|
||||
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"];
|
||||
@@ -251,7 +255,7 @@ pub fn list(dir: &str, importable_only: bool) -> Result<Vec<Entry>, String> {
|
||||
Some(Entry { name, path, is_dir: md.is_dir(), size: if md.is_dir() { 0 } else { md.len() }, modified: unix(md.modified()), kind })
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
sort_entries(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -312,7 +316,9 @@ pub fn footage_metadata(f: &Footage) -> Value {
|
||||
"pixelAspect": f.pixel_aspect,
|
||||
"frameRate": f.frame_rate.as_f64(),
|
||||
"duration": f.duration.seconds(),
|
||||
"frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.duration)) } else { None },
|
||||
// Rounded like the media pool's frame count: Matroska stores the duration in whole
|
||||
// milliseconds, a hair short of the last frame at 30 fps (#1429).
|
||||
"frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.frame_rate.snap_nearest(f.duration))) } else { None },
|
||||
"hasVideo": f.has_video,
|
||||
"hasAudio": f.has_audio,
|
||||
"alpha": format!("{:?}", f.alpha),
|
||||
@@ -407,6 +413,10 @@ mod tests {
|
||||
assert_eq!(media.len(), 3);
|
||||
assert_eq!(parent(&dir.join("Shots").to_string_lossy()).as_deref(), Some(d.as_str()));
|
||||
assert!(list(&dir.join("nope").to_string_lossy(), false).is_err());
|
||||
std::fs::write(dir.join("Ω.csv"), b"x").unwrap();
|
||||
std::fs::write(dir.join("作品.csv"), b"x").unwrap();
|
||||
let all = list(&d, false).unwrap();
|
||||
assert_eq!(all.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["Shots", "A.mov", "b.png", "notes.txt", "Ω.csv", "作品.csv"]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -436,6 +446,61 @@ mod tests {
|
||||
assert_eq!(vparent("/"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listings_keep_folders_first_and_stable_unicode_order() {
|
||||
let mut tree = VirtualTree::default();
|
||||
for name in ["作品.csv", "ä.csv", "Ä.csv", "İ.csv", "B.csv", "a.csv", "A.csv"] {
|
||||
tree.add_file(&format!("/Shoot/{name}"), &format!("/files/{name}"), 1, None);
|
||||
}
|
||||
for name in ["zFolder", "Alpha"] {
|
||||
tree.add_dir(&format!("/Shoot/{name}"));
|
||||
}
|
||||
let entries = tree.list("/Shoot", true).unwrap();
|
||||
assert_eq!(
|
||||
entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
|
||||
["Alpha", "zFolder", "A.csv", "a.csv", "B.csv", "İ.csv", "Ä.csv", "ä.csv", "作品.csv"]
|
||||
);
|
||||
assert_eq!(entries[2].path, "/files/A.csv");
|
||||
let mut reversed = entries.iter().rev().cloned().collect::<Vec<_>>();
|
||||
sort_entries(&mut reversed);
|
||||
assert_eq!(
|
||||
reversed.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
|
||||
["Alpha", "zFolder", "a.csv", "A.csv", "B.csv", "İ.csv", "ä.csv", "Ä.csv", "作品.csv"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Media Browser filename sorting benchmark"]
|
||||
fn directory_sort_benchmark() {
|
||||
let entries: Vec<_> = (0..1000)
|
||||
.map(|i| Entry {
|
||||
name: format!("作品 {} {:04}.mov", if i % 2 == 0 { "Clip" } else { "clip" }, i * 7919 % 1000),
|
||||
path: format!("/Shoot/{i}.mov"),
|
||||
is_dir: i % 7 == 0,
|
||||
size: 1,
|
||||
modified: None,
|
||||
kind: Some("video"),
|
||||
})
|
||||
.collect();
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let mut old = entries.clone();
|
||||
let start = std::time::Instant::now();
|
||||
old.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
before.push(start.elapsed());
|
||||
let mut new = entries.clone();
|
||||
let start = std::time::Instant::now();
|
||||
sort_entries(&mut new);
|
||||
after.push(start.elapsed());
|
||||
assert_eq!(old, new);
|
||||
std::hint::black_box(new);
|
||||
}
|
||||
before.sort();
|
||||
after.sort();
|
||||
println!("1,000 filename sort, median: {:?} -> {:?}", before[2], after[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_dates() {
|
||||
assert_eq!(iso_date(0), "1970-01-01T00:00:00Z");
|
||||
|
||||
@@ -362,8 +362,15 @@ impl Session {
|
||||
// Settings ▸ Export ▸ Default Output Folder, else the project's folder.
|
||||
let folder = self.prefs.export.default_output_folder.trim();
|
||||
let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder));
|
||||
let base =
|
||||
base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty()));
|
||||
// A project saved under a bare filename has the empty path as its parent. It sits in the
|
||||
// working directory, so resolve against that rather than falling through to the
|
||||
// unsaved-project default below (#1427).
|
||||
let base = base.or_else(|| {
|
||||
self.path
|
||||
.as_deref()
|
||||
.and_then(|s| std::path::Path::new(s).parent())
|
||||
.map(|d| if d.as_os_str().is_empty() { std::path::PathBuf::from(".") } else { d.to_path_buf() })
|
||||
});
|
||||
// Unsaved project: ~/Movies, else the home folder (as FilmCraft's exports do). The working directory
|
||||
// is `/` for an app started from the macOS Finder, where nothing can be written (#446).
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(std::path::PathBuf::from);
|
||||
|
||||
@@ -280,6 +280,28 @@ fn import_export_helpers() {
|
||||
assert_eq!(p.recent_fonts_shown(), ["A".to_string(), "C".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_queue_resolves_a_bare_project_filename_against_the_working_directory() {
|
||||
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem};
|
||||
let mut s = session();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let mut item = RenderQueueItem::new(1, cid);
|
||||
item.output.output = "clip.mp4".into();
|
||||
item.output.format = OutputFormat::H264;
|
||||
|
||||
// `--project ./proj.ecproj`: the parent is `.`, so the output goes beside the project.
|
||||
s.path = Some("./proj.ecproj".into());
|
||||
let dotted = s.resolve_output(&item).unwrap();
|
||||
|
||||
// `--project proj.ecproj`: the parent is the empty path, which must mean the same folder
|
||||
// rather than the unsaved-project default (#1427).
|
||||
s.path = Some("proj.ecproj".into());
|
||||
let bare = s.resolve_output(&item).unwrap();
|
||||
|
||||
assert_eq!(PathBuf::from(&bare), PathBuf::from(&dotted), "a bare filename resolved to {bare} instead of {dotted}");
|
||||
assert_eq!(PathBuf::from(&bare), PathBuf::from("./clip.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_queue_uses_default_folder_bit_depth_names_and_segments() {
|
||||
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem};
|
||||
@@ -349,6 +371,34 @@ fn execute_file_needs_the_gate_and_asks_first() {
|
||||
assert!(s.execute("file.executeFile", p).unwrap().get("opened").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_cannot_confirm_executing_a_file_for_itself() {
|
||||
// Warn User When Executing Files: only the user answers the question (#1246).
|
||||
let mut s = session();
|
||||
let f = tmp("script-run.txt");
|
||||
std::fs::write(&f, "x").unwrap();
|
||||
let path = f.to_string_lossy().to_string();
|
||||
let opened = |s: &mut Session| s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(_)));
|
||||
// Without the gate a script can't run files at all, confirmed or not.
|
||||
assert!(s.execute_for_script("file.executeFile", json!({"path": path, "confirmed": true})).is_err());
|
||||
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
|
||||
s.drain_events();
|
||||
// A script asserting `confirmed` still gets the question, directly or in a batch.
|
||||
let r = s.execute_for_script("file.executeFile", json!({"path": path, "confirmed": true})).unwrap();
|
||||
assert!(r.get("needsConfirmation").is_some(), "{r}");
|
||||
assert!(!opened(&mut s));
|
||||
let steps = json!({"steps": [{"command": "file.executeFile", "params": {"path": path, "confirmed": true}}]});
|
||||
let r = s.execute_for_script("engine.batch", steps).unwrap();
|
||||
assert!(r.to_string().contains("needsConfirmation"), "{r}");
|
||||
assert!(!opened(&mut s));
|
||||
// The user's answer from the frontend's question still opens it.
|
||||
assert!(s.execute("file.executeFile", json!({"path": path, "confirmed": true})).unwrap().get("opened").is_some());
|
||||
assert!(opened(&mut s));
|
||||
// With the warning off a script's request opens the file without a question, as before.
|
||||
s.execute("prefs.set", json!({"key": "scripting.warnExecutingFiles", "value": false})).unwrap();
|
||||
assert!(s.execute_for_script("file.executeFile", json!({"path": path})).unwrap().get("opened").is_some());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Edit ▸ History, recent lists
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -111,6 +111,38 @@ fn set_switch_others() {
|
||||
assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err());
|
||||
}
|
||||
|
||||
/// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles,
|
||||
/// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is
|
||||
/// hidden (parenting ignores the eye).
|
||||
#[test]
|
||||
fn null_video_switch() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [n], "switch": "video"})).unwrap();
|
||||
assert!(!layer(&s, n).switches.video);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(layer(&s, n).switches.video);
|
||||
// Alt-click on the solid's eye hides the null too; on the null's, the solid.
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
|
||||
assert!(layer(&s, a).switches.video && !layer(&s, n).switches.video);
|
||||
s.execute("layer.setSwitch", json!({"layers": [n], "switch": "video", "others": true})).unwrap();
|
||||
assert!(!layer(&s, a).switches.video && layer(&s, n).switches.video);
|
||||
s.execute("layer.showAllVideo", json!({})).unwrap();
|
||||
s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap();
|
||||
assert!(layer(&s, a).switches.video && !layer(&s, n).switches.video);
|
||||
// The hidden null still carries its child.
|
||||
s.execute("layer.setParent", json!({"layers": [a], "parent": n})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": n, "path": "transform/position", "value": [420, 180, 0]})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let c = s.project.comp(cid).unwrap().clone();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &c, Tick::ZERO);
|
||||
let child = c.layer(effectcraft_project::LayerId(a)).unwrap();
|
||||
let (m, _) = ctx.layer_to_comp(child);
|
||||
let centre = m.apply(effectcraft_geom::vec2(100.0, 50.0));
|
||||
assert!((centre.x - 420.0).abs() < 1e-6 && (centre.y - 180.0).abs() < 1e-6, "{centre:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_switches_menu() {
|
||||
let mut s = comp();
|
||||
@@ -433,8 +465,10 @@ fn copy_a_selected_property_pastes_onto_the_same_property() {
|
||||
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
let transform = layer(&s, a).props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": a, "prop": transform})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
@@ -706,6 +740,21 @@ fn sequence_layers() {
|
||||
assert_eq!(layer(&s, c).props.prop("transform/opacity").unwrap().keys.len(), 2);
|
||||
}
|
||||
|
||||
/// File ▸ Dependencies ▸ Find Missing Fonts lists a text layer only when no face matches its
|
||||
/// font by family, PostScript or full name.
|
||||
#[test]
|
||||
fn fonts_named_by_their_postscript_or_full_name_are_not_missing() {
|
||||
let mut s = comp();
|
||||
// The first three name bundled faces by family, PostScript and full name; no face has the fourth.
|
||||
let fonts = ["Noto Serif", "Inter-Bold", "Inter SemiBold", "No Such Font Family 123"];
|
||||
let ids: Vec<u64> = fonts.iter().map(|f| s.execute("layer.newText", json!({"text": "Aa", "font": f})).unwrap()["layer"].as_u64().unwrap()).collect();
|
||||
let r = s.execute("file.findMissing", json!({"what": "fonts"})).unwrap();
|
||||
let listed: Vec<(u64, &str)> = r["fonts"].as_array().unwrap().iter().map(|f| (f["layer"].as_u64().unwrap(), f["font"].as_str().unwrap())).collect();
|
||||
assert_eq!(listed, [(ids[3], fonts[3])], "{r}");
|
||||
let f = s.execute("text.fontFeatures", json!({"layer": ids[1]})).unwrap();
|
||||
assert_eq!((&f["family"], &f["style"], &f["missing"]), (&json!("Inter"), &json!("Bold"), &json!(false)), "{f}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_menu_project_ops() {
|
||||
let mut s = comp();
|
||||
@@ -1556,6 +1605,31 @@ fn interpret_footage_loop_extends_video_layers() {
|
||||
assert_eq!(s.project.item(effectcraft_project::ItemId(p)).unwrap().duration(), Some(Tick::from_seconds_f64(6.0)));
|
||||
}
|
||||
|
||||
/// #1068: the same for an interpreted PNG sequence — the loop count survives interpretation and
|
||||
/// a freshly dragged layer plays every cycle.
|
||||
#[test]
|
||||
fn interpret_footage_loop_extends_sequence_layers() {
|
||||
let mut s = comp();
|
||||
let p = s.execute("file.importPlaceholder", json!({"name": "Seq", "width": 320, "height": 240, "duration": 0.4})).unwrap()["item"].as_u64().unwrap();
|
||||
let id = effectcraft_project::ItemId(p);
|
||||
// Make it a sequence like the importer's grouped PNGs.
|
||||
s.edit("Seq", None, |proj, _| {
|
||||
if let Some(ItemKind::Footage(f)) = proj.item_mut(id).map(|i| &mut i.kind) {
|
||||
f.kind = effectcraft_project::FootageKind::Sequence;
|
||||
f.sequence = (0..12).map(|n| format!("/tmp/seq_{n:04}.png")).collect();
|
||||
f.sync_sequence_duration();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.state.project_selection = vec![id];
|
||||
s.execute("file.interpretFootage", json!({"loop": 15.0})).unwrap();
|
||||
let l = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap();
|
||||
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap();
|
||||
let dur = (layer.out_point - layer.in_point).seconds();
|
||||
assert!((dur - 6.0).abs() < 0.05, "15 loops of a 0.4 s sequence: {dur}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentage_guides_follow_the_comp_size() {
|
||||
let mut s = comp();
|
||||
|
||||
@@ -164,6 +164,64 @@ fn presets_apply_render_and_round_trip() {
|
||||
assert!(matches(tg.sub("pathOptions").unwrap()).contains(&"forceAlignment".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_text_presets_keep_the_catalog_and_application_independent() {
|
||||
let expected = [
|
||||
("typewriter", "Typewriter"),
|
||||
("fadeUpCharacters", "Fade Up Characters"),
|
||||
("bounceInWords", "Bounce In Words"),
|
||||
("trackingIn", "Tracking In"),
|
||||
("scramble", "Scramble"),
|
||||
("blurIn", "Blur In"),
|
||||
("jitter", "Jitter"),
|
||||
("dropInLines", "Drop In Lines"),
|
||||
];
|
||||
let (mut s, layer) = session();
|
||||
let catalog = s.execute("text.presets", json!({})).unwrap();
|
||||
let mut returned = catalog.clone();
|
||||
returned[0]["name"] = json!("Edited copy");
|
||||
let project = s.project.clone();
|
||||
for _ in 0..2 {
|
||||
let names = crate::text_presets();
|
||||
assert_eq!(names.iter().map(|(id, name)| (id.as_str(), name.as_str())).collect::<Vec<_>>(), expected);
|
||||
for (id, _) in expected {
|
||||
let result = s.execute("layer.applyTextPreset", json!({"layer": layer, "preset": id})).unwrap();
|
||||
assert!(!result["animators"].as_array().unwrap().is_empty(), "{id}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project, project, "{id}: undo restores the document");
|
||||
}
|
||||
assert_eq!(s.execute("text.presets", json!({})).unwrap(), catalog);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "warm text-preset listing benchmark"]
|
||||
fn text_preset_listing_benchmark() {
|
||||
let old = || {
|
||||
let v: serde_json::Value = serde_json::from_str(include_str!("../presets/text_animators.json")).unwrap();
|
||||
let presets = v["presets"].as_array().unwrap().clone();
|
||||
presets.iter().map(|p| (p["id"].as_str().unwrap().to_string(), p["name"].as_str().unwrap().to_string())).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(old(), crate::text_presets());
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(old());
|
||||
}
|
||||
before.push(start.elapsed());
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(crate::text_presets());
|
||||
}
|
||||
after.push(start.elapsed());
|
||||
}
|
||||
before.sort();
|
||||
after.sort();
|
||||
println!("Text preset names, median per warm lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menus_have_text_animation_entries() {
|
||||
let (s, _) = session();
|
||||
|
||||
@@ -108,6 +108,85 @@ fn keyframe_copy_paste_keeps_relative_timing_and_crosses_properties() {
|
||||
assert!(e.is_err() || prop(&s, l2, "transform/position").keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_selected_keys_excludes_deselected_properties_and_stale_values() {
|
||||
let (mut s, source) = setup();
|
||||
s.execute("prop.set", json!({"layer": source, "path": "transform/opacity", "value": 25})).unwrap();
|
||||
select_prop_keys(&mut s, source, "transform/opacity");
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
assert_eq!(s.state.value_clipboard.len(), 1);
|
||||
animate(&mut s, source, "transform/position", &[(0.0, json!([10, 20, 0])), (1.0, json!([30, 40, 0]))]);
|
||||
animate(&mut s, source, "transform/rotation", &[(0.0, json!(15)), (1.0, json!(90))]);
|
||||
animate(&mut s, source, "transform/scale", &[(0.0, json!([50, 60, 100])), (1.0, json!([150, 160, 100]))]);
|
||||
select_prop_keys(&mut s, source, "transform/scale");
|
||||
s.execute(
|
||||
"keys.select",
|
||||
json!({"keys": [{"layer": source, "path": "transform/position", "time": 0}, {"layer": source, "path": "transform/rotation", "time": 1}], "add": true, "selectProperties": true}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"keys.select",
|
||||
json!({"keys": [{"layer": source, "path": "transform/scale", "time": 0}, {"layer": source, "path": "transform/scale", "time": 1}], "toggle": true, "selectProperties": true}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("keys.easyEase", json!({})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
assert_eq!(s.state.selected_props.len(), 3);
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!(2));
|
||||
assert!(s.state.value_clipboard.is_empty());
|
||||
let target = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("time.set", json!({"time": 2})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [target]})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
undo_redo_roundtrip(
|
||||
&mut s,
|
||||
|s| {
|
||||
for (path, time, value) in [("transform/position", 2.0, KV::Vec3([10.0, 20.0, 0.0])), ("transform/rotation", 3.0, KV::Scalar(90.0))] {
|
||||
let p = prop(s, target, path);
|
||||
assert_eq!(p.keys.len(), 1);
|
||||
let k = &p.keys[0];
|
||||
assert_eq!((k.time.seconds(), &k.value), (time, &value));
|
||||
assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier));
|
||||
assert_eq!(k.in_ease[0], effectcraft_keyframe::Ease::EASY);
|
||||
assert_eq!(k.out_ease[0], effectcraft_keyframe::Ease::EASY);
|
||||
}
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
assert_eq!(prop(s, target, "transform/scale").value, KV::Vec3([100.0; 3]));
|
||||
assert!(prop(s, target, "transform/scale").keys.is_empty());
|
||||
assert_eq!(prop(s, target, "transform/opacity").value, KV::Scalar(100.0));
|
||||
},
|
||||
|s| {
|
||||
assert!(prop(s, target, "transform/position").keys.is_empty());
|
||||
assert!(prop(s, target, "transform/rotation").keys.is_empty());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_mixed_animated_and_static_properties_keeps_the_static_value() {
|
||||
let (mut s, source) = setup();
|
||||
animate(&mut s, source, "transform/position", &[(0.0, json!([10, 20, 0])), (1.0, json!([30, 40, 0]))]);
|
||||
s.execute("prop.set", json!({"layer": source, "path": "transform/rotation", "value": 45})).unwrap();
|
||||
select_prop_keys(&mut s, source, "transform/position");
|
||||
s.execute("prop.select", json!({"layer": source, "path": "transform/rotation", "add": true})).unwrap();
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!({"keys": 2, "values": 1}));
|
||||
let target = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("time.set", json!({"time": 2})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [target]})).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
assert_eq!(prop(&s, target, "transform/rotation").value, KV::Scalar(45.0));
|
||||
let position = prop(&s, target, "transform/position");
|
||||
assert_eq!(
|
||||
position.keys.iter().map(|k| (k.time.seconds(), k.value.clone())).collect::<Vec<_>>(),
|
||||
vec![(2.0, KV::Vec3([10.0, 20.0, 0.0])), (3.0, KV::Vec3([30.0, 40.0, 0.0]))]
|
||||
);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(prop(&s, target, "transform/position").keys.is_empty());
|
||||
assert_eq!(prop(&s, target, "transform/rotation").value, KV::Scalar(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_removes_keys_and_paste_restores_them() {
|
||||
let (mut s, l) = setup();
|
||||
@@ -331,6 +410,77 @@ fn pen_tool_draws_edits_and_closes_a_mask() {
|
||||
assert_eq!(s.active_comp().unwrap().layers[0].masks().unwrap().children.len(), 1);
|
||||
}
|
||||
|
||||
/// Cut every mask path's tangent arrays in the saved project to one entry (static values and
|
||||
/// keyframes alike), then open the result: the shape of a hand-edited or foreign file (#1250).
|
||||
fn reopen_with_short_tangents(s: &mut Session) {
|
||||
fn cut(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.contains_key("vertices") {
|
||||
for k in ["in_tangents", "out_tangents"] {
|
||||
if let Some(Value::Array(a)) = o.get_mut(k) {
|
||||
a.truncate(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
o.values_mut().for_each(cut);
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(cut),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut j: Value = serde_json::from_str(&s.project.to_json()).unwrap();
|
||||
cut(&mut j);
|
||||
let p = effectcraft_project::Project::from_json(&j.to_string()).unwrap();
|
||||
s.replace_project(p, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_commands_fill_in_missing_tangents_of_a_loaded_path() {
|
||||
let (mut s, l) = setup();
|
||||
let m = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10], [90, 10], [90, 90]]})).unwrap()["mask"].as_u64().unwrap();
|
||||
reopen_with_short_tangents(&mut s);
|
||||
let path = |s: &Session| prop(s, l, "masks/#1/path").value.as_path().unwrap().clone();
|
||||
assert_eq!(path(&s).in_tangents.len(), 1, "the file really has short tangents");
|
||||
// Both used to index the tangent arrays by vertex and panic.
|
||||
s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 2, "in": [0, -20], "out": [0, 20]})).unwrap();
|
||||
s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [10, 90]})).unwrap();
|
||||
let p = path(&s);
|
||||
assert_eq!(p.vertices.len(), 4);
|
||||
assert_eq!((p.in_tangents.len(), p.out_tangents.len()), (4, 4));
|
||||
assert_eq!((p.in_tangents[2], p.out_tangents[2]), ([0.0, -20.0], [0.0, 20.0]));
|
||||
assert_eq!(p.in_tangents[1], [0.0, 0.0], "a missing tangent is a corner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_vertices_of_a_loaded_path_with_short_tangents_does_not_panic() {
|
||||
let (mut s, l) = setup();
|
||||
let m = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10], [90, 10], [90, 90]]})).unwrap()["mask"].as_u64().unwrap();
|
||||
reopen_with_short_tangents(&mut s);
|
||||
s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 2}]})).unwrap();
|
||||
s.execute("mask.deleteVertices", json!({})).unwrap();
|
||||
let p = prop(&s, l, "masks/#1/path").value.as_path().unwrap().clone();
|
||||
assert_eq!((p.vertices.len(), p.in_tangents.len(), p.out_tangents.len()), (2, 2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_commands_fill_in_missing_tangents_of_a_loaded_animated_path() {
|
||||
let (mut s, l) = setup();
|
||||
let m = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10], [90, 10], [90, 90]]})).unwrap()["mask"].as_u64().unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": l, "path": "masks/#1/path"})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 0, "point": [0, 0]})).unwrap();
|
||||
reopen_with_short_tangents(&mut s);
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 2, "in": [5, 5]})).unwrap();
|
||||
s.execute("mask.addVertex", json!({"layer": l, "mask": m, "index": 3, "point": [10, 90]})).unwrap();
|
||||
for k in &prop(&s, l, "masks/#1/path").keys {
|
||||
let p = k.value.as_path().unwrap();
|
||||
assert_eq!(p.vertices.len(), 4, "the other key gains the vertex too");
|
||||
assert!(p.in_tangents.len() == 4 && p.out_tangents.len() == 4, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_stopwatch_toggles_a_self_reference_expression() {
|
||||
let (mut s, l) = setup();
|
||||
|
||||
@@ -271,7 +271,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::
|
||||
Some(
|
||||
effectcraft_pdf::parse_page(bytes, page as usize)
|
||||
.map(|d| match layer {
|
||||
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
|
||||
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
|
||||
None => d,
|
||||
})
|
||||
.map_err(|e| format!("{path}: {e}")),
|
||||
@@ -279,7 +279,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::
|
||||
}
|
||||
|
||||
/// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the
|
||||
/// page: one footage item per file layer (each showing only that layer, continuously
|
||||
/// page: one footage item per file layer (optionally cropped to its artwork, continuously
|
||||
/// rasterisable), top layer first. Returns (comp, folder, items).
|
||||
pub fn import_vector_comp(
|
||||
proj: &mut Project,
|
||||
@@ -287,11 +287,12 @@ pub fn import_vector_comp(
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
page: u32,
|
||||
rate: effectcraft_time::FrameRate,
|
||||
duration: effectcraft_time::Tick,
|
||||
retain: bool,
|
||||
timing: (effectcraft_time::FrameRate, effectcraft_time::Tick),
|
||||
) -> Result<(ItemId, ItemId, Vec<ItemId>), String> {
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer};
|
||||
let (rate, duration) = timing;
|
||||
let doc = effectcraft_pdf::parse_page(bytes, page as usize).map_err(|e| format!("{path}: {e}"))?;
|
||||
let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF");
|
||||
let (w, h) = doc.pixel_size();
|
||||
@@ -301,24 +302,33 @@ pub fn import_vector_comp(
|
||||
let mut items = vec![];
|
||||
let names = effectcraft_pdf::layer_names(&doc);
|
||||
for (i, lname) in names.iter().enumerate() {
|
||||
let (size, offset) = if retain {
|
||||
let (layer, offset) = effectcraft_pdf::layer_doc_with_sizes(&doc, i, true);
|
||||
(layer.pixel_size(), offset)
|
||||
} else {
|
||||
((w, h), [0.0, 0.0])
|
||||
};
|
||||
let f = Footage {
|
||||
path: path.to_string(),
|
||||
kind: FootageKind::Still,
|
||||
width: w,
|
||||
height: h,
|
||||
width: size.0,
|
||||
height: size.1,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: rate,
|
||||
has_video: true,
|
||||
alpha: AlphaMode::Straight,
|
||||
loop_count: 1,
|
||||
codec: codec.into(),
|
||||
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }),
|
||||
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: retain, ..Default::default() }),
|
||||
page,
|
||||
..Default::default()
|
||||
};
|
||||
let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f));
|
||||
items.push(id);
|
||||
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None);
|
||||
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, size, None);
|
||||
if retain && let Some(position) = l.props.prop_mut("transform/position") {
|
||||
position.value = Value::Vec3([offset[0] + f64::from(size.0) / 2.0, offset[1] + f64::from(size.1) / 2.0, 0.0]);
|
||||
}
|
||||
l.name = comp.unique_layer_name(lname);
|
||||
comp.layers.insert(0, l);
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ const MAX_COLLAPSED_DEPTH: usize = 8;
|
||||
fn visible_layers<'a>(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterator<Item = &'a Layer> {
|
||||
let comp = ctx.comp;
|
||||
let time = ctx.time;
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time));
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
|
||||
comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id))
|
||||
}
|
||||
|
||||
|
||||
@@ -48,22 +48,43 @@ pub(crate) fn gpu_inflight(var: Option<&str>) -> usize {
|
||||
/// one window of rendered images is held in memory. Chunks hold no lock or slot, so a failing
|
||||
/// or panicking frame cannot leak one, and nested parallelism (or a single thread) cannot
|
||||
/// deadlock.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn map_frames<I: Send, T: Send>(
|
||||
ks: Vec<u64>,
|
||||
cap: Option<usize>,
|
||||
render: impl Fn(u64) -> I + Sync + Send,
|
||||
encode: impl Fn(u64, I) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
map_frames_while(ks, cap, || true, render, encode)
|
||||
}
|
||||
|
||||
/// [`map_frames`] whose `cap` holds only while `capped()`: once it turns false (the GPU stopped
|
||||
/// for good, so every frame falls back to the CPU), the remaining frames render in parallel
|
||||
/// like the uncapped CPU path instead of one chunk of `cap` at a time.
|
||||
pub(crate) fn map_frames_while<I: Send, T: Send>(
|
||||
ks: Vec<u64>,
|
||||
cap: Option<usize>,
|
||||
capped: impl Fn() -> bool,
|
||||
render: impl Fn(u64) -> I + Sync + Send,
|
||||
encode: impl Fn(u64, I) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
use rayon::prelude::*;
|
||||
let Some(n) = cap.map(|n| n.max(1)) else {
|
||||
let Some(n) = cap.map(|n| n.max(1)).filter(|_| capped()) else {
|
||||
return ks.into_par_iter().map(|k| encode(k, render(k))).collect();
|
||||
};
|
||||
let window = n.max(rayon::current_num_threads());
|
||||
let mut out = Vec::with_capacity(ks.len());
|
||||
for win in ks.chunks(window) {
|
||||
for (w, win) in ks.chunks(window).enumerate() {
|
||||
if !capped() {
|
||||
out.par_extend(ks[w * window..].par_iter().map(|&k| encode(k, render(k))));
|
||||
break;
|
||||
}
|
||||
let mut imgs = Vec::with_capacity(win.len());
|
||||
for chunk in win.chunks(n) {
|
||||
imgs.par_extend(chunk.par_iter().map(|&k| render(k)));
|
||||
let mut i = 0;
|
||||
while i < win.len() {
|
||||
let end = if capped() { (i + n).min(win.len()) } else { win.len() };
|
||||
imgs.par_extend(win[i..end].par_iter().map(|&k| render(k)));
|
||||
i = end;
|
||||
}
|
||||
out.par_extend(win.par_iter().copied().zip(imgs.into_par_iter()).map(|(k, img)| encode(k, img)));
|
||||
}
|
||||
@@ -187,7 +208,10 @@ impl<'a> Cx<'a> {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
map_frames(ks, self.gpu_cap(), |k| self.frame(comp, k), f)
|
||||
// The cap is for the GPU's memory: once it has stopped for good, frames render on the
|
||||
// CPU in parallel.
|
||||
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
|
||||
map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f)
|
||||
}
|
||||
|
||||
/// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every
|
||||
@@ -453,6 +477,36 @@ mod tests {
|
||||
(out, max.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_render_in_parallel_once_the_gpu_stops() {
|
||||
// Every frame after the GPU stopped falls back to the CPU: the cap no longer applies.
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
|
||||
let (live, now, max) = (AtomicBool::new(true), AtomicUsize::new(0), AtomicUsize::new(0));
|
||||
let out = pool.install(|| {
|
||||
map_frames_while(
|
||||
(0..24).collect(),
|
||||
Some(1),
|
||||
|| live.load(Ordering::SeqCst),
|
||||
|k| {
|
||||
if k == 1 {
|
||||
live.store(false, Ordering::SeqCst);
|
||||
}
|
||||
let c = now.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if k >= 2 {
|
||||
max.fetch_max(c, Ordering::SeqCst);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
now.fetch_sub(1, Ordering::SeqCst);
|
||||
k * 10
|
||||
},
|
||||
|_, v| v,
|
||||
)
|
||||
});
|
||||
assert_eq!(out, (0..24).map(|k| k * 10).collect::<Vec<_>>());
|
||||
assert!(max.load(Ordering::SeqCst) > 1, "frames after the GPU stopped ran one at a time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capped_frames_keep_their_order() {
|
||||
let want: Vec<u64> = (0..40).map(|k| k * 10).collect();
|
||||
|
||||
@@ -613,3 +613,28 @@ fn wav_and_aiff_audio_only() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An AIFF EffectCraft renders imports back (#1428): plain 16-bit and AIFF-C `fl32`.
|
||||
#[test]
|
||||
fn rendered_aiff_imports_back() {
|
||||
use effectcraft_export::render_queue::AudioFormat;
|
||||
let d = out_dir("aiff-import");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 2.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
for (format, name) in [(AudioFormat::S16, "s16.aif"), (AudioFormat::F32, "f32.aif")] {
|
||||
// 44.1 kHz, not the 48 kHz the samples are read back at: a wrong rate shows in the duration.
|
||||
let om = OutputModule { audio_sample_rate: 44_100, audio_channels: 2, audio_format: format, ..OutputModule::for_format(OutputFormat::Aiff) };
|
||||
let path = d.join(name);
|
||||
assert!(run(&p, cid, &pool, &om, &path).audio);
|
||||
let f = effectcraft_media::probe(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert!(f.has_audio && !f.has_video, "{name}");
|
||||
assert_eq!(f.duration, Tick::from_seconds_f64(1.2), "{name}: 52 920 frames at 44.1 kHz");
|
||||
// Tone on the left, silence on the right: the two channels are read as two.
|
||||
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
|
||||
let (l, r) = (rms(s.iter().step_by(2).copied()), rms(s.iter().skip(1).step_by(2).copied()));
|
||||
assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}");
|
||||
assert!(r < 0.01, "{name}: right RMS {r}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +261,10 @@ function noise(x) {
|
||||
function timeToFrames(t, fps, isDuration) {
|
||||
if (t === undefined) t = time + thisComp.displayStartTime;
|
||||
if (fps === undefined) fps = 1 / __S.fd;
|
||||
return Math.floor(t * fps + 1e-6);
|
||||
var frames = t * fps;
|
||||
// The 1e-6 keeps a whole number of frames that floating point overshoots (31/24 - 5/24) whole.
|
||||
if (isDuration && frames !== 0) return frames < 0 ? Math.floor(frames + 1e-6) : Math.ceil(frames - 1e-6);
|
||||
return Math.floor(frames + 1e-6);
|
||||
}
|
||||
|
||||
function framesToTime(frames, fps) {
|
||||
|
||||
@@ -1286,4 +1286,81 @@ fn layer_control_property_stands_for_its_layer() {
|
||||
assert_close!(f.num("effect('Map')(1).value.name == 'B' ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_to_frames_rounds_durations_away_from_zero() {
|
||||
let f = fx();
|
||||
// Adobe's public expression reference distinguishes durations from absolute times.
|
||||
// Expected integer counts are literal values, independent of the implementation.
|
||||
for (expression, expected) in [
|
||||
("timeToFrames(-0.01, 24, true)", -1.0),
|
||||
("timeToFrames(-0.05, 24, true)", -2.0),
|
||||
("timeToFrames(0, 24, true)", 0.0),
|
||||
("timeToFrames(-0.125, 24, true)", -3.0),
|
||||
("timeToFrames(0.125, 24, true)", 3.0),
|
||||
("timeToFrames(0.01, 24, true)", 1.0),
|
||||
("timeToFrames(0.05, 24, true)", 2.0),
|
||||
] {
|
||||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_to_frames_rounds_absolute_times_toward_negative_infinity() {
|
||||
let f = fx();
|
||||
for (expression, expected) in [
|
||||
("timeToFrames(-0.01, 24, false)", -1.0),
|
||||
("timeToFrames(-0.05, 24, false)", -2.0),
|
||||
("timeToFrames(0, 24, false)", 0.0),
|
||||
("timeToFrames(-0.125, 24, false)", -3.0),
|
||||
("timeToFrames(0.125, 24, false)", 3.0),
|
||||
("timeToFrames(0.01, 24, false)", 0.0),
|
||||
("timeToFrames(0.05, 24, false)", 1.0),
|
||||
] {
|
||||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_to_frames_preserves_default_arguments() {
|
||||
let f = fx();
|
||||
// The fixture's composition is 30 fps with displayStartTime = 0.
|
||||
for (expression, evaluation_time, expected) in [
|
||||
("timeToFrames(0.01, 24)", 0.0, 0.0),
|
||||
("timeToFrames(-0.01, 24)", 0.0, -1.0),
|
||||
("timeToFrames(0.05)", 0.0, 1.0),
|
||||
("timeToFrames(-0.05)", 0.0, -2.0),
|
||||
("timeToFrames()", 0.0, 0.0),
|
||||
("timeToFrames()", 0.05, 1.0),
|
||||
("timeToFrames()", -0.05, -2.0),
|
||||
("timeToFrames(undefined, 24, true)", 0.05, 2.0),
|
||||
("timeToFrames(0.05, undefined, true)", 0.0, 2.0),
|
||||
] {
|
||||
assert_eq!(f.num(expression, evaluation_time), expected, "{expression} at {evaluation_time}s");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_to_frames_preserves_coercion_and_small_durations() {
|
||||
let f = fx();
|
||||
// Duration rounding must not erase tiny nonzero values; absolute times keep the existing tolerance.
|
||||
for (expression, expected) in [
|
||||
("timeToFrames('0.05', '24', false)", 1.0),
|
||||
("timeToFrames('0.05', '24', true)", 2.0),
|
||||
("timeToFrames('-0.05', '24', true)", -2.0),
|
||||
("timeToFrames(null, 24, true)", 0.0),
|
||||
("timeToFrames(0.05, null, true)", 0.0),
|
||||
("timeToFrames(0.05, 0, true)", 0.0),
|
||||
("timeToFrames(31 / 24 - 5 / 24, 24, true)", 26.0),
|
||||
("timeToFrames(5 / 24 - 31 / 24, 24, true)", -26.0),
|
||||
("timeToFrames(-1e-10, 24, false)", 0.0),
|
||||
("1 / timeToFrames(-0, 24, true) === Infinity ? 1 : 0", 1.0),
|
||||
("isNaN(timeToFrames(NaN, 24, true)) ? 1 : 0", 1.0),
|
||||
("timeToFrames(Infinity, 24, true) === Infinity ? 1 : 0", 1.0),
|
||||
("timeToFrames(-Infinity, 24, true) === -Infinity ? 1 : 0", 1.0),
|
||||
("var calls = 0; var t = {valueOf: function () { calls++; return 0.05; }}; timeToFrames(t, 24, true) + 100 * calls", 102.0),
|
||||
] {
|
||||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||||
}
|
||||
}
|
||||
|
||||
mod property_group;
|
||||
|
||||
@@ -917,6 +917,12 @@ impl GpuContext {
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Entries in the layer upload cache.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn upload_entries(&self) -> usize {
|
||||
self.uploads.lock().map(|u| u.map.len()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Upload a (layer cache) buffer, reusing the texture while the same `Arc` is alive: static
|
||||
/// layers upload once, not every frame.
|
||||
pub(crate) fn upload_buf(&self, buf: &Arc<Buf>) -> Option<GpuImage> {
|
||||
@@ -939,14 +945,14 @@ impl GpuContext {
|
||||
u.bytes -= old.bytes;
|
||||
}
|
||||
u.bytes += bytes;
|
||||
// Drop textures of freed buffers, then the least recently used over budget.
|
||||
// Drop textures of freed buffers at once (a layer whose buffer changes every frame
|
||||
// makes a new one each time, and nothing can hit the old one again), then the least
|
||||
// recently used over budget.
|
||||
let budget = self.upload_budget();
|
||||
if u.bytes > budget {
|
||||
let dead: Vec<usize> = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect();
|
||||
for k in dead {
|
||||
if let Some(e) = u.map.remove(&k) {
|
||||
u.bytes -= e.bytes;
|
||||
}
|
||||
let dead: Vec<usize> = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect();
|
||||
for k in dead {
|
||||
if let Some(e) = u.map.remove(&k) {
|
||||
u.bytes -= e.bytes;
|
||||
}
|
||||
}
|
||||
if u.bytes > budget && !self.upload_full_warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
|
||||
@@ -317,6 +317,10 @@ impl Accelerator for Gpu {
|
||||
self.ctx.name.clone()
|
||||
}
|
||||
|
||||
fn available(&self) -> bool {
|
||||
self.ctx.check_health().is_ok()
|
||||
}
|
||||
|
||||
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
|
||||
let img = self.render(r, comp, t);
|
||||
if img.is_none() {
|
||||
|
||||
@@ -154,8 +154,9 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let i = u32(max(floor(hp), 0.0)) % 6u;
|
||||
let f = hp - floor(hp);
|
||||
let wt = w[i] + (w[(i + 1u) % 6u] - w[i]) * f;
|
||||
let base = luminance(c);
|
||||
let gray = max(base + (wt - 0.5) * hsl.y * 0.6, 0.0);
|
||||
let mx = max(max(c.x, c.y), c.z);
|
||||
let mn = min(min(c.x, c.y), c.z);
|
||||
let gray = max(mn + (mx - mn) * wt, 0.0);
|
||||
var o = vec3<f32>(gray);
|
||||
if (P.u[0].y != 0u) {
|
||||
o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0));
|
||||
|
||||
@@ -510,8 +510,10 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let n = (hash_noise(u32(p.x), u32(p.y), P.u[0].y) - 0.5) * jitter * (8.0 / 255.0);
|
||||
c += vec3<f32>(n);
|
||||
}
|
||||
let op = P.f[8].z;
|
||||
textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4<f32>(c * op, op), P.u[0].x));
|
||||
let o = textureLoad(src, p, 0);
|
||||
// None keeps the layer's alpha, as the CPU path does (#1381).
|
||||
let op = select(P.f[8].z, P.f[8].z * o.w, P.u[0].x == 255u);
|
||||
textureStore(out, p, gen_blend(o, vec4<f32>(c * op, op), P.u[0].x));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Noise / Add Grain
|
||||
|
||||
+36
-9
@@ -49,18 +49,29 @@ pub(crate) fn hold_gpu_lock() {
|
||||
|
||||
/// The shared test device (the calling test holds [`hold_gpu_lock`]).
|
||||
pub(crate) fn gpu() -> Option<&'static Gpu> {
|
||||
static G: OnceLock<Option<Gpu>> = OnceLock::new();
|
||||
static G: OnceLock<Result<Gpu, String>> = OnceLock::new();
|
||||
hold_gpu_lock();
|
||||
G.get_or_init(|| {
|
||||
let g = Gpu::headless();
|
||||
if g.is_none() {
|
||||
eprintln!("effectcraft-gpu tests: no GPU adapter, skipping GPU comparisons");
|
||||
} else if let Some(g) = &g {
|
||||
eprintln!("effectcraft-gpu tests: adapter {}", g.ctx.name);
|
||||
let g = G.get_or_init(|| {
|
||||
let g = Gpu::try_headless();
|
||||
match &g {
|
||||
Ok(g) => eprintln!("effectcraft-gpu tests: adapter {}", g.ctx.name),
|
||||
Err(e) => eprintln!("effectcraft-gpu tests: no GPU ({e}), skipping GPU comparisons"),
|
||||
}
|
||||
g
|
||||
})
|
||||
.as_ref()
|
||||
});
|
||||
require_gpu("effectcraft-gpu tests", g.as_ref())
|
||||
}
|
||||
|
||||
/// `g`, or `None` (the GPU tests skip) when there is no GPU, unless `EFFECTCRAFT_REQUIRE_GPU=1`
|
||||
/// (as in `tests_init`): then a GPU that didn't start fails the test, with why.
|
||||
pub(crate) fn require_gpu<G>(tests: &str, g: Result<G, &String>) -> Option<G> {
|
||||
match g {
|
||||
Ok(g) => Some(g),
|
||||
Err(e) if std::env::var("EFFECTCRAFT_REQUIRE_GPU").is_ok_and(|v| v == "1") => {
|
||||
panic!("{tests}: EFFECTCRAFT_REQUIRE_GPU=1 but GPU initialization failed: {e}")
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The test device is on wgpu's OpenGL backend (Mesa's llvmpipe on the FreeBSD CI job).
|
||||
@@ -1044,3 +1055,19 @@ fn static_effect_layers_draw_from_the_cache() {
|
||||
assert!(frames[1] == frames[2]);
|
||||
check("static effect layer vs CPU", compare_at(&s, opts(), t(2)), 0.0);
|
||||
}
|
||||
|
||||
/// The layer upload cache drops the textures of freed buffers when it inserts, not only once
|
||||
/// it is over budget (an animated layer makes a new buffer every frame).
|
||||
#[test]
|
||||
fn upload_cache_drops_textures_of_freed_buffers() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let img = effectcraft_raster::Image::new(256, 256);
|
||||
let before = g.ctx.upload_entries();
|
||||
for _ in 0..6 {
|
||||
let buf = std::sync::Arc::new(effectcraft_effects::Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 });
|
||||
let up = g.ctx.upload_buf(&buf).expect("upload");
|
||||
drop((up, buf));
|
||||
}
|
||||
let kept = g.ctx.upload_entries();
|
||||
assert!(kept <= before + 1, "the upload cache kept {kept} textures of freed buffers (had {before})");
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ use crate::Gpu;
|
||||
/// A device of the test's own (the test holds [`crate::tests::hold_gpu_lock`]).
|
||||
fn gpu() -> Option<Gpu> {
|
||||
crate::tests::hold_gpu_lock();
|
||||
let g = Gpu::headless();
|
||||
if g.is_none() {
|
||||
eprintln!("effectcraft-gpu adv3d tests: no GPU adapter, skipping");
|
||||
match Gpu::try_headless() {
|
||||
Ok(g) => Some(g),
|
||||
Err(e) => {
|
||||
eprintln!("effectcraft-gpu adv3d tests: no GPU ({e}), skipping");
|
||||
crate::tests::require_gpu("effectcraft-gpu adv3d tests", Err(&e))
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
fn add(p: &mut Project, cid: ItemId, src: LayerSource, edit: impl FnOnce(&mut effectcraft_project::Layer)) {
|
||||
|
||||
@@ -17,19 +17,19 @@ use crate::tests::{Pattern, Scene, diff, n, set, tolerance, v3};
|
||||
/// The device, held exclusively: deferred state is per device and a frame renders at a time
|
||||
/// (a frame worker renders one frame at a time too).
|
||||
pub(crate) fn deferred_gpu() -> Option<(&'static Gpu, std::sync::MutexGuard<'static, ()>)> {
|
||||
static G: OnceLock<Option<Gpu>> = OnceLock::new();
|
||||
static G: OnceLock<Result<Gpu, String>> = OnceLock::new();
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
// Also no other GPU test while it runs (see `hold_gpu_lock`).
|
||||
crate::tests::hold_gpu_lock();
|
||||
let g = G
|
||||
.get_or_init(|| {
|
||||
let g = pollster::block_on(Gpu::request_deferred()).map_err(|e| eprintln!("deferred gpu tests skipped: {e}")).ok();
|
||||
if let Some(g) = &g {
|
||||
eprintln!("deferred gpu tests: adapter {}", g.ctx.name);
|
||||
}
|
||||
g
|
||||
})
|
||||
.as_ref()?;
|
||||
let g = G.get_or_init(|| {
|
||||
let g = pollster::block_on(Gpu::request_deferred());
|
||||
match &g {
|
||||
Ok(g) => eprintln!("deferred gpu tests: adapter {}", g.ctx.name),
|
||||
Err(e) => eprintln!("deferred gpu tests: no GPU ({e}), skipping"),
|
||||
}
|
||||
g
|
||||
});
|
||||
let g = crate::tests::require_gpu("deferred gpu tests", g.as_ref())?;
|
||||
Some((g, LOCK.lock().unwrap_or_else(|e| e.into_inner())))
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ fn color_balance_vibrance_tritone_mixer() {
|
||||
fn black_white_colorama_selective_color() {
|
||||
effect_case("ec.color.blackwhite", &[]);
|
||||
effect_case("ec.color.blackwhite", &[("reds", n(120.0)), ("blues", n(-50.0)), ("cyans", n(200.0)), ("tint", on())]);
|
||||
effect_case("ec.color.blackwhite", &[("yellows", n(300.0)), ("greens", n(-200.0)), ("magentas", n(0.0))]);
|
||||
effect_case("ec.color.colorama", &[]);
|
||||
effect_case("ec.color.colorama", &[("inputPhase/mode", e(4)), ("inputPhase/phaseShift", n(75.0)), ("outputCycle/cycles", n(2.5)), ("blend", n(40.0))]);
|
||||
effect_case("ec.color.colorama", &[("inputPhase/mode", e(8)), ("outputCycle/cycles", n(1.3))]);
|
||||
|
||||
@@ -203,6 +203,28 @@ fn echo_and_posterize_time() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn echo_after_star_burst_preserves_the_stars() {
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||||
for operator in 0..7 {
|
||||
for posterize in [false, true] {
|
||||
let mut s = Scene::new(depth);
|
||||
let mut l = s.solid([1.0; 3], 97, 61);
|
||||
s.effect(&mut l, "ec.sim.ccstarburst", &[("gridSpacing", n(8.0)), ("size", n(40.0))]);
|
||||
s.effect(&mut l, "ec.time.echo", &[("numberOfEchoes", n(2.0)), ("echoTime", n(-0.1)), ("decay", n(0.6)), ("echoOperator", e(operator))]);
|
||||
if posterize {
|
||||
s.effect(&mut l, "ec.time.posterizetime", &[("frameRate", n(4.0))]);
|
||||
}
|
||||
s.push(l);
|
||||
let t = Tick::from_seconds_f64(0.6);
|
||||
let label = format!("Star Burst + Echo {operator} posterize={posterize} {depth:?}");
|
||||
check(&label, compare_at(&s, opts(), t), 0.0);
|
||||
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_levels_match_cpu_division() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
|
||||
@@ -73,6 +73,9 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut
|
||||
let levels = r.pipe().levels;
|
||||
let mut i = 0;
|
||||
while i < visible.len() {
|
||||
// A device that ran out of memory (or was lost) on an earlier layer fails every later
|
||||
// one: give the frame back to the CPU now instead of drawing the rest of the comp.
|
||||
e.g.check_health().ok()?;
|
||||
if visible[i].is_3d() {
|
||||
let mut j = i;
|
||||
while j < visible.len() && visible[j].is_3d() {
|
||||
|
||||
@@ -290,7 +290,8 @@ mod tests {
|
||||
let mut s = super::session();
|
||||
s.exporter = Some(Arc::new(super::FileExporter { sink: Some(Arc::new(move |p: &str, d: Vec<u8>| g.lock().unwrap().push((p.to_string(), d)))) }));
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let dir = "/no-such-dir-effectcraft-sink-test";
|
||||
// Rooted without a drive letter is not absolute on Windows, where the engine joins it to the home drive.
|
||||
let dir = if cfg!(windows) { "C:/no-such-dir-effectcraft-sink-test" } else { "/no-such-dir-effectcraft-sink-test" };
|
||||
s.execute(
|
||||
"renderQueue.add",
|
||||
json!({"format": "gif", "output": format!("{dir}/a.gif"), "resolution": 0.0625, "timeSpan": "custom", "start": 0.0, "end": 0.2}),
|
||||
|
||||
@@ -38,14 +38,14 @@ fn replacing_a_missing_file_relinks_the_others_that_moved_with_it() {
|
||||
let before = footage_paths(&s);
|
||||
assert_eq!(before.len(), 2, "{before:?}");
|
||||
// The job folder moves; both files go missing.
|
||||
let new = root.join("Archive/Job");
|
||||
let new = root.join("Archive").join("Job");
|
||||
std::fs::create_dir_all(root.join("Archive")).unwrap();
|
||||
std::fs::rename(&old, &new).unwrap();
|
||||
let r = s.execute("footage.check", json!({"wait": true})).unwrap();
|
||||
assert_eq!(r["missing"], 2, "{r}");
|
||||
// Locating one finds the other.
|
||||
let a = before.iter().find(|(_, p, _)| p.ends_with("a.png")).unwrap().0;
|
||||
let r = s.execute("file.replaceFootage", json!({"item": a, "path": new.join("stills/a.png").to_string_lossy()})).unwrap();
|
||||
let r = s.execute("file.replaceFootage", json!({"item": a, "path": new.join("stills").join("a.png").to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["relinked"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let after = footage_paths(&s);
|
||||
assert!(after.iter().all(|(_, p, missing)| p.starts_with(&*new.to_string_lossy()) && !missing), "{after:?}");
|
||||
|
||||
@@ -260,23 +260,25 @@ fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() {
|
||||
// Import As: Composition — one layer per file layer, top first, together like the page.
|
||||
let path = tmp("layers.ai");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["Art", "Sky"]);
|
||||
assert_eq!((comp.width, comp.height), (120, 80));
|
||||
let art = comp.layers[0].id.0;
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
let d = mean_diff(&img, &direct);
|
||||
assert!(d < 1.0 / 255.0, "layered comp vs page {d}");
|
||||
// Each layer's footage shows only its layer.
|
||||
s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap();
|
||||
let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
assert_eq!(sky.get(5, 5), img.get(5, 5));
|
||||
let p = sky.get(60, 30);
|
||||
assert!(p[2] > p[0], "only sky under the art {p:?}");
|
||||
for import_as in ["composition", "compositionLayerSizes"] {
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap();
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["Art", "Sky"]);
|
||||
assert_eq!((comp.width, comp.height), (120, 80));
|
||||
let art = comp.layers[0].id.0;
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
let d = mean_diff(&img, &direct);
|
||||
assert!(d < 1.0 / 255.0, "layered comp vs page {d}");
|
||||
// Each layer's footage shows only its layer.
|
||||
s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap();
|
||||
let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
assert_eq!(sky.get(5, 5), img.get(5, 5));
|
||||
let p = sky.get(60, 30);
|
||||
assert!(p[2] > p[0], "only sky under the art {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -522,3 +524,75 @@ fn shapes_from_vector_layer_keeps_images_as_footage_layers() {
|
||||
assert!((0..3).all(|k| (a[k] - b[k]).abs() < 0.05), "({x}, {y}): {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn retain_pdf(width: u32) -> Vec<u8> {
|
||||
effectcraft_pdf::write::pdf(
|
||||
&[
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> >> /Contents 4 0 R >>".into(), None),
|
||||
(
|
||||
4,
|
||||
"<< >>".into(),
|
||||
Some(
|
||||
format!("/OC /L0 BDC q 20 15 {width} 20 re W n 1 0 0 rg 0 0 120 80 re f Q EMC /OC /L1 BDC 0 0 1 RG 4 w 70 40 m 100 40 l S EMC")
|
||||
.into_bytes(),
|
||||
),
|
||||
),
|
||||
(5, "<< /Type /OCG /Name (Box) >>".into(), None),
|
||||
(6, "<< /Type /OCG /Name (Line) >>".into(), None),
|
||||
],
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_composition_retains_layer_sizes_and_positions() {
|
||||
use effectcraft_project::{ItemKind, LayerSource, Project};
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
for name in ["retain.ai", "retain.pdf"] {
|
||||
let path = tmp(name);
|
||||
let bytes = retain_pdf(30);
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
assert_eq!((comp.width, comp.height), (120, 80));
|
||||
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Line", "Box"]);
|
||||
let mut ids = vec![];
|
||||
for (name, size, anchor, position) in [("Box", (30, 20), [15.0, 10.0], [35.0, 55.0]), ("Line", (30, 4), [15.0, 2.0], [85.0, 40.0])] {
|
||||
let l = comp.layer_by_name(name).unwrap();
|
||||
let LayerSource::Footage { item } = l.source else { panic!("footage") };
|
||||
let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") };
|
||||
assert_eq!((f.width, f.height), size, "{name} uses its artwork bounds");
|
||||
assert!(f.layer.as_ref().unwrap().layer_size);
|
||||
assert_eq!(l.props.prop("transform/anchor").unwrap().value.as_vec2(), anchor);
|
||||
assert_eq!(l.props.prop("transform/position").unwrap().value.as_vec2(), position);
|
||||
let frame = s.footage.frame(item, f, Tick::ZERO).unwrap();
|
||||
assert_eq!((frame.width, frame.height), size);
|
||||
let sharp = s.footage.vector_frame(item, f, 2.0).unwrap();
|
||||
assert_eq!((sharp.width, sharp.height), (size.0 * 2, size.1 * 2));
|
||||
assert!(sharp.get(1, 1)[3] > 0.99);
|
||||
ids.push(item);
|
||||
}
|
||||
let direct = effectcraft_svg::rasterize(&effectcraft_pdf::parse(&bytes).unwrap(), 120, 80, 1.0);
|
||||
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001);
|
||||
let saved = s.project.to_json();
|
||||
s.project = std::sync::Arc::new(Project::from_json(&saved).unwrap());
|
||||
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "reopened project");
|
||||
let box_id = s.active_comp().unwrap().layer_by_name("Box").unwrap().id.0;
|
||||
s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [box_id]})).unwrap();
|
||||
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "shape conversion preserves placement");
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
std::fs::write(&path, retain_pdf(40)).unwrap();
|
||||
s.execute_checked("file.reloadFootage", json!({"items": ids})).unwrap();
|
||||
let ItemKind::Footage(f) = &s.project.item(ids[0]).unwrap().kind else { panic!("footage") };
|
||||
assert_eq!((f.width, f.height), (40, 20), "reload refreshes the layer's own dimensions");
|
||||
let frame = s.footage.frame(ids[0], f, Tick::ZERO).unwrap();
|
||||
assert_eq!((frame.width, frame.height), (40, 20));
|
||||
assert!(frame.get(39, 10)[0] > 0.99, "reload invalidates cached pixels");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! A WebM rendered by EffectCraft and imported back reports its real frame count (#1429). Matroska
|
||||
//! stores the duration in whole milliseconds, so at 30 fps a 1-frame file lasts 33 ms, just short
|
||||
//! of one 33.33 ms frame.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-host-webm-meta-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_webm_at_30_fps_reports_every_frame() {
|
||||
for n in [1u32, 4] {
|
||||
let mut s = effectcraft_host::session();
|
||||
let out = tmp(&format!("frames-{n}.webm"));
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "BG", "color": "#142850"})).unwrap();
|
||||
let span = json!({"comp": "Main", "format": "webm", "output": out, "timeSpan": "custom", "start": 0, "duration": n as f64 / 30.0});
|
||||
s.execute("renderQueue.add", span).unwrap();
|
||||
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
|
||||
let r = s.execute("file.import", json!({"paths": [out.clone()]})).unwrap();
|
||||
let m = s.execute("item.metadata", json!({"item": r["items"][0]})).unwrap();
|
||||
let _ = std::fs::remove_file(&out);
|
||||
assert_eq!(m["item"]["frames"], json!(n), "{n} rendered frames: {m}");
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,16 @@ impl ShapePath {
|
||||
pub fn len(&self) -> usize {
|
||||
self.vertices.len()
|
||||
}
|
||||
/// Give both tangent arrays one entry per vertex: missing tangents become zero (a corner),
|
||||
/// extra ones are dropped. The three arrays are read independently from a project file, so a
|
||||
/// loaded path can disagree with itself (#1250). Returns whether anything changed.
|
||||
pub fn fit_tangents(&mut self) -> bool {
|
||||
let n = self.vertices.len();
|
||||
let changed = self.in_tangents.len() != n || self.out_tangents.len() != n;
|
||||
self.in_tangents.resize(n, [0.0; 2]);
|
||||
self.out_tangents.resize(n, [0.0; 2]);
|
||||
changed
|
||||
}
|
||||
/// Number of segments (vertex to next vertex, the closing one included).
|
||||
pub fn segment_count(&self) -> usize {
|
||||
let n = self.vertices.len();
|
||||
|
||||
@@ -308,7 +308,7 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve
|
||||
"op" => {
|
||||
o.put("a", "amount", &anim::num);
|
||||
o.raw("lj", json!(o.e("join") + 1));
|
||||
o.raw("ml", json!(o.f("miter")));
|
||||
o.put("ml", "miter", &anim::num);
|
||||
if (o.f("copies") - 1.0).abs() > 1e-9 || o.animated("copies") || o.f("copyOffset") != 0.0 {
|
||||
o.ex.warn(format!("{lbl}: Offset Paths copies are not part of Lottie (one copy exported)"));
|
||||
}
|
||||
@@ -543,9 +543,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
|
||||
if let Some(lj) = int(j, "lj") {
|
||||
set_enum(&mut g, "join", lj.saturating_sub(1).clamp(0, 2) as u32);
|
||||
}
|
||||
if let (Some(ml), Some(p)) = (j.get("ml").and_then(Json::as_f64), g.get_mut("miter")) {
|
||||
p.value = Value::Scalar(ml);
|
||||
}
|
||||
set(im, &mut g, "miter", j.get("ml"), &anim::to_scalar);
|
||||
}
|
||||
"pb" => set(im, &mut g, "amount", j.get("a"), &anim::to_scalar),
|
||||
"tw" => {
|
||||
|
||||
@@ -358,6 +358,55 @@ fn path_operators_roundtrip() {
|
||||
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_paths_miter_uses_animatable_lottie_value() {
|
||||
let (mut p, cid) = setup();
|
||||
let l = shape_layer(&mut p, cid, |ids| {
|
||||
let mut animated = build::shape_simple_op(ids, "offset").unwrap();
|
||||
animated.get_mut("miter").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(5.0)), Keyframe::new(s(1.0), Value::Scalar(9.0))];
|
||||
let mut constant = build::shape_simple_op(ids, "offset").unwrap();
|
||||
constant.get_mut("miter").unwrap().value = Value::Scalar(6.0);
|
||||
let mut stroke = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 2.0);
|
||||
stroke.get_mut("miter").unwrap().value = Value::Scalar(7.0);
|
||||
vec![animated, constant, stroke]
|
||||
});
|
||||
push(&mut p, cid, l);
|
||||
|
||||
let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let shapes = &json["layers"][0]["shapes"];
|
||||
assert_eq!(shapes[0]["ty"], "op");
|
||||
assert_eq!(shapes[0]["ml"]["a"], json!(1));
|
||||
assert_eq!(shapes[0]["ml"]["k"][0]["s"], json!([5.0]));
|
||||
assert_eq!(shapes[0]["ml"]["k"][1]["s"], json!([9.0]));
|
||||
assert_eq!(shapes[1]["ml"], json!({"a": 0, "k": 6.0}));
|
||||
assert_eq!(shapes[2]["ty"], "st");
|
||||
assert_eq!(shapes[2]["ml"], json!(7.0));
|
||||
assert!(compare_comps(&p, cid, &q, nc) > 10);
|
||||
let restored = &q.comp(nc).unwrap().layers[0];
|
||||
assert_eq!(restored.props.group("contents/offset").unwrap().get("miter").unwrap().keys.len(), 2);
|
||||
|
||||
// Previously exported Offset Paths have a scalar ml; they remain importable.
|
||||
let mut old = json;
|
||||
old["layers"][0]["shapes"][1]["ml"] = json!(6.0);
|
||||
let mut legacy = Project::default();
|
||||
let bytes = serde_json::to_vec(&old).unwrap();
|
||||
let imported = import(&mut legacy, &bytes, "Legacy", "", &mut |_| None).unwrap();
|
||||
let constant = &legacy.comp(imported.comp).unwrap().layers[0].props;
|
||||
let second = constant
|
||||
.sub("contents")
|
||||
.unwrap()
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|n| match n {
|
||||
Node::Group(g) if g.match_id == "offset" => Some(g),
|
||||
_ => None,
|
||||
})
|
||||
.nth(1)
|
||||
.unwrap();
|
||||
assert_eq!(second.get("miter").unwrap().value, Value::Scalar(6.0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- layers
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,6 +24,8 @@ filmcraft-isobmff = { workspace = true }
|
||||
filmcraft-frame = { workspace = true }
|
||||
filmcraft-color = { workspace = true }
|
||||
filmcraft-time = { workspace = true }
|
||||
# AIFF import (#1428): enables the aiff reader of filmcraft-codecs' symphonia.
|
||||
symphonia = { workspace = true }
|
||||
exr = { workspace = true }
|
||||
gif = { workspace = true }
|
||||
image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] }
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn vector_doc(
|
||||
Some(
|
||||
effectcraft_pdf::parse_page(bytes, page as usize)
|
||||
.map(|d| match layer {
|
||||
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
|
||||
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
|
||||
None => d,
|
||||
})
|
||||
.map_err(|e| format!("{path}: {e}")),
|
||||
|
||||
@@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result<Model> {
|
||||
let Some(key) = it.next() else { continue };
|
||||
let bad = || perr(format!("STL line {}: expected three numbers", ln + 1));
|
||||
match key.to_ascii_lowercase().as_str() {
|
||||
"solid" => {
|
||||
if name.is_none() {
|
||||
name = Some(line.get(5..).unwrap_or("").trim().to_string());
|
||||
}
|
||||
"solid" if name.is_none() => {
|
||||
name = Some(line.get(5..).unwrap_or("").trim().to_string());
|
||||
}
|
||||
"facet" => {
|
||||
// `facet normal nx ny nz`
|
||||
|
||||
@@ -4,7 +4,9 @@ Vector footage from PDF files, PDF-compatible Adobe Illustrator files (`.ai`, wh
|
||||
documents) and Encapsulated PostScript (`.eps`). Files parse into the same render tree as SVG
|
||||
(`effectcraft_svg::Doc`), so they rasterise at any scale (Continuously Rasterize), convert to shape
|
||||
layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as compositions with one
|
||||
layer per file layer (File ▸ Import ▸ Composition).
|
||||
layer per file layer (File ▸ Import ▸ Composition). Composition - Retain Layer Sizes uses each
|
||||
layer's artwork bounds and keeps its placement on the page. Images keep their rectangular bounds;
|
||||
clipping paths constrain the bounds, while soft masks do not change them.
|
||||
|
||||
Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file
|
||||
structure, filters, content streams, graphics state, colour spaces, functions, shadings, text,
|
||||
|
||||
+148
-7
@@ -394,11 +394,16 @@ impl Cff {
|
||||
let private = |pd: &[(u16, Vec<f64>)]| -> (Vec<std::ops::Range<usize>>, (f64, f64)) {
|
||||
let Some(pv) = get(pd, 18).filter(|v| v.len() == 2) else { return (vec![], (0.0, 0.0)) };
|
||||
let (size, off) = (pv[0] as usize, pv[1] as usize);
|
||||
let Some(bytes) = d.get(off..off + size) else { return (vec![], (0.0, 0.0)) };
|
||||
let Some(bytes) = off.checked_add(size).and_then(|end| d.get(off..end)) else { return (vec![], (0.0, 0.0)) };
|
||||
let pr = dict(bytes);
|
||||
let nominal = get(&pr, 21).and_then(|v| v.first().copied()).unwrap_or(0.0);
|
||||
let default = get(&pr, 20).and_then(|v| v.first().copied()).unwrap_or(0.0);
|
||||
let subrs = get(&pr, 19).and_then(|v| v.first().copied()).and_then(|s| index(d, off + s as usize)).map(|x| x.0).unwrap_or_default();
|
||||
let subrs = get(&pr, 19)
|
||||
.and_then(|v| v.first().copied())
|
||||
.and_then(|s| off.checked_add(s as usize))
|
||||
.and_then(|p| index(d, p))
|
||||
.map(|x| x.0)
|
||||
.unwrap_or_default();
|
||||
(subrs, (default, nominal))
|
||||
};
|
||||
let mut lsubrs = vec![];
|
||||
@@ -533,6 +538,10 @@ impl Cff {
|
||||
|
||||
/// A glyph's outline in glyph space (`font_matrix` maps it to text space) and its advance.
|
||||
pub fn outline(&self, gid: u16) -> Option<(BezPath, f64)> {
|
||||
self.outline_depth(gid, 0)
|
||||
}
|
||||
|
||||
fn outline_depth(&self, gid: u16, depth: usize) -> Option<(BezPath, f64)> {
|
||||
let r = self.charstrings.get(gid as usize)?.clone();
|
||||
let fd = self.fdselect.get(gid as usize).copied().unwrap_or(0) as usize;
|
||||
let fd = fd.min(self.lsubrs.len() - 1);
|
||||
@@ -547,13 +556,17 @@ impl Cff {
|
||||
_ => self.widths[fd].0,
|
||||
};
|
||||
let mut path = st.path;
|
||||
// endchar seac: base and accent glyphs by standard code.
|
||||
if let Some((adx, ady, b, a)) = st.seac {
|
||||
// endchar seac: base and accent glyphs by standard code. The nesting is bounded like the
|
||||
// Type 1 interpreter's: a font whose accented glyphs seac into each other (or into
|
||||
// themselves) must not recurse until the stack overflows.
|
||||
if let Some((adx, ady, b, a)) = st.seac
|
||||
&& depth < 2
|
||||
{
|
||||
let find = |code: u8| crate::encoding::Base::Standard.name(code).and_then(|n| self.gid_by_name(&n));
|
||||
if let Some((bp, _)) = find(b).and_then(|g| self.outline(g)) {
|
||||
if let Some((bp, _)) = find(b).and_then(|g| self.outline_depth(g, depth + 1)) {
|
||||
path.extend(bp);
|
||||
}
|
||||
if let Some((ap, _)) = find(a).and_then(|g| self.outline(g)) {
|
||||
if let Some((ap, _)) = find(a).and_then(|g| self.outline_depth(g, depth + 1)) {
|
||||
path.extend(kurbo::Affine::translate((adx, ady)) * ap);
|
||||
}
|
||||
}
|
||||
@@ -835,7 +848,9 @@ impl T2<'_> {
|
||||
}
|
||||
11 => return,
|
||||
14 => {
|
||||
self.take_width(false);
|
||||
// A width precedes the operands when the count is odd (5 with seac, 1 alone);
|
||||
// the no-width counts (0 and 4) are even, like the stems' and rmoveto's.
|
||||
self.take_width(true);
|
||||
if self.stack.len() >= 4 {
|
||||
let n = self.stack.len();
|
||||
let s = &self.stack[n - 4..];
|
||||
@@ -1048,4 +1063,130 @@ mod tests {
|
||||
assert!((p.area().abs() - 250_000.0).abs() < 1e-6, "{}", p.area());
|
||||
assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0));
|
||||
}
|
||||
|
||||
/// An `endchar` seac whose base and accent name its own glyph used to recurse until the
|
||||
/// stack overflowed and aborted the app; the depth limit cuts it off like the Type 1
|
||||
/// interpreter's.
|
||||
#[test]
|
||||
fn seac_cycles_are_cut_off_not_a_stack_overflow() {
|
||||
let enc = |v: i32| -> Vec<u8> {
|
||||
if (-107..=107).contains(&v) {
|
||||
vec![(v + 139) as u8]
|
||||
} else {
|
||||
let mut o = vec![28];
|
||||
o.extend_from_slice(&(v as i16).to_be_bytes());
|
||||
o
|
||||
}
|
||||
};
|
||||
// `w adx ady bchar achar endchar`: both components are 'A' (standard code 65), this glyph.
|
||||
let mut cs = vec![];
|
||||
for v in [500, 0, 0, 65, 65] {
|
||||
cs.extend(enc(v));
|
||||
}
|
||||
cs.push(14);
|
||||
let font = write_test_cff(&[(".notdef", vec![14]), ("A", cs)]);
|
||||
let cff = Cff::parse(&font).unwrap();
|
||||
let (p, _) = cff.outline(cff.gid_by_name("A").unwrap()).unwrap();
|
||||
assert!(p.elements().is_empty());
|
||||
}
|
||||
|
||||
/// A CFF whose top DICT points its Private DICT at offsets near `usize::MAX`: `size` and `off`
|
||||
/// come from the file, and reading the Private DICT must not overflow `off + size`.
|
||||
#[test]
|
||||
fn huge_private_dict_offsets_do_not_overflow() {
|
||||
fn idx(items: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut out = (items.len() as u16).to_be_bytes().to_vec();
|
||||
if items.is_empty() {
|
||||
return out;
|
||||
}
|
||||
out.push(4);
|
||||
let mut off = 1u32;
|
||||
out.extend_from_slice(&off.to_be_bytes());
|
||||
for it in items {
|
||||
off += it.len() as u32;
|
||||
out.extend_from_slice(&off.to_be_bytes());
|
||||
}
|
||||
for it in items {
|
||||
out.extend_from_slice(it);
|
||||
}
|
||||
out
|
||||
}
|
||||
let int = |v: i32| {
|
||||
let mut o = vec![29];
|
||||
o.extend_from_slice(&v.to_be_bytes());
|
||||
o
|
||||
};
|
||||
// The BCD real "1E300": a size and an offset that both saturate at `usize::MAX`.
|
||||
let huge = vec![0x1E, 0x1B, 0x30, 0x0F];
|
||||
let header = [1u8, 0, 4, 4];
|
||||
let names = idx(&[b"Test".to_vec()]);
|
||||
let strings = idx(&[]);
|
||||
let gsubrs = idx(&[]);
|
||||
let charset = vec![0u8];
|
||||
let cs = idx(&[vec![14]]); // one `.notdef` charstring: endchar
|
||||
let top_len = 5 + 1 + 5 + 1 + huge.len() * 2 + 1;
|
||||
let top_index_len = 2 + 1 + 8 + top_len;
|
||||
let base = header.len() + names.len() + top_index_len + strings.len() + gsubrs.len();
|
||||
let cs_off = base + charset.len();
|
||||
let mut top = vec![];
|
||||
top.extend(int(0));
|
||||
top.push(15); // charset: ISOAdobe
|
||||
top.extend(int(cs_off as i32));
|
||||
top.push(17); // CharStrings
|
||||
top.extend(&huge);
|
||||
top.extend(&huge);
|
||||
top.push(18); // Private: [size, offset]
|
||||
assert_eq!(top.len(), top_len);
|
||||
let mut out = header.to_vec();
|
||||
out.extend(names);
|
||||
out.extend(idx(&[top]));
|
||||
out.extend(strings);
|
||||
out.extend(gsubrs);
|
||||
out.extend(charset);
|
||||
out.extend(cs);
|
||||
let cff = Cff::parse(&out).expect("the damaged Private DICT is skipped, the font parses");
|
||||
assert_eq!(cff.glyph_count(), 1);
|
||||
}
|
||||
|
||||
/// A four-operand `endchar` (`adx ady bchar achar`, no width) is a seac like the Type 1
|
||||
/// interpreter's: `endchar` must not eat `adx` as a width, and a five-operand one reads its
|
||||
/// width first.
|
||||
#[test]
|
||||
fn endchar_reads_its_width_by_operand_count() {
|
||||
let enc = |v: i32| -> Vec<u8> {
|
||||
if (-107..=107).contains(&v) {
|
||||
vec![(v + 139) as u8]
|
||||
} else {
|
||||
let mut o = vec![28];
|
||||
o.extend_from_slice(&(v as i16).to_be_bytes());
|
||||
o
|
||||
}
|
||||
};
|
||||
// The base glyph: a square drawn without a width (`500 500 -500 hlineto endchar`).
|
||||
let mut square = vec![];
|
||||
for v in [500, 500, -500] {
|
||||
square.extend(enc(v));
|
||||
}
|
||||
square.extend([6, 14]);
|
||||
// Four operands: `adx ady bchar achar endchar`, no width to take.
|
||||
let mut seac = vec![];
|
||||
for v in [10, 20, 65, 65] {
|
||||
seac.extend(enc(v));
|
||||
}
|
||||
seac.push(14);
|
||||
let font = write_test_cff(&[(".notdef", vec![14]), ("A", square.clone()), ("Aacute", seac)]);
|
||||
let cff = Cff::parse(&font).unwrap();
|
||||
let (p, _) = cff.outline(cff.gid_by_name("Aacute").unwrap()).unwrap();
|
||||
assert!(!p.elements().is_empty(), "the base glyph of the seac is part of the outline");
|
||||
// Five operands: the first is the width, the last four the seac.
|
||||
let mut wide = vec![];
|
||||
for v in [600, 0, 0, 65, 65] {
|
||||
wide.extend(enc(v));
|
||||
}
|
||||
wide.push(14);
|
||||
let font = write_test_cff(&[(".notdef", vec![14]), ("A", square), ("Awide", wide)]);
|
||||
let cff = Cff::parse(&font).unwrap();
|
||||
let (_, w) = cff.outline(cff.gid_by_name("Awide").unwrap()).unwrap();
|
||||
assert_eq!(w, 600.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,10 +208,10 @@ pub fn name_to_unicode(name: &str) -> Option<char> {
|
||||
if let Some((_, u)) = NAMES.iter().find(|(n, _)| *n == base) {
|
||||
return char::from_u32(*u);
|
||||
}
|
||||
if let Some(h) = base.strip_prefix("uni")
|
||||
&& h.len() >= 4
|
||||
{
|
||||
return u32::from_str_radix(&h[..4], 16).ok().and_then(char::from_u32);
|
||||
// `get(..4)` also insists on a character boundary: a name like `uni\u{20AC}\u{20AC}` used to
|
||||
// panic slicing inside a multi-byte character.
|
||||
if let Some(x) = base.strip_prefix("uni").and_then(|h| h.get(..4)) {
|
||||
return u32::from_str_radix(x, 16).ok().and_then(char::from_u32);
|
||||
}
|
||||
if let Some(h) = base.strip_prefix('u')
|
||||
&& (4..=6).contains(&h.len())
|
||||
@@ -386,4 +386,12 @@ mod tests {
|
||||
assert_eq!(unicode_to_name('Ω'), "Omega");
|
||||
assert_eq!(unicode_to_name('中'), "uni4E2D");
|
||||
}
|
||||
|
||||
/// A glyph name like `uni\u{20AC}\u{20AC}` (multi-byte UTF-8 after `uni`) must not slice
|
||||
/// inside a character.
|
||||
#[test]
|
||||
fn multibyte_glyph_names_do_not_panic() {
|
||||
assert_eq!(name_to_unicode("uni\u{20AC}\u{20AC}"), None);
|
||||
assert_eq!(name_to_unicode("uni\u{20AC}"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,10 @@ pub(crate) fn inline_len(file: &File, d: &Dict, res: Option<&Dict>) -> Option<us
|
||||
let mask = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true)));
|
||||
let bpc = if mask { 1 } else { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize };
|
||||
let n = if mask { 1 } else { file.get(d, "ColorSpace").map(|c| color_space(file, c, res).components()).unwrap_or(1) };
|
||||
Some((w * n * bpc).div_ceil(8) * h)
|
||||
// `Width`/`Height` come straight from the file and can be enormous: fall back to the
|
||||
// `EI` scan (the caller's `None` branch) instead of overflowing the arithmetic.
|
||||
let row = w.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8);
|
||||
row.checked_mul(h)
|
||||
}
|
||||
|
||||
/// Decode an image's samples (all filters, including DCT). Returns (bytes, components from
|
||||
@@ -181,6 +184,11 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec<u8>, usize, usize
|
||||
return Some((data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(w * h).collect(), w, h));
|
||||
}
|
||||
let bpc = if soft { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize } else { 1 };
|
||||
// Like `decode()`: only 1/2/4/8/16-bit depths are supported. Anything else (notably
|
||||
// `bpc >= 32`, where `1u32 << bpc` below would overflow) means "no usable mask".
|
||||
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
|
||||
return None;
|
||||
}
|
||||
let max = ((1u32 << bpc) - 1).max(1) as f64;
|
||||
let dec = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= 2);
|
||||
let v = unpack(&data, w, h, 1, bpc);
|
||||
|
||||
@@ -195,6 +195,27 @@ pub fn layer_doc(doc: &Doc, index: usize) -> Doc {
|
||||
d
|
||||
}
|
||||
|
||||
/// One layer, optionally cropped to its artwork, and its original document-space top-left.
|
||||
pub fn layer_doc_with_sizes(doc: &Doc, index: usize, retain: bool) -> (Doc, [f64; 2]) {
|
||||
let mut d = layer_doc(doc, index);
|
||||
if !retain {
|
||||
return (d, [0.0, 0.0]);
|
||||
}
|
||||
let page = kurbo::Rect::new(0.0, 0.0, doc.width, doc.height);
|
||||
let bounds = d.artwork_bounds().map(|b| b.intersect(page)).filter(|b| b.width() > 0.0 && b.height() > 0.0);
|
||||
let (x, y, w, h) = match bounds {
|
||||
Some(b) => {
|
||||
let (x, y) = (b.x0.floor(), b.y0.floor());
|
||||
(x, y, b.x1.ceil() - x, b.y1.ceil() - y)
|
||||
}
|
||||
None => (0.0, 0.0, 1.0, 1.0),
|
||||
};
|
||||
d.width = w;
|
||||
d.height = h;
|
||||
d.root.transform = Affine::translate((-x, -y)) * d.root.transform;
|
||||
(d, [x, y])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -515,7 +515,7 @@ impl<'a> Interp<'a> {
|
||||
&& (p + 2 == data.len() || crate::object::is_white(data[p + 2]))
|
||||
};
|
||||
let end = match crate::image::inline_len(self.file, &d, Some(res)) {
|
||||
Some(len) => (start + len).min(data.len()),
|
||||
Some(len) => start.saturating_add(len).min(data.len()),
|
||||
None => {
|
||||
let mut p = start;
|
||||
while p + 1 < data.len() && !is_ei(p) {
|
||||
|
||||
@@ -1045,3 +1045,48 @@ fn multiple_pages_and_calculator_shadings() {
|
||||
assert!(img.get(5, 25)[0] < 0.1 && img.get(95, 25)[0] > 0.9, "{:?} {:?}", img.get(5, 25), img.get(95, 25));
|
||||
assert_eq!(parse_page(&bytes, 2), Err(Error::PageOutOfRange(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_layers_keep_strokes_clips_and_empty_layers() {
|
||||
let doc = parse(&sample_pdf(false)).unwrap();
|
||||
for i in 0..layer_names(&doc).len() {
|
||||
let full = layer_doc(&doc, i);
|
||||
let (cropped, offset) = layer_doc_with_sizes(&doc, i, true);
|
||||
let (w, h) = cropped.pixel_size();
|
||||
let image = effectcraft_svg::rasterize(&cropped, w, h, 1.0);
|
||||
let original = effectcraft_svg::rasterize(&full, 200, 100, 1.0);
|
||||
for y in 0..100 {
|
||||
for x in 0..200 {
|
||||
let a = image.get(x - offset[0] as i64, y - offset[1] as i64);
|
||||
let b = original.get(x, y);
|
||||
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-5), "layer {i} at {x},{y}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let (empty, _) = layer_doc_with_sizes(&doc, usize::MAX, true);
|
||||
assert_eq!(empty.pixel_size(), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_layers_measure_transformed_artwork_without_rasterizing() {
|
||||
let doc = effectcraft_svg::parse(br#"<svg width="120" height="80"><g transform="translate(60,0)"><rect x="10" y="20" width="30" height="10" transform="rotate(90)"/></g><path d="M 10 20 L 30 20" fill="none" stroke="red" stroke-width="4" stroke-linecap="square"/></svg>"#).unwrap();
|
||||
let (rotated, offset) = layer_doc_with_sizes(&doc, 0, true);
|
||||
assert_eq!(offset, [30.0, 10.0]);
|
||||
assert_eq!(rotated.pixel_size(), (10, 30));
|
||||
let (stroked, offset) = layer_doc_with_sizes(&doc, 1, true);
|
||||
assert_eq!(offset, [8.0, 18.0]);
|
||||
assert_eq!(stroked.pixel_size(), (24, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_layers_preserve_artwork_beyond_the_bounds_depth_limit() {
|
||||
let mut doc = effectcraft_svg::parse(br#"<svg width="120" height="80"><rect x="20" y="15" width="30" height="20"/></svg>"#).unwrap();
|
||||
for _ in 0..300 {
|
||||
let mut group = Group::new("nested");
|
||||
group.children = std::mem::take(&mut doc.root.children);
|
||||
doc.root.children.push(Node::Group(group));
|
||||
}
|
||||
let (cropped, offset) = layer_doc_with_sizes(&doc, 0, true);
|
||||
assert_eq!(cropped.pixel_size(), (120, 80), "unknown bounds keep the full page");
|
||||
assert_eq!(offset, [0.0, 0.0]);
|
||||
}
|
||||
|
||||
@@ -263,6 +263,43 @@ fn hostile_structures_return_errors_or_draw_nothing() {
|
||||
assert!(!doc.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_image_with_absurd_dimensions_falls_back_to_ei_scan() {
|
||||
// `/W 5000000000000000000` overflows the row math (`* bpc` alone exceeds usize::MAX):
|
||||
// `inline_len` must return `None` (scan for `EI`) instead of overflowing; the oversized
|
||||
// image itself is then refused by size.
|
||||
let bytes = page_pdf("q 10 0 0 10 0 0 cm BI /W 5000000000000000000 /H 100 /CS /G /BPC 8 ID \0 EI Q", "", vec![]);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
let _ = render(&doc);
|
||||
assert!(!doc.skipped.is_empty());
|
||||
// A length just under `usize::MAX` passes the row math; past byte 4096 of the content the
|
||||
// caller's `start + len` would then overflow.
|
||||
let content = format!("{}q 10 0 0 10 0 0 cm BI /W 2305843009213693440 /H 8 /CS /G /BPC 8 ID \0 EI Q", " ".repeat(5000));
|
||||
let doc = parse(&page_pdf(&content, "", vec![])).unwrap();
|
||||
let _ = render(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smask_with_absurd_bpc_is_ignored_not_fatal() {
|
||||
// `/BitsPerComponent 32` in a soft mask: `1u32 << bpc` must neither panic (debug)
|
||||
// nor mistranslate samples into alpha (release); the mask is treated as absent.
|
||||
let bytes = page_pdf(
|
||||
"q 2 0 0 2 0 0 cm /Im Do Q",
|
||||
"/XObject << /Im 10 0 R >>",
|
||||
vec![
|
||||
(
|
||||
10,
|
||||
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 11 0 R >>".into(),
|
||||
Some(vec![0x80; 4]),
|
||||
),
|
||||
(11, "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 32 >>".into(), Some(vec![0xFF; 16])),
|
||||
],
|
||||
);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
let img = render(&doc);
|
||||
assert!(coverage(&img, 0, 0, 200, 100) > 0.0, "image still draws, opaque");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_corrupt_eps_never_panic() {
|
||||
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n\
|
||||
|
||||
+14
-1
@@ -143,7 +143,7 @@ impl Type1 {
|
||||
q += 1;
|
||||
}
|
||||
q += 1;
|
||||
let bytes = priv_.get(q..q + n)?;
|
||||
let bytes = priv_.get(q..q.checked_add(n)?)?;
|
||||
Some((cs(bytes), q + n))
|
||||
};
|
||||
if let Some(p) = find(&priv_, b"/Subrs", 0) {
|
||||
@@ -532,4 +532,17 @@ mod tests {
|
||||
assert!((p.area().abs() - 250_000.0).abs() < 1e-6);
|
||||
assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0));
|
||||
}
|
||||
|
||||
/// A `/Subrs` entry whose `RD` length is `usize::MAX`: the bytes are not in the file, and
|
||||
/// `q + n` must not overflow.
|
||||
#[test]
|
||||
fn huge_rd_lengths_do_not_overflow() {
|
||||
let mut out = b"%!PS-AdobeFont-1.0: Test 001\n/FontMatrix [0.001 0 0 0.001 0 0] readonly def\ncurrentfile eexec\n".to_vec();
|
||||
let mut priv_ = b"dup /Private 8 dict dup begin /lenIV -1 def\n/Subrs 1 array\ndup 0 18446744073709551615 RD\n".to_vec();
|
||||
priv_.extend_from_slice(b"end\n/CharStrings 1 dict dup begin\n/.notdef 4 RD \x8b\x8b\r\x0e ND\nend\nend\nmark currentfile closefile\n");
|
||||
out.extend(encrypt(&priv_, EEXEC_R, 4));
|
||||
out.extend_from_slice(b"\n0000000000000000\ncleartomark\n");
|
||||
let t = Type1::parse(&out).expect("the damaged Subrs entry is skipped");
|
||||
assert!(t.has_glyph(".notdef"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,10 +769,14 @@ impl Crop {
|
||||
if self.use_roi
|
||||
&& let Some([x, y, rw, rh]) = self.roi
|
||||
{
|
||||
let l = (x * scale).round() as i32;
|
||||
let t = (y * scale).round() as i32;
|
||||
let r = w as i32 - ((x + rw) * scale).round() as i32;
|
||||
let b = h as i32 - ((y + rh) * scale).round() as i32;
|
||||
// The quad arrives unvalidated (control channel or a hand-edited project):
|
||||
// clamp like the sibling edge fields (plus/minus 20 000) and subtract
|
||||
// without overflow.
|
||||
let conv = |v: f64| v.round().clamp(-20_000.0, 20_000.0) as i32;
|
||||
let l = conv(x * scale);
|
||||
let t = conv(y * scale);
|
||||
let r = (w as i32).saturating_sub(conv((x + rw) * scale));
|
||||
let b = (h as i32).saturating_sub(conv((y + rh) * scale));
|
||||
return (t, l, b, r);
|
||||
}
|
||||
(self.top, self.left, self.bottom, self.right)
|
||||
@@ -1094,8 +1098,8 @@ impl OutputModule {
|
||||
/// scale `scale`: after Crop and Resize (before codec rounding).
|
||||
pub fn frame_size(&self, w: u32, h: u32, scale: f64) -> (u32, u32) {
|
||||
let (t, l, b, r) = self.crop.edges(w, h, scale);
|
||||
let cw = (w as i32 - l - r).max(1) as u32;
|
||||
let ch = (h as i32 - t - b).max(1) as u32;
|
||||
let cw = (w as i32).saturating_sub(l).saturating_sub(r).max(1) as u32;
|
||||
let ch = (h as i32).saturating_sub(t).saturating_sub(b).max(1) as u32;
|
||||
if !self.resize.enabled {
|
||||
return (cw, ch);
|
||||
}
|
||||
@@ -1643,6 +1647,22 @@ mod tests {
|
||||
assert!(s.frame_time(&c, 0) >= Tick::from_seconds_f64(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_roi_cannot_overflow_crop_math() {
|
||||
// `-1e12` saturates to `i32::MIN` on conversion: `w as i32 - l` must saturate,
|
||||
// not overflow, and still yield a usable (degenerate) frame size.
|
||||
let c = Comp::new(1920, 1080, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
|
||||
let rs = RenderSettings::default();
|
||||
let mut m = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
m.crop = Crop { enabled: true, use_roi: true, roi: Some([-1e12, -1e12, 200.0, 200.0]), ..Default::default() };
|
||||
let (w, h) = m.output_size(&c, &rs);
|
||||
assert!((w, h) == (1, 1), "degenerate but valid: {w}x{h}");
|
||||
// Sane ROIs are unaffected (existing behavior).
|
||||
m.crop = Crop { enabled: true, use_roi: true, roi: Some([100.0, 50.0, 800.0, 400.0]), ..Default::default() };
|
||||
let half = RenderSettings { resolution: 0.5, ..Default::default() };
|
||||
assert_eq!(m.output_size(&c, &half), (400, 200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_quicktime_format() {
|
||||
let f = OutputFormat::from_name("uncompressed").unwrap();
|
||||
|
||||
+5
@@ -78,6 +78,11 @@ impl Dependencies<'_> {
|
||||
hash_debug(self.h, layer);
|
||||
hash_values(self.h, ctx, layer, &layer.props);
|
||||
hash_debug(self.h, &ctx.world_matrix(layer));
|
||||
// A cached owner never runs this layer's effects, so the comp camera and lights its
|
||||
// scene-reading effects see belong in the owner's key too (#1252).
|
||||
if super::reads_comp_scene(layer) {
|
||||
hash_debug(self.h, &crate::comp_scene(ctx, layer));
|
||||
}
|
||||
if super::time_dependent(layer, true) {
|
||||
layer.layer_time(ctx.time).0.hash(self.h);
|
||||
}
|
||||
|
||||
+24
-10
@@ -216,7 +216,7 @@ struct FxHost<'r, 'a, 'c> {
|
||||
/// whose effects run on the comp below).
|
||||
origin: [f64; 2],
|
||||
/// Index of the effect being rendered (bounds `self_at` to effects before it).
|
||||
index: std::sync::atomic::AtomicUsize,
|
||||
index: usize,
|
||||
original: Option<&'c Buf>,
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
if self.r.depth > MAX_FX_DEPTH || self.layer.switches.adjustment {
|
||||
return None;
|
||||
}
|
||||
let n = effects.min(self.index.load(std::sync::atomic::Ordering::Relaxed));
|
||||
let n = effects.min(self.index);
|
||||
let t = self.layer.comp_time(Tick::from_seconds_f64(layer_time));
|
||||
let sub = self.r.nested();
|
||||
sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone())
|
||||
@@ -337,8 +337,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
}
|
||||
|
||||
fn params_at(&self, layer_time: f64) -> Option<Params> {
|
||||
let i = self.index.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let g = self.layer.effects()?.groups().nth(i)?;
|
||||
let g = self.layer.effects()?.groups().nth(self.index)?;
|
||||
let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time)));
|
||||
Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr)))
|
||||
}
|
||||
@@ -456,6 +455,12 @@ pub trait Accelerator: Send + Sync {
|
||||
fn auto_pick(&self) -> Option<&AutoPick> {
|
||||
None
|
||||
}
|
||||
/// Whether the accelerator can still render frames. `false` once it has stopped for good
|
||||
/// (its device was lost or retired): every frame it is asked for falls back to the CPU, so
|
||||
/// callers that limit frames in flight for it can stop doing so.
|
||||
fn available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// Deferred readbacks: where a GPU readback cannot be waited for (WebGPU in a browser
|
||||
/// worker), a frame renders in *passes*. A readback the accelerator does not have yet is
|
||||
/// started, the step returns a cheap placeholder and the pass is marked missed
|
||||
@@ -1158,7 +1163,7 @@ impl<'a> Renderer<'a> {
|
||||
buf
|
||||
});
|
||||
}
|
||||
let host = FxHost { r: self, ctx, layer, origin, index: Default::default(), original: original.as_ref() };
|
||||
let host = FxHost { r: self, ctx, layer, origin, index: 0, original: original.as_ref() };
|
||||
let env = EffectEnv {
|
||||
masks: &mask_shapes,
|
||||
host: Some(&host),
|
||||
@@ -1192,12 +1197,21 @@ impl<'a> Renderer<'a> {
|
||||
{
|
||||
let run: Vec<_> = stack[k..].iter().take_while(|(_, _, s)| s.gpu && a.supports_effect(s.id)).collect();
|
||||
let params: Vec<Params> = run.iter().map(|(_, g, _)| self.effect_params(ctx, layer, g)).collect();
|
||||
let hosts: Vec<_> = run.iter().map(|(i, _, _)| FxHost { index: *i, ..host }).collect();
|
||||
let steps: Vec<FxStep> = run
|
||||
.iter()
|
||||
.zip(¶ms)
|
||||
.map(|((i, g, s), params)| FxStep {
|
||||
.zip(&hosts)
|
||||
.map(|(((i, g, s), params), host)| FxStep {
|
||||
spec: s,
|
||||
ctx: EffectCtx { params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env: EffectEnv { effect_index: *i, ..env } },
|
||||
ctx: EffectCtx {
|
||||
params,
|
||||
time: lt.seconds(),
|
||||
layer_size,
|
||||
seed: g.uid as u32,
|
||||
adjustment,
|
||||
env: EffectEnv { host: Some(host), effect_index: *i, ..env },
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let t0 = web_time::Instant::now();
|
||||
@@ -1212,8 +1226,8 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
k += 1;
|
||||
let params = self.effect_params(ctx, layer, g);
|
||||
host.index.store(i, std::sync::atomic::Ordering::Relaxed);
|
||||
let env = EffectEnv { effect_index: i, ..env };
|
||||
let host = FxHost { index: i, ..host };
|
||||
let env = EffectEnv { host: Some(&host), effect_index: i, ..env };
|
||||
let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env };
|
||||
let t0 = web_time::Instant::now();
|
||||
target.cpu(&mut |buf| {
|
||||
@@ -2086,7 +2100,7 @@ impl<'a> Renderer<'a> {
|
||||
/// applied).
|
||||
pub fn visible_layers(&self, ctx: &EvalCtx<'a>) -> Vec<&'a Layer> {
|
||||
let (comp, t) = (ctx.comp, ctx.time);
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
|
||||
comp.layers
|
||||
.iter()
|
||||
.rev()
|
||||
|
||||
@@ -542,10 +542,25 @@ fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option<kurbo::Rect> {
|
||||
Paint::Gradient { stroke: Some(style), .. } => Some(style),
|
||||
_ => None,
|
||||
};
|
||||
let grow = stroke.map_or(1.0, |style| style.width * style.miter.max(1.0) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0);
|
||||
let grow = stroke.map_or(1.0, |style| style.width * stroke_reach(style, paths) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0);
|
||||
Some(b.inflate(grow, grow))
|
||||
}
|
||||
|
||||
/// How far a stroke can reach past its path, in half widths: the miter limit only where a
|
||||
/// miter join exists (a path of two or more segments, or a closed one), √2 at square cap
|
||||
/// corners, else one. Padding every stroke by the miter limit made a 1080 px straight line
|
||||
/// (a wipe matte) a 4326×6366 buffer, ~440 MB per layer per frame.
|
||||
fn stroke_reach(style: &StrokeStyle, paths: &[BezPath]) -> f64 {
|
||||
if style.is_variable() {
|
||||
// Taper and Wave can widen the stroke: keep the widest padding.
|
||||
return style.miter.max(std::f64::consts::SQRT_2);
|
||||
}
|
||||
let joins =
|
||||
style.join == Join::Miter && paths.iter().any(|p| p.segments().nth(1).is_some() || p.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)));
|
||||
let cap = if style.cap == Cap::Square { std::f64::consts::SQRT_2 } else { 1.0 };
|
||||
if joins { style.miter.max(cap) } else { cap }
|
||||
}
|
||||
|
||||
/// Local-space visual bounds of a draw, as After Effects' layer bounds (`sourceRectAtTime` with
|
||||
/// extents): the path bounds, plus half the stroke width for strokes. No anti-aliasing or miter
|
||||
/// padding, so snapping and Align put edges exactly together (#352).
|
||||
|
||||
@@ -424,3 +424,40 @@ fn radial_gradient_highlight_moves_the_focal_point() {
|
||||
// The outer circle is unchanged: points just outside it are black in both.
|
||||
assert!(a.get(100, 182)[0] < 0.03 && b.get(100, 182)[0] < 0.03 && b.get(100, 17)[0] < 0.03);
|
||||
}
|
||||
|
||||
/// The layer-space buffer `super::render` draws the comp's only shape layer into.
|
||||
fn shape_buf(p: &Project, cid: ItemId) -> effectcraft_effects::Buf {
|
||||
let comp = p.comp(cid).unwrap();
|
||||
let ctx = crate::eval::EvalCtx::new(p, cid, comp, Tick::ZERO);
|
||||
let layer = &comp.layers[0];
|
||||
super::render(&ctx, layer, layer.props.sub("contents").unwrap(), 1.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_stroke_buffer_is_not_padded_by_the_miter_limit() {
|
||||
// A 1080 px wide vertical wipe (a track matte): no joins, so the buffer is the line plus half
|
||||
// the width, not 4× (Miter Limit 4) the width on every side (4326×6366, ~440 MB per frame).
|
||||
let (p, cid) = shape_comp_sized(1080, 1920, |ids| vec![line_path(ids, [0.0, 1020.0], [0.0, -1020.0]), build::shape_stroke(ids, RED, 1080.0)]);
|
||||
let b = shape_buf(&p, cid);
|
||||
assert!(b.img.width <= 1090 && b.img.height <= 3130, "{}×{}", b.img.width, b.img.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miter_corner_still_fits_its_buffer() {
|
||||
// A sharp V with a 20 px miter-joined stroke: the miter tip reaches well past half the width.
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let sp = effectcraft_keyframe::ShapePath {
|
||||
vertices: vec![[-40.0, -30.0], [0.0, 20.0], [40.0, -30.0]],
|
||||
in_tangents: vec![[0.0; 2]; 3],
|
||||
out_tangents: vec![[0.0; 2]; 3],
|
||||
closed: false,
|
||||
feather: Vec::new(),
|
||||
};
|
||||
vec![build::shape_path(ids, sp), build::shape_stroke(ids, RED, 20.0)]
|
||||
});
|
||||
// The miter tip of the V points down from (100, 70): its pixels are drawn, not clipped.
|
||||
let img = frame(&p, cid);
|
||||
assert!(alpha(&img, 100, 80) > 0.9, "{}", alpha(&img, 100, 80));
|
||||
let b = shape_buf(&p, cid);
|
||||
assert!(b.img.height as f64 >= 50.0 + 20.0 * 4.0 * 0.5, "{}", b.img.height);
|
||||
}
|
||||
|
||||
@@ -953,3 +953,60 @@ fn shape_content_bounds_are_the_visual_edges() {
|
||||
assert!(b.iter().zip([-half, -half, half, half]).all(|(a, e)| (a - e).abs() < 1e-6), "{stroke:?}: {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Solo is not time-dependent (as in After Effects): while any layer is soloed, the others stay
|
||||
/// excluded even when the playhead is outside the soloed layer's in-out range.
|
||||
#[test]
|
||||
fn solo_excludes_other_layers_outside_the_soloed_layers_range() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let a = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
|
||||
let mut b = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
|
||||
b.in_point = Tick::from_seconds_f64(1.0);
|
||||
b.switches.solo = true;
|
||||
let c = p.comp_mut(cid).unwrap();
|
||||
c.layers.push(b);
|
||||
c.layers.push(a);
|
||||
let first = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
|
||||
assert_eq!(first.get(100, 50), [0.0, 0.0, 0.0, 0.0], "neither A nor B before B's in point");
|
||||
let second = render_frame(&p, cid, Tick::from_seconds_f64(1.5), 1.0);
|
||||
assert_eq!(second.get(100, 50), [0.0, 0.0, 1.0, 1.0], "only B inside its range");
|
||||
}
|
||||
|
||||
/// #1252: a layer blended with a CC Particle World layer, under a camera panned by `pan` px.
|
||||
fn scene_reader_dependency(pan: f64) -> (Project, ItemId) {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut source = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
|
||||
let vals = [("birthRate", Value::Scalar(2.0)), ("particle/particleType", Value::Enum(2)), ("particle/birthSize", Value::Scalar(0.1))];
|
||||
add_effect(&mut p, &mut source, "ec.sim.ccparticleworld", [200.0, 100.0], &vals);
|
||||
source.switches.video = false;
|
||||
let mut owner = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
|
||||
add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(source.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]);
|
||||
let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None);
|
||||
for path in ["transform/position", "transform/poi"] {
|
||||
let pr = cam.props.prop_mut(path).unwrap();
|
||||
let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) };
|
||||
pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]);
|
||||
}
|
||||
p.comp_mut(cid).unwrap().layers = vec![cam, owner, source];
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// #1252: the owner's cache key covers the comp camera and lights that a layer it selects reads,
|
||||
/// though the owner itself runs no scene-reading effect.
|
||||
#[test]
|
||||
fn a_selected_layer_reading_the_comp_scene_invalidates_its_owner() {
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let (p, cid) = scene_reader_dependency(0.0);
|
||||
let (q, _) = scene_reader_dependency(40.0);
|
||||
let key = |p: &Project| {
|
||||
let c = p.comp(cid).unwrap();
|
||||
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap()
|
||||
};
|
||||
assert_ne!(key(&p), key(&q), "moving the camera changes the owner's key");
|
||||
let cache = crate::LayerCache::default();
|
||||
let here = render_cached(&p, cid, t, Some(&cache));
|
||||
let panned = render_cached(&q, cid, t, Some(&cache));
|
||||
assert_same(&panned, &render_cached(&q, cid, t, None), "cached render after the camera moved");
|
||||
let (a, b) = (particles_x(&here), particles_x(&panned));
|
||||
assert!(b < a - 20.0, "the blended particles follow the camera: {a} -> {b}");
|
||||
}
|
||||
|
||||
@@ -873,7 +873,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item:
|
||||
let saved = b.outer;
|
||||
b.outer = saved * ctx.world_matrix(layer);
|
||||
let t = nctx.time;
|
||||
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
|
||||
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
|
||||
for l in nctx.comp.layers.iter().rev() {
|
||||
if !l.is_active_at(t)
|
||||
|| l.is_light()
|
||||
|
||||
@@ -497,7 +497,7 @@ fn collapsed_items<'a>(
|
||||
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
|
||||
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] };
|
||||
let t = nctx.time;
|
||||
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
|
||||
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
|
||||
let nested: Vec<&'a Layer> = nctx
|
||||
.comp
|
||||
.layers
|
||||
|
||||
@@ -106,5 +106,9 @@ mod tests_ui_more;
|
||||
// #760: `$.evalFile` and `BridgeTalk`.
|
||||
#[cfg(test)]
|
||||
mod tests_evalfile;
|
||||
// `/browser/…` is the web editor's file table, an absolute path on wasm; on Windows it has no
|
||||
// drive, so the script runtime resolves it under the current drive, where the host holds nothing.
|
||||
#[cfg(all(test, not(windows)))]
|
||||
mod tests_services;
|
||||
#[cfg(test)]
|
||||
mod tests_ui_mouse;
|
||||
|
||||
@@ -1978,6 +1978,7 @@ var app = {
|
||||
savePrefAsBool: function () {},
|
||||
savePrefAsFloat: function () {},
|
||||
reload: function () {},
|
||||
saveToDisk: function () {},
|
||||
},
|
||||
newProject: function () { __call("file.newProject", {}); return new Project(); },
|
||||
open: function (file) {
|
||||
|
||||
@@ -157,11 +157,16 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
|
||||
s.state.selected_layers.clear();
|
||||
}
|
||||
let r = s.execute_for_script(id, params);
|
||||
if let Some(c) = target
|
||||
&& prev != Some(c)
|
||||
&& s.state.active_comp == Some(c)
|
||||
&& id != "comp.open"
|
||||
{
|
||||
if let Some(c) = target {
|
||||
leave_comp(s, id, c, prev);
|
||||
}
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// After a script's command on comp `c`, make `prev` (the comp active before it) active again,
|
||||
/// unless the command opened `c` on purpose; with no comp to go back to, `c` stays open.
|
||||
fn leave_comp(s: &mut Session, id: &str, c: effectcraft_engine::project::ItemId, prev: Option<effectcraft_engine::project::ItemId>) {
|
||||
if prev != Some(c) && s.state.active_comp == Some(c) && id != "comp.open" {
|
||||
match prev {
|
||||
Some(p) if s.project.comp(p).is_some() => s.activate_comp(p),
|
||||
_ => {
|
||||
@@ -171,7 +176,6 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Run a keyframe command that acts on the selected keys (`keys.velocity`, `keys.interpolation`,
|
||||
@@ -195,11 +199,17 @@ fn exec_on_keys(s: &mut Session, id: &str, params: J, targets: &J) -> Result<J,
|
||||
let key = l.props.find(prop).and_then(|pr| pr.keys.get(usize::try_from(index).ok()?)).ok_or("the keyframe no longer exists")?;
|
||||
keys.push(KeyRef { layer: LayerId(layer), prop, time: key.time });
|
||||
}
|
||||
// Make the keys' comp active before selecting them: activating a comp brings back the key
|
||||
// selection it had, which would replace these keys and leave the command nothing to act on
|
||||
// when the comp isn't the active one (#1531).
|
||||
let prev = s.state.active_comp;
|
||||
s.activate_comp(cid);
|
||||
let saved = std::mem::replace(&mut s.state.selected_keys, keys);
|
||||
let r = exec(s, id, params);
|
||||
let comp = s.active_comp();
|
||||
let exists = |k: &KeyRef| comp.and_then(|c| c.layer(k.layer)).and_then(|l| l.props.find(k.prop)).is_some_and(|pr| pr.keys.iter().any(|x| x.time == k.time));
|
||||
s.state.selected_keys = saved.into_iter().filter(exists).collect();
|
||||
leave_comp(s, id, cid, prev);
|
||||
r
|
||||
}
|
||||
|
||||
@@ -336,9 +346,19 @@ pub(crate) fn normalize(p: &str) -> std::path::PathBuf {
|
||||
// Resolve symlinks where the file (or its folder) exists, so links can't escape the project.
|
||||
std::fs::canonicalize(&out)
|
||||
.or_else(|_| out.parent().map(std::fs::canonicalize).unwrap_or(Ok(out.clone())).map(|d| d.join(out.file_name().unwrap_or_default())))
|
||||
.map(without_verbatim_prefix)
|
||||
.unwrap_or(out)
|
||||
}
|
||||
|
||||
/// A canonical Windows drive path without its `\\?\` prefix, so it compares with a lexical one
|
||||
/// (a path whose folder doesn't exist on disk stays lexical).
|
||||
fn without_verbatim_prefix(p: std::path::PathBuf) -> std::path::PathBuf {
|
||||
match p.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
|
||||
Some(plain) if plain.as_bytes().get(1) == Some(&b':') => plain.into(),
|
||||
_ => p,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network";
|
||||
|
||||
/// The file-access gate: reads under the project's folder are always allowed; other reads, all
|
||||
@@ -380,11 +400,14 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
||||
}
|
||||
let s = &a.session;
|
||||
match op.as_str() {
|
||||
"access" => Ok(json!(check_access(s, &path, false)?.exists())),
|
||||
// Through the session's services, like the reads: hosts without a file system (the
|
||||
// browser's file table) answer for their own files.
|
||||
"access" => Ok(json!(s.services.exists(&check_access(s, &path, false)?.to_string_lossy()))),
|
||||
"exists" | "isDir" => {
|
||||
// Unreadable locations report "missing" instead of failing.
|
||||
let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) };
|
||||
Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() }))
|
||||
let p = p.to_string_lossy();
|
||||
Ok(json!(if op == "exists" { s.services.exists(&p) } else { s.services.is_dir(&p) }))
|
||||
}
|
||||
"read" | "readBinary" => {
|
||||
let p = check_access(s, &path, false)?;
|
||||
@@ -563,27 +586,32 @@ fn position(msg: &str, file: &str, code: &str) -> (Option<u32>, Option<u32>) {
|
||||
let mut it = l[i..].split([':', ')']);
|
||||
Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
|
||||
};
|
||||
// The innermost frame in the user's script, or the error's own position.
|
||||
let frames: Vec<&str> = msg.lines().skip(1).collect();
|
||||
let frame = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick));
|
||||
// `ReferenceError: name is not defined`: boa places it at the start of the function that reads
|
||||
// the name, so the first use of the name in the code is the better position (#1063).
|
||||
// the name, so the name's first use from that frame's line on is the better position (#1063).
|
||||
// With no frame to start from, the first use anywhere in the code is (#1161).
|
||||
let first = msg.lines().next().unwrap_or("");
|
||||
let undefined = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next());
|
||||
if let Some(at) = undefined.and_then(|name| first_use(name, code)) {
|
||||
let start_line = frame.map_or(1, |(line, _)| line);
|
||||
if let Some(at) = undefined.and_then(|name| first_use(name, code, start_line)) {
|
||||
return (Some(at.0), Some(at.1));
|
||||
}
|
||||
let frames: Vec<&str> = msg.lines().skip(1).collect();
|
||||
if let Some((a, b)) = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)) {
|
||||
if let Some((a, b)) = frame {
|
||||
return (Some(a), Some(b));
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
||||
/// The first use of `name` as a whole identifier in `code`, as a 1-based line and column.
|
||||
fn first_use(name: &str, code: &str) -> Option<(u32, u32)> {
|
||||
/// The first use of `name` as a whole identifier in `code` on or after `start_line` (1-based), as a
|
||||
/// 1-based line and column.
|
||||
fn first_use(name: &str, code: &str, start_line: u32) -> Option<(u32, u32)> {
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$';
|
||||
for (n, line) in code.lines().enumerate() {
|
||||
for (n, line) in code.lines().enumerate().skip(start_line.saturating_sub(1) as usize) {
|
||||
let b = line.as_bytes();
|
||||
let mut from = 0;
|
||||
while let Some(i) = line.get(from..).and_then(|rest| rest.find(name)).map(|i| i + from) {
|
||||
@@ -829,4 +857,14 @@ mod nesting_tests {
|
||||
assert_eq!(out.result, serde_json::json!(2));
|
||||
assert!(check_nesting(&("(".repeat(MAX_NESTING) + &")".repeat(MAX_NESTING))).is_ok());
|
||||
}
|
||||
|
||||
/// A canonical drive path and a lexical one compare once the `\\?\` prefix is gone; a UNC
|
||||
/// share and a Unix path are left as they are.
|
||||
#[test]
|
||||
fn only_a_verbatim_drive_prefix_is_dropped() {
|
||||
use std::path::PathBuf;
|
||||
assert_eq!(without_verbatim_prefix(r"\\?\C:\proj\a.txt".into()), PathBuf::from(r"C:\proj\a.txt"));
|
||||
assert_eq!(without_verbatim_prefix(r"\\?\UNC\server\share\a.txt".into()), PathBuf::from(r"\\?\UNC\server\share\a.txt"));
|
||||
assert_eq!(without_verbatim_prefix("/proj/a.txt".into()), PathBuf::from("/proj/a.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +187,18 @@ fn errors_inside_a_function_wrapper_report_the_failing_line() {
|
||||
assert_eq!(e.line, Some(3), "{e:?}");
|
||||
}
|
||||
|
||||
/// The first use of an undefined name is the one inside the function that reads it: a mention
|
||||
/// before it, in a comment, a string or another function, doesn't move the report (#1161).
|
||||
#[test]
|
||||
fn an_undefined_name_mentioned_earlier_reports_its_use_in_the_reading_function() {
|
||||
let mut s = session();
|
||||
let code = "// nope is mentioned here\nvar text = 'nope';\nfunction other() { return nope; }\n(function () {\n var a = 1;\n nope.call();\n})();\n";
|
||||
let o = run_code(&mut s, code, "earlier.jsx");
|
||||
let e = o.error.expect("reference error");
|
||||
assert!(e.message.contains("nope"), "{e:?}");
|
||||
assert_eq!(e.line, Some(6), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_names_and_lookups() {
|
||||
let mut s = session();
|
||||
@@ -430,6 +442,32 @@ fn save_frame_to_png_writes_behind_the_file_gate() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #1246: with Warn User When Executing Files on, a script that asserts `confirmed` itself still
|
||||
/// gets the question; only the user's answer opens the file.
|
||||
#[test]
|
||||
fn a_script_cannot_confirm_executing_a_file_for_itself() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-script-exec-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("run.txt");
|
||||
std::fs::write(&f, "x").unwrap();
|
||||
let q = serde_json::to_string(&f.to_string_lossy()).unwrap();
|
||||
let mut s = session();
|
||||
// The user allows file access; the warning stays on (the default).
|
||||
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
|
||||
s.drain_events();
|
||||
let opened = |s: &mut Session| s.drain_events().iter().any(|e| matches!(e, effectcraft_engine::Event::OpenUrl(_)));
|
||||
for code in [
|
||||
format!("JSON.stringify(app.run('file.executeFile', {{path: {q}, confirmed: true}}))"),
|
||||
format!("JSON.stringify(app.run('engine.batch', {{steps: [{{command: 'file.executeFile', params: {{path: {q}, confirmed: true}}}}]}}))"),
|
||||
format!("new File({q}).execute()"),
|
||||
] {
|
||||
let o = ok(&mut s, &code);
|
||||
assert!(!o.result.to_string().contains("opened") || o.result == json!(false), "{code}: {:?}", o.result);
|
||||
assert!(!opened(&mut s), "{code} opened the file without asking");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #590: a script can't give itself file and network access (or turn off the warning before
|
||||
/// executing files) by changing Settings ▸ Scripting & Expressions; the user still can.
|
||||
#[test]
|
||||
@@ -1258,6 +1296,36 @@ fn effect_parameter_interpolation_and_ease() {
|
||||
assert_eq!(o.result, json!([true, true, 16.67, 33.33, true]));
|
||||
}
|
||||
|
||||
/// #1531: the per-key methods act on a comp that isn't the active one too. Activating the keys'
|
||||
/// comp brought back its own (empty) key selection over the keys named, so the interpolation and
|
||||
/// the ease were silently dropped once another comp with a layer had been made first.
|
||||
#[test]
|
||||
fn key_interpolation_and_ease_apply_outside_the_active_comp() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var a = app.project.items.addComp("A", 400, 400, 1, 10, 20); a.layers.addNull();
|
||||
var c = app.project.items.addComp("C", 400, 400, 1, 10, 20);
|
||||
var n = c.layers.addNull(); n.threeDLayer = true;
|
||||
var p = n.property("ADBE Transform Group").property("ADBE Rotate Y");
|
||||
p.setValueAtTime(1, 0); p.setValueAtTime(2, -178);
|
||||
for (var j = 1; j <= 2; j++) {
|
||||
p.setInterpolationTypeAtKey(j, KeyframeInterpolationType.BEZIER, KeyframeInterpolationType.BEZIER);
|
||||
p.setTemporalEaseAtKey(j, [new KeyframeEase(0, 60)], [new KeyframeEase(0, 60)]);
|
||||
}
|
||||
[p.keyOutInterpolationType(1) == KeyframeInterpolationType.BEZIER,
|
||||
p.keyInInterpolationType(2) == KeyframeInterpolationType.BEZIER,
|
||||
p.keyOutTemporalEase(1)[0].influence,
|
||||
Math.round(p.valueAtTime(1.25, false) * 100) / 100]
|
||||
"#,
|
||||
);
|
||||
let v = o.result;
|
||||
assert_eq!((&v[0], &v[1], &v[2]), (&json!(true), &json!(true), &json!(60)), "{v}");
|
||||
// Eased, not the linear -44.5.
|
||||
assert!(v[3].as_f64().is_some_and(|x| (x - -44.5).abs() > 1.0), "{v}");
|
||||
}
|
||||
|
||||
/// #808: sourcePointToComp and compPointToSource follow the layer's transform.
|
||||
#[test]
|
||||
fn source_point_conversions_follow_the_layer_transform() {
|
||||
@@ -1375,3 +1443,42 @@ fn scripted_levels_alpha_controls_resolve_from_the_effect() {
|
||||
);
|
||||
assert_eq!(o.result, json!([0.5, 0.5]));
|
||||
}
|
||||
|
||||
/// After Effects resolves the Transform properties and Source Text from the layer itself, without
|
||||
/// naming their group: `layer.property("Position")` (#1348).
|
||||
#[test]
|
||||
fn layer_property_resolves_transform_and_source_text_shortcuts() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("x", 100, 100, 1, 5, 30);
|
||||
var t = c.layers.addText("hi");
|
||||
var s = c.layers.addSolid([1, 0, 0], "s", 100, 100, 1);
|
||||
var listed = [t.property("Source Text"), s.property("Position"), s.property("Opacity"), s.property("Scale")].join(", ");
|
||||
s.property("Position").setValue([30, 40]);
|
||||
s.property("Opacity").setValue(25);
|
||||
t.property("Source Text").setValue("bye");
|
||||
s.threeDLayer = true;
|
||||
[listed, s.property("Position").value[0], s.property("Opacity").value, t.property("Source Text").value.text,
|
||||
s.property("ADBE Position").matchName, s("Anchor Point").matchName, s.property("Rotation").matchName,
|
||||
s.property("X Rotation").matchName, s.property("Orientation").matchName]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
o.result,
|
||||
json!([
|
||||
"[object Property], [object Property], [object Property], [object Property]",
|
||||
30,
|
||||
25,
|
||||
"bye",
|
||||
"ADBE Position",
|
||||
"ADBE Anchor Point",
|
||||
"ADBE Rotate Z",
|
||||
"ADBE Rotate X",
|
||||
"ADBE Orientation"
|
||||
])
|
||||
);
|
||||
let (_, c) = comp_named(&s, "x");
|
||||
assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0));
|
||||
}
|
||||
|
||||
@@ -94,3 +94,10 @@ fn calling_without_a_name_is_a_script_error() {
|
||||
let o = ok(&mut s, "var c = app.project.items.addComp('N', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l.name = 'Ctl'; l");
|
||||
assert_eq!(o.result, json!({ "type": "[object AVLayer]", "name": "Ctl" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_preferences_save_to_disk_is_callable() {
|
||||
let mut s = session();
|
||||
let o = ok(&mut s, "app.preferences.saveToDisk(); app.preferences.reload(); [typeof app.preferences.saveToDisk, app.preferences.getPrefAsLong('a', 'b')]");
|
||||
assert_eq!(o.result, json!(["function", 0]));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Scripts see the host's files: existence checks go through `Services`, like reads, so a host
|
||||
//! that keeps files in memory (the web editor's browser storage) answers for its own files.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_engine::{Services, Session};
|
||||
|
||||
use crate::run_code;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Mem(Mutex<HashMap<String, Vec<u8>>>);
|
||||
|
||||
impl Services for Mem {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
self.0.lock().unwrap().get(path).cloned().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.0.lock().unwrap().insert(path.into(), data.into());
|
||||
Ok(())
|
||||
}
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
self.0.lock().unwrap().contains_key(path)
|
||||
}
|
||||
fn is_dir(&self, path: &str) -> bool {
|
||||
path == "/browser/footage"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_opens_a_file_only_the_host_holds() {
|
||||
let mem = Arc::new(Mem::default());
|
||||
mem.write_file("/browser/data.txt", b"hello").unwrap();
|
||||
let mut s = Session { services: mem, ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
// A project in a folder that isn't on disk: scripts may read files next to it.
|
||||
s.path = Some("/browser/project.ecproj".into());
|
||||
let out = run_code(
|
||||
&mut s,
|
||||
r#"var f = new File("/browser/data.txt"); var ok = f.open("r");
|
||||
writeLn(ok + " " + (ok ? f.read() : f.error));
|
||||
writeLn(new File("/browser/missing.txt").exists + " " + new Folder("/browser/footage").exists);"#,
|
||||
"t.jsx",
|
||||
);
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
assert_eq!(out.output, vec!["true hello".to_string(), "false true".to_string()]);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use kurbo::{Affine, BezPath, Rect, Shape as _};
|
||||
|
||||
use crate::{Doc, Group, Node};
|
||||
|
||||
impl Doc {
|
||||
/// Geometric artwork bounds in document pixels, including strokes and clipping.
|
||||
/// Opacity and soft masks do not change the artwork's dimensions.
|
||||
pub fn artwork_bounds(&self) -> Option<Rect> {
|
||||
let page = Rect::new(0.0, 0.0, self.width, self.height);
|
||||
group_bounds(&self.root, Affine::IDENTITY, 0, page)
|
||||
}
|
||||
}
|
||||
|
||||
fn path_bounds(path: BezPath) -> Option<Rect> {
|
||||
if path.elements().is_empty() || !path.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let r = path.bounding_box();
|
||||
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
|
||||
}
|
||||
|
||||
fn group_bounds(group: &Group, transform: Affine, depth: usize, page: Rect) -> Option<Rect> {
|
||||
if depth > 256 {
|
||||
return Some(page);
|
||||
}
|
||||
let transform = transform * group.transform;
|
||||
if !transform.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut bounds: Option<Rect> = None;
|
||||
for node in &group.children {
|
||||
let rect = match node {
|
||||
Node::Group(g) => group_bounds(g, transform, depth + 1, page),
|
||||
Node::Image(i) => {
|
||||
let rect = Rect::new(0.0, 0.0, f64::from(i.width), f64::from(i.height));
|
||||
path_bounds(transform * i.transform * rect.to_path(0.01))
|
||||
}
|
||||
Node::Shape(s) => {
|
||||
let m = transform * s.transform;
|
||||
let path = s.geom.to_path();
|
||||
let mut rect = s.fill.as_ref().and_then(|_| path_bounds(m * &path));
|
||||
if let Some(stroke) = &s.stroke {
|
||||
let scale = m.determinant().abs().sqrt();
|
||||
if scale.is_finite()
|
||||
&& path.is_finite()
|
||||
&& let Some(outline) = effectcraft_path::stroke_outline(std::slice::from_ref(&path), &stroke.style(), scale)
|
||||
&& let Some(r) = path_bounds(m * outline)
|
||||
{
|
||||
rect = Some(rect.map_or(r, |b| b.union(r)));
|
||||
}
|
||||
}
|
||||
rect
|
||||
}
|
||||
};
|
||||
if let Some(r) = rect {
|
||||
bounds = Some(bounds.map_or(r, |b| b.union(r)));
|
||||
}
|
||||
}
|
||||
let mut bounds = bounds?;
|
||||
for (clip, _) in &group.clip {
|
||||
bounds = bounds.intersect(path_bounds(transform * clip)?);
|
||||
}
|
||||
(bounds.width() > 0.0 && bounds.height() > 0.0).then_some(bounds)
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod bounds;
|
||||
mod color;
|
||||
mod pathdata;
|
||||
mod raster;
|
||||
@@ -108,6 +109,28 @@ pub struct Stroke {
|
||||
pub dash: Option<(Vec<f64>, f64)>,
|
||||
}
|
||||
|
||||
impl Stroke {
|
||||
fn style(&self) -> effectcraft_path::StrokeStyle {
|
||||
effectcraft_path::StrokeStyle {
|
||||
width: self.width,
|
||||
cap: match self.cap {
|
||||
Cap::Butt => effectcraft_path::Cap::Butt,
|
||||
Cap::Round => effectcraft_path::Cap::Round,
|
||||
Cap::Square => effectcraft_path::Cap::Square,
|
||||
},
|
||||
join: match self.join {
|
||||
Join::Miter => effectcraft_path::Join::Miter,
|
||||
Join::Round => effectcraft_path::Join::Round,
|
||||
Join::Bevel => effectcraft_path::Join::Bevel,
|
||||
},
|
||||
miter: self.miter,
|
||||
dash: self.dash.clone(),
|
||||
taper: None,
|
||||
wave: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape geometry in its user space (basic shapes are kept so they can become parametric shape
|
||||
/// layer items).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -405,23 +405,7 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) {
|
||||
paint(dst, &cov, fill, (opacity * s.fill_opacity) as f32, m, bbox);
|
||||
}
|
||||
if let Some(st) = &s.stroke {
|
||||
let style = effectcraft_path::StrokeStyle {
|
||||
width: st.width,
|
||||
cap: match st.cap {
|
||||
crate::Cap::Butt => effectcraft_path::Cap::Butt,
|
||||
crate::Cap::Round => effectcraft_path::Cap::Round,
|
||||
crate::Cap::Square => effectcraft_path::Cap::Square,
|
||||
},
|
||||
join: match st.join {
|
||||
crate::Join::Miter => effectcraft_path::Join::Miter,
|
||||
crate::Join::Round => effectcraft_path::Join::Round,
|
||||
crate::Join::Bevel => effectcraft_path::Join::Bevel,
|
||||
},
|
||||
miter: st.miter,
|
||||
dash: st.dash.clone(),
|
||||
taper: None,
|
||||
wave: None,
|
||||
};
|
||||
let style = st.style();
|
||||
let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h);
|
||||
paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,13 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
|
||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
out.close = Some(false);
|
||||
}
|
||||
if pressed_outside(ui.ctx(), &area.response) {
|
||||
// A press in the pass that first draws the picker must not close it: an Area's rect is only
|
||||
// at its requested position from its second pass on (the first pass reports it at the
|
||||
// origin), so the click that just opened the picker would be read as a press outside it and
|
||||
// the picker would flash and close in that one frame (#1476).
|
||||
let pass_id = id.with("open-pass");
|
||||
let drawn_before = ui.data(|d| d.get_temp::<u64>(pass_id)).is_some_and(|p| p < ui.ctx().cumulative_pass_nr());
|
||||
if drawn_before && pressed_outside(ui.ctx(), &area.response) {
|
||||
out.close.get_or_insert(true);
|
||||
}
|
||||
if let Some(keep) = out.close {
|
||||
@@ -56,13 +62,17 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(id.with("open"), false);
|
||||
d.remove::<[f32; 3]>(orig_id);
|
||||
d.remove::<u64>(pass_id);
|
||||
// Closed: the next Escape is the dialog's again.
|
||||
d.remove::<u64>(open_pass_id());
|
||||
});
|
||||
} else {
|
||||
// Still open after this pass: dialogs leave Escape to the picker (see `open_recently`).
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
ui.data_mut(|d| d.insert_temp(open_pass_id(), pass));
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(pass_id, pass);
|
||||
d.insert_temp(open_pass_id(), pass);
|
||||
});
|
||||
}
|
||||
out.changed
|
||||
}
|
||||
|
||||
+70
-10
@@ -111,6 +111,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) ->
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if language == "de" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") {
|
||||
match source {
|
||||
"Reveal in Explorer" => return "Im Explorer anzeigen",
|
||||
"Reveal in File Manager" => return "Im Dateimanager anzeigen",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if language == "cs" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") {
|
||||
match source {
|
||||
"Reveal in Explorer" => return "Zobrazit v Průzkumníku",
|
||||
@@ -2034,9 +2041,9 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("view.zoomOut", "Zoom Out", "ズームアウト"),
|
||||
("", "Resolution", "解像度"),
|
||||
("view.res.full", "Full", "フル画質"),
|
||||
("view.res.half", "Half", "1/2画質"),
|
||||
("view.res.third", "Third", "1/3画質"),
|
||||
("view.res.quarter", "Quarter", "1/4画質"),
|
||||
("view.res.half", "Half", "1/2 画質"),
|
||||
("view.res.third", "Third", "1/3 画質"),
|
||||
("view.res.quarter", "Quarter", "1/4 画質"),
|
||||
("view.res.custom", "Custom...", "カスタム..."),
|
||||
("view.displayColorManagement", "Use Display Color Management", "ディスプレイカラーマネジメントを使用"),
|
||||
("", "Simulate Output", "出力をシミュレート"),
|
||||
@@ -5555,7 +5562,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
|
||||
("layer.settings", "Layer Settings...", "Ebeneneinstellungen..."),
|
||||
("layer.openLayer", "Open Layer", "Ebene öffnen"),
|
||||
("layer.openSource", "Open Layer Source", "Ebenenquelle öffnen"),
|
||||
("layer.revealInFinder", "Reveal in Finder", "Im Finder einblenden"),
|
||||
("layer.revealInFinder", "Reveal in Finder", "Im Finder anzeigen"),
|
||||
("", "Mask", "Maske"),
|
||||
("layer.addMask", "New Mask", "Neue Maske"),
|
||||
("layer.mask.shape", "Mask Shape...", "Maskenform..."),
|
||||
@@ -5565,7 +5572,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
|
||||
("layer.mask.reset", "Reset Mask", "Maske zurücksetzen"),
|
||||
("layer.mask.remove", "Remove Mask", "Maske entfernen"),
|
||||
("layer.mask.removeAll", "Remove All Masks", "Alle Masken entfernen"),
|
||||
("track.mask", "Track Mask", "Maske tracken"),
|
||||
("track.mask", "Track Mask", "Maske verfolgen"),
|
||||
("", "Mode", "Modus"),
|
||||
("layer.mask.mode", "None", "Ohne"),
|
||||
("layer.mask.mode", "Add", "Addieren"),
|
||||
@@ -5602,8 +5609,8 @@ const GERMAN: &[(&str, &str, &str)] = &[
|
||||
("layer.hideOtherVideo", "Hide Other Video", "Anderes Video ausblenden"),
|
||||
("layer.showAllVideo", "Show All Video", "Gesamtes Video einblenden"),
|
||||
("layer.unlockAll", "Unlock All Layers", "Alle Ebenen entsperren"),
|
||||
("layer.expressions", "Enable Expressions", "Expressions aktivieren"),
|
||||
("layer.expressions", "Disable Expressions", "Expressions deaktivieren"),
|
||||
("layer.expressions", "Enable Expressions", "Ausdrücke aktivieren"),
|
||||
("layer.expressions", "Disable Expressions", "Ausdrücke deaktivieren"),
|
||||
("layer.setSwitch", "Shy", "Schüchtern"),
|
||||
("layer.setSwitch", "Lock", "Sperren"),
|
||||
("layer.setSwitch", "Audio", "Audio"),
|
||||
@@ -5631,7 +5638,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
|
||||
("layer.autoOrient", "Auto-Orient...", "Automatische Ausrichtung..."),
|
||||
("", "Time", "Zeit"),
|
||||
("layer.enableTimeRemap", "Enable Time Remapping", "Zeitverzerrung aktivieren"),
|
||||
("layer.timeReverse", "Time-Reverse Layer", "Ebenenreihenfolge umkehren"),
|
||||
("layer.timeReverse", "Time-Reverse Layer", "Ebene zeitlich umkehren"),
|
||||
("layer.timeStretch", "Time Stretch...", "Zeitdehnung..."),
|
||||
("layer.freezeFrame", "Freeze Frame", "Frame fixieren"),
|
||||
("layer.freezeOnLastFrame", "Freeze On Last Frame", "Bei letztem Frame fixieren"),
|
||||
@@ -5700,7 +5707,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
|
||||
("layer.trackMatte", "Matte with Layer Below", "Spurmaske mit Ebene darunter"),
|
||||
("", "Layer Styles", "Ebenenstile"),
|
||||
("layer.style.options", "Layer Style Options...", "Ebenenstiloptionen..."),
|
||||
("layer.style.convertToEditable", "Convert to Editable Styles", "In editierbare Formate umwandeln"),
|
||||
("layer.style.convertToEditable", "Convert to Editable Styles", "In bearbeitbare Stile umwandeln"),
|
||||
("layer.style.showAll", "Show All", "Alle anzeigen"),
|
||||
("layer.style.removeAll", "Remove All", "Alle entfernen"),
|
||||
("layer.style.dropShadow", "Drop Shadow", "Schlagschatten"),
|
||||
@@ -5715,7 +5722,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
|
||||
("path.groupShapes", "Group Shapes", "Formen gruppieren"),
|
||||
("path.ungroupShapes", "Ungroup Shapes", "Gruppierung von Formen aufheben"),
|
||||
("", "Arrange", "Anordnen"),
|
||||
("layer.arrange", "Bring Layer to Front", "Ebene ganz nach vorn"),
|
||||
("layer.arrange", "Bring Layer to Front", "Ebene ganz nach vorne"),
|
||||
("layer.arrange", "Bring Layer Forward", "Ebene nach vorne"),
|
||||
("layer.arrange", "Send Layer Backward", "Ebene nach hinten"),
|
||||
("layer.arrange", "Send Layer to Back", "Ebene ganz nach hinten"),
|
||||
@@ -6679,6 +6686,59 @@ mod tests {
|
||||
assert_eq!(entry(&app, &redo, "Redo Text Čeština".into()), "Znovu Text Čeština");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_reveal_labels_follow_the_platform_file_manager() {
|
||||
// As `czech_…` and `ukrainian_…` do. Asserted through `label`, not `entry`: `entry`
|
||||
// returns the English for German, Russian and French, which have catalogues but are
|
||||
// not wired into the menus (see the undo/redo match in `entry` and `supported`).
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.session.execute("prefs.set", json!({"key": "general.language", "value": "de"})).unwrap();
|
||||
for command in ["file.revealInFinder", "layer.revealInFinder"] {
|
||||
for (source, translated) in [
|
||||
("Reveal in Finder", "Im Finder anzeigen"),
|
||||
("Reveal in Explorer", "Im Explorer anzeigen"),
|
||||
("Reveal in File Manager", "Im Dateimanager anzeigen"),
|
||||
] {
|
||||
assert_eq!(label(&app, command, source), translated, "{command} / {source}");
|
||||
// Another command keeps a label that merely looks like one of these.
|
||||
assert_eq!(label(&app, "file.openRecent", source), source);
|
||||
}
|
||||
assert_eq!(label(&app, command, "My file.mp4"), "My file.mp4", "a user's filename is never looked up");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_menu_labels_name_what_their_command_does() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.session.execute("prefs.set", json!({"key": "general.language", "value": "de"})).unwrap();
|
||||
// "Ebenenreihenfolge umkehren" reads as reversing the stacking order, not time, and
|
||||
// "Formate" are formats, not layer styles (#1443).
|
||||
assert_eq!(label(&app, "layer.timeReverse", "Time-Reverse Layer"), "Ebene zeitlich umkehren");
|
||||
assert_eq!(label(&app, "keys.timeReverse", "Time-Reverse Keyframes"), "Keyframes zeitlich umkehren");
|
||||
assert_eq!(label(&app, "layer.style.convertToEditable", "Convert to Editable Styles"), "In bearbeitbare Stile umwandeln");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_uses_one_word_per_term() {
|
||||
let german: Vec<&str> = GERMAN.iter().map(|(_, _, de)| *de).collect();
|
||||
let with = |needle: &str| german.iter().filter(|de| de.contains(needle)).copied().collect::<Vec<_>>();
|
||||
// "Expression" is "Ausdruck", not the English word.
|
||||
assert!(with("Expression").is_empty(), "{:?}", with("Expression"));
|
||||
// "Track" as a verb is "verfolgen", not "tracken".
|
||||
assert!(with("tracken").is_empty(), "{:?}", with("tracken"));
|
||||
// "Reveal <somewhere>" is "anzeigen". ("einblenden" stays right for "Show All",
|
||||
// "Fade from Blur" and the like, so only the Reveal entries are checked.)
|
||||
let reveal: Vec<&str> =
|
||||
GERMAN.iter().filter(|(_, en, _)| en.starts_with("Reveal")).map(|(_, _, de)| *de).filter(|de| !de.to_lowercase().contains("anzeigen")).collect();
|
||||
assert!(reveal.is_empty(), "{reveal:?}");
|
||||
// One spelling of "to the front".
|
||||
let vorn: Vec<&str> = german.iter().filter(|de| de.ends_with("nach vorn")).copied().collect();
|
||||
assert!(vorn.is_empty(), "{vorn:?}");
|
||||
// The terms those entries joined are still the majority ones.
|
||||
assert!(with("verfolgen").len() >= 4, "{:?}", with("verfolgen"));
|
||||
assert!(with("anzeigen").len() >= 12, "{}", with("anzeigen").len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn czech_reveal_labels_follow_the_platform_file_manager() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
|
||||
@@ -439,13 +439,56 @@ const TRADITIONAL_CHINESE: &[Row] = &[];
|
||||
/// `CC` series, the `VR` set, the `OCIO` set, `Cryptomatte`, `EXtractoR`, `IDentifier`,
|
||||
/// `Key Light`, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects) repeat that
|
||||
/// English name here rather than being left out.
|
||||
const JAPANESE_EFFECTS: &[Row] = &[];
|
||||
///
|
||||
/// The rows below were read from the installed After Effects 2026 (26.5) Japanese interface
|
||||
/// (its generated Effect menu and its Effects & Presets panel).
|
||||
const JAPANESE_EFFECTS: &[Row] = &[
|
||||
("ec.blur.bilateral", "Bilateral Blur", "ブラー(バイラテラル)"),
|
||||
("ec.blur.cameralens", "Camera Lens Blur", "ブラー(カメラレンズ)"),
|
||||
("ec.blur.channel", "Channel Blur", "ブラー(チャンネル)"),
|
||||
("ec.blur.compound", "Compound Blur", "ブラー(合成)"),
|
||||
("ec.blur.directional", "Directional Blur", "ブラー(方向)"),
|
||||
("ec.blur.fastbox", "Fast Box Blur", "高速ボックスブラー"),
|
||||
("ec.blur.gaussian", "Gaussian Blur", "ブラー(ガウス)"),
|
||||
("ec.blur.radial", "Radial Blur", "ブラー(放射状)"),
|
||||
("ec.color.lumetri", "Lumetri Color", "Lumetri カラー"),
|
||||
("ec.key.colorkey", "Color Key", "カラーキー"),
|
||||
("ec.key.luma", "Luma Key", "ルミナンスキー"),
|
||||
("ec.key.spill", "Spill Suppressor", "スピルサプレッション"),
|
||||
("ec.obsolete.basic3d", "Basic 3D", "基本 3D"),
|
||||
("ec.obsolete.basictext", "Basic Text", "基本テキスト"),
|
||||
("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "ブラー(ガウス)(レガシー)"),
|
||||
("ec.obsolete.lightning", "Lightning", "稲妻"),
|
||||
("ec.obsolete.pathtext", "Path Text", "パステキスト"),
|
||||
("ec.time.pixelmotionblur", "Pixel Motion Blur", "ピクセルモーションブラー"),
|
||||
];
|
||||
|
||||
/// Traditional Chinese (`zh-hant`) category labels, keyed by the registry's English category.
|
||||
const TRADITIONAL_CHINESE_CATEGORIES: &[(&str, &str)] = &[];
|
||||
|
||||
/// Japanese (`ja`) category labels, keyed by the registry's English category.
|
||||
const JAPANESE_CATEGORIES: &[(&str, &str)] = &[];
|
||||
const JAPANESE_CATEGORIES: &[(&str, &str)] = &[
|
||||
("3D Channel", "3D チャンネル"),
|
||||
("Audio", "オーディオ"),
|
||||
("Blur & Sharpen", "ブラー&シャープ"),
|
||||
("Channel", "チャンネル"),
|
||||
("Color Correction", "カラー補正"),
|
||||
("Distort", "ディストーション"),
|
||||
("Expression Controls", "式制御"),
|
||||
("Immersive Video", "イマーシブビデオ"),
|
||||
("Keying", "キーイング"),
|
||||
("Matte", "マット"),
|
||||
("Noise & Grain", "ノイズ&グレイン"),
|
||||
("Obsolete", "旧バージョン"),
|
||||
("Paint", "描画"),
|
||||
("Perspective", "遠近"),
|
||||
("Simulation", "シミュレーション"),
|
||||
("Stylize", "スタイライズ"),
|
||||
("Text", "テキスト"),
|
||||
("Time", "時間"),
|
||||
("Transition", "トランジション"),
|
||||
("Utility", "ユーティリティ"),
|
||||
];
|
||||
|
||||
/// The 21 category folder names the generated Effect menu and Effects & Presets draw, in
|
||||
/// Simplified Chinese. Read from the installed After Effects 2026 (26.5.0.89) Simplified Chinese
|
||||
@@ -521,14 +564,17 @@ mod tests {
|
||||
|
||||
/// The languages that have no rows yet, with the table a translator fills in for each. A new
|
||||
/// language is added here and to [`catalog`]; the test then guards its rows as they arrive.
|
||||
const PENDING: &[(&str, &[Row])] = &[("zh-hant", TRADITIONAL_CHINESE), ("ja", JAPANESE_EFFECTS)];
|
||||
const PENDING: &[(&str, &[Row])] = &[("zh-hant", TRADITIONAL_CHINESE)];
|
||||
|
||||
/// The languages whose tables are being filled in: guarded like [`PENDING`] row by row.
|
||||
const PARTIAL: &[(&str, &[Row])] = &[("ja", JAPANESE_EFFECTS)];
|
||||
|
||||
/// A pending language may lag Simplified Chinese but may not invent ids or disagree about
|
||||
/// which effect an id names: a translator fills these tables row by row. While they are
|
||||
/// empty the test is a no-op, which is why the slot ships before the data does.
|
||||
#[test]
|
||||
fn a_pending_language_only_uses_known_ids() {
|
||||
for (language, rows) in PENDING {
|
||||
for (language, rows) in PENDING.iter().chain(PARTIAL) {
|
||||
let mut ids: Vec<&str> = vec![];
|
||||
for (id, english, translated) in *rows {
|
||||
let Some((_, hans_english, _)) = SIMPLIFIED_CHINESE.iter().find(|(hans_id, _, _)| hans_id == id) else {
|
||||
@@ -606,6 +652,9 @@ mod tests {
|
||||
assert_eq!(category("Blur & Sharpen"), "模糊和锐化");
|
||||
assert_eq!(name("ec.unknown.effect", "Mystery"), "Mystery");
|
||||
assert_eq!(category("Not A Category"), "Not A Category");
|
||||
set_current("ja");
|
||||
assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "ブラー(ガウス)");
|
||||
assert_eq!(name("ec.unknown.effect", "Mystery"), "Mystery", "a row a partial table lacks draws the registry name");
|
||||
set_current("en");
|
||||
}
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"),
|
||||
("Effects & Presets options", "エフェクト&プリセットのオプション"),
|
||||
("Copy Presets Folder Path", "プリセットフォルダーのパスをコピー"),
|
||||
("Copy Source Path", "ソースパスをコピー"),
|
||||
("Effects: {}", "エフェクト:{}"),
|
||||
("Empty folder", "空のフォルダー"),
|
||||
("Enable Depth of Field", "被写界深度を有効化"),
|
||||
@@ -290,6 +291,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Layer {}", "レイヤー{}"),
|
||||
("Layer:", "レイヤー:"),
|
||||
("Layer: {}", "レイヤー:{}"),
|
||||
("Layer Styles", "レイヤースタイル"),
|
||||
("Learn", "学習"),
|
||||
("Leave all attributes in \"{}\"", "すべての属性を「{}」に残す"),
|
||||
("Licence: {}", "ライセンス:{}"),
|
||||
@@ -553,7 +555,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("3D Reference Axes", "3D参照軸"),
|
||||
("Affect all layers that use this solid", "この平面を使用するすべてのレイヤーに適用"),
|
||||
("Choose grid and guide options", "グリッドとガイドのオプションを選択"),
|
||||
("Colorize", "カラー化"),
|
||||
("Colorize", "色付き"),
|
||||
("Custom", "カスタム"),
|
||||
("Custom...", "カスタム..."),
|
||||
("Fit", "フィット"),
|
||||
@@ -561,7 +563,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Full", "フル画質"),
|
||||
("Half", "1/2 画質"),
|
||||
("Only this layer uses this solid.", "この平面を使用するのはこのレイヤーだけです。"),
|
||||
("Proportional Grid", "均等グリッド"),
|
||||
("Proportional Grid", "プロポーショナルグリッド"),
|
||||
("Quarter", "1/4 画質"),
|
||||
("Region of Interest", "関心領域"),
|
||||
("Reset Exposure", "露出をリセット"),
|
||||
@@ -587,7 +589,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Import Image Sequence", "画像シーケンスを読み込み"),
|
||||
("Import PDF / Illustrator File", "PDF / Illustratorファイルを読み込み"),
|
||||
("Import Photoshop File", "Photoshopファイルを読み込み"),
|
||||
("Interpret Unlabeled Alpha As", "ラベルなしアルファの解釈"),
|
||||
("Interpret Unlabeled Alpha As", "ラベルのないアルファを変換"),
|
||||
("Join the ArtCraft Discord", "ArtCraftのDiscordに参加"),
|
||||
("Mercury GPU Acceleration ({})", "Mercury GPUアクセラレーション({})"),
|
||||
("Mercury Software Only", "Mercuryソフトウェアのみ"),
|
||||
@@ -595,7 +597,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Off (Final Quality)", "オフ(最終品質)"),
|
||||
("OpenGL (compatibility)", "OpenGL(互換)"),
|
||||
("Opus Bitrate: {} kbps", "Opusビットレート:{} kbps"),
|
||||
("RGB Straight", "RGB(ストレート)"),
|
||||
("RGB Straight", "RGB ストレート"),
|
||||
("Red", "赤"),
|
||||
("Tone map", "トーンマップ"),
|
||||
("Use In-Window Menu Bar on macOS", "macOSでウィンドウ内メニューバーを使用"),
|
||||
@@ -772,6 +774,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"),
|
||||
("Effects & Presets options", "效果和预设选项"),
|
||||
("Copy Presets Folder Path", "复制预设文件夹路径"),
|
||||
("Copy Source Path", "复制源路径"),
|
||||
("Effects: {}", "效果:{}"),
|
||||
("Empty folder", "空文件夹"),
|
||||
("Enable Depth of Field", "启用景深"),
|
||||
@@ -834,6 +837,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Layer {}", "图层 {}"),
|
||||
("Layer:", "图层:"),
|
||||
("Layer: {}", "图层:{}"),
|
||||
("Layer Styles", "图层样式"),
|
||||
("Learn", "学习"),
|
||||
("Leave all attributes in \"{}\"", "将所有属性保留在“{}”中"),
|
||||
("Licence: {}", "许可:{}"),
|
||||
@@ -1504,6 +1508,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"),
|
||||
("Effects & Presets options", "效果和預設集選項"),
|
||||
("Copy Presets Folder Path", "複製預設集資料夾路徑"),
|
||||
("Copy Source Path", "複製來源路徑"),
|
||||
("Effects: {}", "效果:{}"),
|
||||
("Empty folder", "空資料夾"),
|
||||
("Enable Depth of Field", "啟用景深"),
|
||||
@@ -1566,6 +1571,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Layer {}", "圖層 {}"),
|
||||
("Layer:", "圖層:"),
|
||||
("Layer: {}", "圖層:{}"),
|
||||
("Layer Styles", "圖層樣式"),
|
||||
("Learn", "學習"),
|
||||
("Leave all attributes in \"{}\"", "將所有屬性保留在“{}”中"),
|
||||
("Licence: {}", "許可:{}"),
|
||||
@@ -2239,6 +2245,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."),
|
||||
("Effects & Presets options", "Параметри ефектів і наборів"),
|
||||
("Copy Presets Folder Path", "Копіювати шлях до папки наборів"),
|
||||
("Copy Source Path", "Копіювати шлях до джерела"),
|
||||
("Effects: {}", "Ефекти: {}"),
|
||||
("Empty folder", "Порожня тека"),
|
||||
("Enable Depth of Field", "Увімкнути глибину різкості"),
|
||||
@@ -2301,6 +2308,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Layer {}", "Шар {}"),
|
||||
("Layer:", "Шар:"),
|
||||
("Layer: {}", "Шар: {}"),
|
||||
("Layer Styles", "Стилі шару"),
|
||||
("Learn", "Навчання"),
|
||||
("Leave all attributes in \"{}\"", "Залишити всі атрибути в «{}»"),
|
||||
("Licence: {}", "Ліцензія: {}"),
|
||||
@@ -2791,6 +2799,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."),
|
||||
("Effects & Presets options", "Opções de Efeitos e predefinições"),
|
||||
("Copy Presets Folder Path", "Copiar caminho da pasta de predefinições"),
|
||||
("Copy Source Path", "Copiar caminho da origem"),
|
||||
("Effects: {}", "Efeitos: {}"),
|
||||
("Empty folder", "Pasta vazia"),
|
||||
("Enable Depth of Field", "Ativar profundidade de campo"),
|
||||
@@ -2851,6 +2860,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
("Layer {}", "Camada {}"),
|
||||
("Layer:", "Camada:"),
|
||||
("Layer: {}", "Camada: {}"),
|
||||
("Layer Styles", "Estilos de camada"),
|
||||
("Learn", "Aprender"),
|
||||
("Leave all attributes in \"{}\"", "Manter todos os atributos em \"{}\""),
|
||||
("Licence: {}", "Licença: {}"),
|
||||
@@ -3241,6 +3251,7 @@ const CZECH: &[(&str, &str)] = &[
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."),
|
||||
("Effects & Presets options", "Možnosti efektů a přednastavení"),
|
||||
("Copy Presets Folder Path", "Kopírovat cestu ke složce přednastavení"),
|
||||
("Copy Source Path", "Kopírovat cestu ke zdroji"),
|
||||
("Effects: {}", "Efekty: {}"),
|
||||
("Empty folder", "Prázdná složka"),
|
||||
("Enable Depth of Field", "Povolit hloubku ostrosti"),
|
||||
@@ -3302,6 +3313,7 @@ const CZECH: &[(&str, &str)] = &[
|
||||
("Layer {}", "Vrstva {}"),
|
||||
("Layer:", "Vrstva:"),
|
||||
("Layer: {}", "Vrstva: {}"),
|
||||
("Layer Styles", "Styly vrstvy"),
|
||||
("Learn", "Výuka"),
|
||||
("Leave all attributes in \"{}\"", "Ponechat všechny atributy v „{}“"),
|
||||
("Licence: {}", "Licence: {}"),
|
||||
@@ -3653,6 +3665,7 @@ const CZECH: &[(&str, &str)] = &[
|
||||
/// order. Terms follow the Spanish menu catalog (composición, material de archivo, cola de procesamiento,
|
||||
/// ajuste preestablecido, método abreviado). Written from the English source.
|
||||
const SPANISH: &[(&str, &str)] = &[
|
||||
("Copy Source Path", "Copiar ruta de origen"),
|
||||
("Appearance Settings…", "Ajustes de apariencia…"),
|
||||
("Add to favorites", "Añadir a favoritos"),
|
||||
("Remove from favorites", "Quitar de favoritos"),
|
||||
@@ -3858,6 +3871,7 @@ const SPANISH: &[(&str, &str)] = &[
|
||||
("Layer {}", "Capa {}"),
|
||||
("Layer:", "Capa:"),
|
||||
("Layer: {}", "Capa: {}"),
|
||||
("Layer Styles", "Estilos de capa"),
|
||||
("Learn", "Aprender"),
|
||||
("Leave all attributes in \"{}\"", "Dejar todos los atributos en \"{}\""),
|
||||
("Licence: {}", "Licencia: {}"),
|
||||
|
||||
@@ -291,7 +291,7 @@ fn only_comp(p: &Value) -> Option<effectcraft_engine::project::ItemId> {
|
||||
|
||||
/// Execute a UI or engine command by id.
|
||||
pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
if id == "effects.copyPresetsFolderPath" {
|
||||
if matches!(id, "effects.copyPresetsFolderPath" | "mediaBrowser.copySourcePath") {
|
||||
return frontend(app, ctx, id, params);
|
||||
}
|
||||
// Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of
|
||||
@@ -880,6 +880,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
let now = ctx.input(|i| i.time);
|
||||
let v = &mut app.ui.viewer;
|
||||
Ok(match id {
|
||||
"mediaBrowser.copySourcePath" => {
|
||||
let path = p.get("path").and_then(Value::as_str).filter(|path| !path.is_empty()).ok_or("A source path is required")?;
|
||||
ctx.copy_text(path.to_string());
|
||||
json!({"path": path})
|
||||
}
|
||||
"effects.copyPresetsFolderPath" => {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err("The web app has no local presets folder".into());
|
||||
@@ -1521,6 +1526,19 @@ pub(crate) fn panel_shown(app: &EffectcraftApp, p: PanelKind) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The command a menu entry runs when chosen from a menu: Window ▸ <panel> closes a panel whose
|
||||
/// entry is checked, as in After Effects (#1630). Shortcuts and agents calling `window.panel`
|
||||
/// still only show the panel.
|
||||
pub(crate) fn menu_choice(app: &EffectcraftApp, id: String, params: Value) -> (String, Value) {
|
||||
if id == "window.panel"
|
||||
&& let Some(p) = params.get("panel").and_then(Value::as_str).and_then(PanelKind::from_name)
|
||||
&& panel_shown(app, p)
|
||||
{
|
||||
return ("window.closePanel".into(), params);
|
||||
}
|
||||
(id, params)
|
||||
}
|
||||
|
||||
/// Check-mark state of frontend toggles (engine state is answered by the engine).
|
||||
pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option<bool> {
|
||||
let v = &app.ui.viewer;
|
||||
@@ -1990,7 +2008,7 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
crate::menu_keys::handle(&ctx, &nav);
|
||||
if let Some((id, params)) = clicked
|
||||
if let Some((id, params)) = clicked.map(|(id, params)| menu_choice(app, id, params))
|
||||
&& let Err(e) = invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params })
|
||||
{
|
||||
app.ui.status = e;
|
||||
|
||||
@@ -376,6 +376,7 @@ pub fn activate(app: &mut EffectcraftApp, ctx: &egui::Context, menu: &NativeMenu
|
||||
}
|
||||
}
|
||||
let params = if params.is_null() { json!({}) } else { params };
|
||||
let (command, params) = crate::menus::menu_choice(app, command, params);
|
||||
let r = crate::menus::invoke(app, ctx, &command, params);
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
@@ -612,6 +613,23 @@ mod tests {
|
||||
assert!(spec2.items().iter().any(|i| i.command == "file.openRecent" && i.label == "Recent Test.ecproj"));
|
||||
}
|
||||
|
||||
/// #1630: a checked Window menu entry closes its panel; an unchecked one opens it.
|
||||
#[test]
|
||||
fn window_menu_entry_toggles_its_panel() {
|
||||
use crate::dock::PanelKind;
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
let choose = |app: &mut EffectcraftApp| {
|
||||
let spec = build(app);
|
||||
let id = spec.items().into_iter().find(|i| i.command == "window.panel" && i.params["panel"] == "align").unwrap().id.clone();
|
||||
activate(app, &ctx, &spec, &id).unwrap();
|
||||
};
|
||||
choose(&mut app);
|
||||
assert!(app.ui.dock.contains(PanelKind::Align));
|
||||
choose(&mut app);
|
||||
assert!(!app.ui.dock.contains(PanelKind::Align));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_dispatches_the_menu_command() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use crate::i18n::tr;
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::panels::DragPayload;
|
||||
@@ -228,7 +229,7 @@ fn preset_row(
|
||||
name: &str,
|
||||
auto_id: &str,
|
||||
query: &str,
|
||||
on_apply: Apply,
|
||||
on_apply: impl FnOnce() -> Apply,
|
||||
apply: &mut Option<Apply>,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
@@ -245,22 +246,22 @@ fn preset_row(
|
||||
highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t);
|
||||
app.auto.add(auto_id, r, name);
|
||||
if resp.double_clicked() {
|
||||
*apply = Some(on_apply);
|
||||
*apply = Some(on_apply());
|
||||
}
|
||||
}
|
||||
|
||||
/// User preset files, rescanned every few seconds (and on Refresh List).
|
||||
fn user_presets(ctx: &egui::Context, force: bool) -> Vec<PresetFile> {
|
||||
fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec<PresetFile>) -> Arc<Vec<PresetFile>> {
|
||||
let id = egui::Id::new("fx-user-presets");
|
||||
let now = ctx.input(|i| i.time);
|
||||
if !force
|
||||
&& let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, std::sync::Arc<Vec<PresetFile>>)>(id))
|
||||
&& let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(id))
|
||||
&& now - at < 5.0
|
||||
{
|
||||
return (*v).clone();
|
||||
return v;
|
||||
}
|
||||
let v = presets_dir().map(|d| scan_presets(&d)).unwrap_or_default();
|
||||
ctx.data_mut(|d| d.insert_temp(id, (now, std::sync::Arc::new(v.clone()))));
|
||||
let v = Arc::new(scan());
|
||||
ctx.data_mut(|d| d.insert_temp(id, (now, v.clone())));
|
||||
v
|
||||
}
|
||||
|
||||
@@ -288,6 +289,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add("effects.menu", mr, "Effects & Presets options");
|
||||
let view = app.ui.effects_view.clone();
|
||||
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
|
||||
let copy_label = format!(" {}", tr("Copy Presets Folder Path"));
|
||||
let menu = vec![
|
||||
mark(view.depth == "all", "Show Effects for All Color Depths"),
|
||||
mark(view.depth == "32", "Show 32 bpc Effects Only"),
|
||||
@@ -301,7 +303,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
" Refresh List".into(),
|
||||
" Clear Recently Used".into(),
|
||||
"-".into(),
|
||||
format!(" {}", tr("Copy Presets Folder Path")),
|
||||
copy_label.clone(),
|
||||
];
|
||||
let mut force_scan = false;
|
||||
let separators = menu.iter().filter(|label| *label == "-").count();
|
||||
@@ -314,7 +316,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.separator();
|
||||
} else {
|
||||
let resp = ui.selectable_label(false, label);
|
||||
if i == 12 {
|
||||
if label == ©_label {
|
||||
app.auto.add("effects.copyPresetsFolderPath", resp.rect, "Copy Presets Folder Path");
|
||||
}
|
||||
if resp.clicked() {
|
||||
@@ -330,6 +332,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let Some(i) = choice {
|
||||
let v = &mut app.ui.effects_view;
|
||||
match i {
|
||||
i if menu.get(i) == Some(©_label) => {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
0 => v.depth = "all".into(),
|
||||
1 => v.depth = "32".into(),
|
||||
2 => v.depth = "gpu".into(),
|
||||
@@ -338,11 +345,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
7 => v.alphabetical = true,
|
||||
9 => force_scan = true,
|
||||
10 => app.ui.effects_recent.clear(),
|
||||
12 => {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -389,13 +391,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
categories.push(c);
|
||||
}
|
||||
}
|
||||
let files: Vec<PresetFile> =
|
||||
user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
|
||||
let user = user_presets(&ctx, force_scan, || presets_dir().map(|d| scan_presets(&d)).unwrap_or_default());
|
||||
let files: Vec<&PresetFile> = user.iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
|
||||
let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty();
|
||||
if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
|
||||
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) {
|
||||
for (id, name) in text {
|
||||
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply);
|
||||
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, || Apply::TextPreset(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
for cat in &categories {
|
||||
@@ -403,7 +405,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
continue;
|
||||
}
|
||||
for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) {
|
||||
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply);
|
||||
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, || Apply::Builtin(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) {
|
||||
@@ -417,7 +419,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
&f.name,
|
||||
&format!("effects.preset.file.{}", f.name),
|
||||
&query,
|
||||
Apply::PresetFile(f.path.clone()),
|
||||
|| Apply::PresetFile(f.path.clone()),
|
||||
&mut apply,
|
||||
);
|
||||
}
|
||||
@@ -536,6 +538,64 @@ mod tests {
|
||||
let v = scan_presets(&d);
|
||||
assert_eq!(v.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), vec!["bounce", "Zoom In"]);
|
||||
assert!(scan_presets(&d.join("missing")).is_empty());
|
||||
let ctx = egui::Context::default();
|
||||
let set_time = |time| {
|
||||
let mut output = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |_| {});
|
||||
output.textures_delta.clear();
|
||||
};
|
||||
let scans = std::cell::Cell::new(0);
|
||||
let scan = || {
|
||||
scans.set(scans.get() + 1);
|
||||
scan_presets(&d)
|
||||
};
|
||||
set_time(1.0);
|
||||
let first = user_presets(&ctx, false, scan);
|
||||
assert_eq!(first.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["bounce", "Zoom In"]);
|
||||
std::fs::remove_file(d.join("bounce.ECPRESET")).unwrap();
|
||||
std::fs::write(d.join("Another.ecpreset"), "{}").unwrap();
|
||||
set_time(5.999);
|
||||
let hit = user_presets(&ctx, false, scan);
|
||||
assert!(Arc::ptr_eq(&first, &hit));
|
||||
assert_eq!(hit.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["bounce", "Zoom In"]);
|
||||
assert_eq!(scans.get(), 1);
|
||||
set_time(6.0);
|
||||
let expired = user_presets(&ctx, false, scan);
|
||||
assert_eq!(expired.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Another", "Zoom In"]);
|
||||
std::fs::remove_file(d.join("Another.ecpreset")).unwrap();
|
||||
let refreshed = user_presets(&ctx, true, scan);
|
||||
assert_eq!(refreshed.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Zoom In"]);
|
||||
assert_eq!(scans.get(), 3);
|
||||
assert_eq!(first.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["bounce", "Zoom In"]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "warm custom-preset cache benchmark"]
|
||||
fn user_preset_cache_benchmark() {
|
||||
let ctx = egui::Context::default();
|
||||
let files = user_presets(&ctx, false, || {
|
||||
(0..1000).map(|i| PresetFile { name: format!("Preset {i:04}"), path: format!("/presets/Preset {i:04}.ecpreset") }).collect()
|
||||
});
|
||||
assert_eq!(files.first().unwrap().name, "Preset 0000");
|
||||
assert_eq!(files.last().unwrap().name, "Preset 0999");
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
let (_, shared) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(egui::Id::new("fx-user-presets"))).unwrap();
|
||||
std::hint::black_box((*shared).clone());
|
||||
}
|
||||
before.push(start.elapsed());
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(user_presets(&ctx, false, Vec::new));
|
||||
}
|
||||
after.push(start.elapsed());
|
||||
}
|
||||
assert!(Arc::ptr_eq(&files, &user_presets(&ctx, false, Vec::new)));
|
||||
before.sort();
|
||||
after.sort();
|
||||
println!("1,000 custom presets, median per warm cache lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Font Size, Syntax Highlighting, Line Numbers, Auto-complete (Tab accepts), Bracket Matching
|
||||
//! and Word Wrap. The Composition viewer uses the shared diagnostics in `expr_bar`.
|
||||
|
||||
use effectcraft_engine::commands::expr_tools::language_menu;
|
||||
use effectcraft_engine::prefs::Scripting;
|
||||
use egui::text::{LayoutJob, TextFormat};
|
||||
use egui::{Color32, FontId, Rect, Stroke, pos2, vec2};
|
||||
@@ -211,6 +212,37 @@ pub fn completions(text: &str, cursor: usize) -> (String, Vec<&'static str>) {
|
||||
(word, c)
|
||||
}
|
||||
|
||||
/// How `name` is called, from the Expression Language menu's signatures: `None` for a
|
||||
/// property, `Some(true)` for a function that takes arguments, `Some(false)` for one that never
|
||||
/// does.
|
||||
fn call_shape(name: &str) -> Option<bool> {
|
||||
let calls: Vec<&str> =
|
||||
language_menu().into_iter().flat_map(|(_, items)| items).filter_map(|(_, text)| text.strip_prefix(name)?.strip_prefix('(')).collect();
|
||||
(!calls.is_empty()).then(|| calls.iter().any(|rest| !rest.starts_with(')')))
|
||||
}
|
||||
|
||||
/// Accepts the first auto-complete candidate at char index `cursor`: inserts the rest of the
|
||||
/// name, plus `()` for a function (#1393), and returns the new cursor: between the parentheses,
|
||||
/// or after them when the function takes no arguments.
|
||||
pub fn accept_completion(buf: &mut String, cursor: usize) -> Option<usize> {
|
||||
let (word, cands) = completions(buf, cursor);
|
||||
let first = cands.first()?;
|
||||
let at = byte_of(buf, cursor);
|
||||
let rest = &first[word.len()..];
|
||||
let end = cursor + first.chars().count() - word.chars().count();
|
||||
let already_called = buf.get(at..).is_some_and(|after| after.starts_with('('));
|
||||
match call_shape(first).filter(|_| !already_called) {
|
||||
Some(takes_args) => {
|
||||
buf.insert_str(at, &format!("{rest}()"));
|
||||
Some(end + if takes_args { 1 } else { 2 })
|
||||
}
|
||||
None => {
|
||||
buf.insert_str(at, rest);
|
||||
Some(end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour runs for JavaScript: (byte start, byte end, colour).
|
||||
pub fn highlight(text: &str, base: Color32, sx: &Syntax) -> Vec<(usize, usize, Color32)> {
|
||||
let b = text.as_bytes();
|
||||
@@ -320,15 +352,10 @@ pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p:
|
||||
if p.auto_complete
|
||||
&& child.memory(|m| m.has_focus(id))
|
||||
&& let Some(c) = cursor
|
||||
&& !completions(buf, c).1.is_empty()
|
||||
&& child.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab))
|
||||
{
|
||||
let (word, cands) = completions(buf, c);
|
||||
if let Some(first) = cands.first()
|
||||
&& child.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab))
|
||||
{
|
||||
let at = byte_of(buf, c);
|
||||
buf.insert_str(at, &first[word.len()..]);
|
||||
accepted = Some(c + first.chars().count() - word.chars().count());
|
||||
}
|
||||
accepted = accept_completion(buf, c);
|
||||
}
|
||||
let out = egui::TextEdit::multiline(buf)
|
||||
.id(id)
|
||||
@@ -446,3 +473,37 @@ mod contrast_tests {
|
||||
assert_eq!(col("// note"), Some(sx.comment));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod completion_tests {
|
||||
use super::*;
|
||||
|
||||
/// #1393: accepting a function inserts the call, with the cursor between the parentheses
|
||||
/// (after them when the function takes no arguments); a property stays a bare name.
|
||||
#[test]
|
||||
fn accepting_a_function_completion_inserts_the_call() {
|
||||
let mut buf = String::from("x = loopO");
|
||||
assert_eq!(accept_completion(&mut buf, 9), Some(12));
|
||||
assert_eq!(buf, "x = loopOut()");
|
||||
|
||||
let mut buf = String::from("wigg");
|
||||
assert_eq!(accept_completion(&mut buf, 4), Some(7));
|
||||
assert_eq!(buf, "wiggle()");
|
||||
|
||||
let mut buf = String::from("isClo");
|
||||
assert_eq!(accept_completion(&mut buf, 5), Some(10));
|
||||
assert_eq!(buf, "isClosed()");
|
||||
|
||||
let mut buf = String::from("tim");
|
||||
assert_eq!(accept_completion(&mut buf, 3), Some(4));
|
||||
assert_eq!(buf, "time");
|
||||
}
|
||||
|
||||
/// Completing a name already followed by its call does not add a second pair.
|
||||
#[test]
|
||||
fn accepting_before_an_existing_call_keeps_one_pair_of_parentheses() {
|
||||
let mut buf = String::from("loopO(\"cycle\")");
|
||||
assert_eq!(accept_completion(&mut buf, 5), Some(7));
|
||||
assert_eq!(buf, "loopOut(\"cycle\")");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +228,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
"file.import" if !has(p, &["importAs", "page"]) && is_pdf(p) => (
|
||||
"Import PDF / Illustrator File".into(),
|
||||
vec![
|
||||
Field::choice("importAs", "Import Kind", &[("Footage", json!("footage")), ("Composition", json!("composition"))], 0),
|
||||
Field::choice(
|
||||
"importAs",
|
||||
"Import Kind",
|
||||
&[
|
||||
("Footage", json!("footage")),
|
||||
("Composition", json!("composition")),
|
||||
("Composition - Retain Layer Sizes", json!("compositionLayerSizes")),
|
||||
],
|
||||
0,
|
||||
),
|
||||
Field::num("page", "Page", 1.0),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -109,6 +109,15 @@ fn shown_dims(l: &Layer, pr: &Property) -> usize {
|
||||
if pr.shown_dims > 0 && !l.is_3d() { (pr.shown_dims as usize).min(n) } else { n.min(4) }
|
||||
}
|
||||
|
||||
/// The speed graph's value of dimension `d` at layer time `lt` (one curve for a spatial property).
|
||||
fn speed_at(pr: &Property, spatial: bool, d: usize, lt: Tick) -> f64 {
|
||||
if spatial {
|
||||
effectcraft_engine::keyframe::speed(&pr.keys, lt, true)
|
||||
} else {
|
||||
effectcraft_engine::keyframe::velocity(&pr.keys, lt, false).get(d).copied().unwrap_or(0.0).abs()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the curves for the visible time span.
|
||||
fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, plot: Rect, speed: bool) -> Vec<Curve<'a>> {
|
||||
let sel = &app.session.state.selected_keys;
|
||||
@@ -126,16 +135,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl
|
||||
let x = plot.min.x + plot.width() * i as f32 / (n - 1) as f32;
|
||||
let ct = tm.t(x);
|
||||
let tt = Tick::from_seconds_f64(ct);
|
||||
let v = if speed {
|
||||
let lt = l.layer_time(tt);
|
||||
if spatial {
|
||||
effectcraft_engine::keyframe::speed(&pr.keys, lt, true)
|
||||
} else {
|
||||
effectcraft_engine::keyframe::velocity(&pr.keys, lt, false).get(d).copied().unwrap_or(0.0).abs()
|
||||
}
|
||||
} else {
|
||||
ectx.at(tt).value(l, pr).components().get(d).copied().unwrap_or(0.0)
|
||||
};
|
||||
let v = if speed { speed_at(pr, spatial, d, l.layer_time(tt)) } else { ectx.at(tt).value(l, pr).components().get(d).copied().unwrap_or(0.0) };
|
||||
pts.push((ct, v));
|
||||
}
|
||||
let mut keys = vec![];
|
||||
@@ -290,7 +290,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
ctx.data_mut(|d| d.insert_temp(was_speed_id, speed));
|
||||
if ctx.data_mut(|d| d.remove_temp::<bool>(fit_pending_id())).unwrap_or(false) || type_changed {
|
||||
if cs.iter().any(|c| c.keys.iter().any(|k| k.sel)) {
|
||||
fit_selection(app, &cs, tm, plot);
|
||||
fit_selection(app, &cs, tm, plot, speed);
|
||||
} else {
|
||||
fit_all(app);
|
||||
}
|
||||
@@ -584,7 +584,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
fit_all(app);
|
||||
}
|
||||
if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") {
|
||||
fit_selection(app, &cs, tm, plot);
|
||||
fit_selection(app, &cs, tm, plot, speed);
|
||||
}
|
||||
buttons.x += 6.0;
|
||||
let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box);
|
||||
@@ -646,7 +646,7 @@ pub(crate) fn track_open(ctx: &egui::Context, on: bool) {
|
||||
}
|
||||
|
||||
/// Zoom time and value to the selected keys (and their handles).
|
||||
fn fit_selection(app: &mut EffectcraftApp, cs: &[Curve], tm: TMap, plot: Rect) {
|
||||
fn fit_selection(app: &mut EffectcraftApp, cs: &[Curve], tm: TMap, plot: Rect, speed: bool) {
|
||||
let mut t0 = f64::INFINITY;
|
||||
let mut t1 = f64::NEG_INFINITY;
|
||||
let mut v0 = f64::INFINITY;
|
||||
@@ -678,6 +678,26 @@ fn fit_selection(app: &mut EffectcraftApp, cs: &[Curve], tm: TMap, plot: Rect) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// In the speed graph eased keys sit at speed 0 and the curve peaks between them (#1617):
|
||||
// frame the curve from the first to the last selected key. A lone key has its handles on
|
||||
// the speed graph, so the check above misses it: it frames its neighbouring segments here.
|
||||
if speed {
|
||||
for c in cs {
|
||||
let (Some(first), Some(last)) = (c.keys.iter().position(|k| k.sel), c.keys.iter().rposition(|k| k.sel)) else { continue };
|
||||
let (a, b) = if first == last { (first.saturating_sub(1), last + 1) } else { (first, last) };
|
||||
let (Some(ka), Some(kb)) = (c.keys.get(a), c.keys.get(b.min(c.keys.len().saturating_sub(1)))) else { continue };
|
||||
t0 = t0.min(ka.t);
|
||||
t1 = t1.max(kb.t);
|
||||
let (s0, s1) = (ka.time.seconds(), kb.time.seconds());
|
||||
const SAMPLES: usize = 128;
|
||||
for i in 0..=SAMPLES {
|
||||
let lt = Tick::from_seconds_f64(s0 + (s1 - s0) * i as f64 / SAMPLES as f64);
|
||||
let v = speed_at(c.prop, !c.editable, c.dim, lt);
|
||||
v0 = v0.min(v);
|
||||
v1 = v1.max(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let span = (t1 - t0).max(0.2);
|
||||
let pps = plot.width() as f64 * 0.8 / span;
|
||||
// Through the timeline's zoom, so the fit never zooms out past the whole comp.
|
||||
|
||||
@@ -15,6 +15,7 @@ use effectcraft_engine::media_browser as mb;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::DragPayload;
|
||||
use super::panel_kit as kit;
|
||||
@@ -24,6 +25,7 @@ use crate::{EffectcraftApp, widgets};
|
||||
// ---------------------------------------------------------------- Media Browser
|
||||
|
||||
type Thumb = Option<(egui::TextureHandle, [u32; 2])>;
|
||||
type Listing = (f64, Arc<Result<Vec<mb::Entry>, String>>);
|
||||
|
||||
/// Thumbnail of a file (cached per path; `None` while not decoded or not decodable).
|
||||
fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut u32) -> Thumb {
|
||||
@@ -76,11 +78,11 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Listing cached per folder for a second.
|
||||
let lid = egui::Id::new(("mb-list", dir.clone(), st.importable_only));
|
||||
let now = ctx.input(|i| i.time);
|
||||
let listing: Option<(f64, Result<Vec<mb::Entry>, String>)> = ctx.data(|d| d.get_temp(lid));
|
||||
let listing: Option<Listing> = ctx.data(|d| d.get_temp(lid));
|
||||
let entries = match listing {
|
||||
Some((at, l)) if now - at < 1.0 => l,
|
||||
_ => {
|
||||
let l = b.list(&dir, st.importable_only);
|
||||
let l = Arc::new(b.list(&dir, st.importable_only));
|
||||
ctx.data_mut(|d| d.insert_temp(lid, (now, l.clone())));
|
||||
l
|
||||
}
|
||||
@@ -115,7 +117,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
if kit::button(app, ui, Rect::from_min_size(pos2(ax, y), vec2(w, 20.0)), &format!("mediaBrowser.action.{id}"), label, false) {
|
||||
kit::exec(app, "mediaBrowser.action", json!({"action": id}));
|
||||
ctx.data_mut(|d| d.remove::<(f64, Result<Vec<mb::Entry>, String>)>(lid));
|
||||
ctx.data_mut(|d| d.remove::<Listing>(lid));
|
||||
}
|
||||
ax -= 6.0;
|
||||
}
|
||||
@@ -150,7 +152,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// Files.
|
||||
let list = Rect::from_min_max(pos2(side.max.x + 1.0, side.min.y), rect.max);
|
||||
let entries = match entries {
|
||||
let entries = match entries.as_ref() {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
p.text(list.center(), Align2::CENTER_CENTER, e, Tokens::ui(12.0), t.danger);
|
||||
@@ -209,6 +211,14 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
egui::DragAndDrop::set_payload(&ctx, DragPayload::Files(vec![e.path.clone()]));
|
||||
}
|
||||
crate::widgets::context_menu(&resp, |ui| {
|
||||
let copy = ui.button(tr("Copy Source Path"));
|
||||
app.auto.add(&format!("mediaBrowser.entry.{}.copySourcePath", e.name), copy.rect, "Copy Source Path");
|
||||
if copy.clicked() {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "mediaBrowser.copySourcePath", json!({"path": e.path})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
if !e.is_dir && ui.button(tr("Import")).clicked() {
|
||||
actions.push(("mediaBrowser.import", json!({"paths": [e.path]})));
|
||||
ui.close();
|
||||
@@ -409,3 +419,52 @@ pub fn import_card(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect) -> f
|
||||
}
|
||||
full.max.y - 16.0 - bottom
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "Media Browser warm listing cache benchmark"]
|
||||
fn listing_cache_benchmark() {
|
||||
let ctx = egui::Context::default();
|
||||
let entries: Vec<_> = (0..1000)
|
||||
.map(|i| mb::Entry {
|
||||
name: format!("Clip {i:04}.mov"),
|
||||
path: format!("/Shoot/作品 Clip {i:04}.mov"),
|
||||
is_dir: false,
|
||||
size: 1,
|
||||
modified: None,
|
||||
kind: Some("video"),
|
||||
})
|
||||
.collect();
|
||||
let old_id = egui::Id::new("old-list");
|
||||
let new_id = egui::Id::new("new-list");
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(old_id, (0.0, Ok::<_, String>(entries.clone())));
|
||||
d.insert_temp(new_id, (0.0, Arc::new(Ok::<_, String>(entries))));
|
||||
});
|
||||
let (_, original) = ctx.data(|d| d.get_temp::<Listing>(new_id)).unwrap();
|
||||
assert_eq!(original.as_ref().as_ref().unwrap()[0].name, "Clip 0000.mov");
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
let _ = std::hint::black_box(ctx.data(|d| d.get_temp::<(f64, Result<Vec<mb::Entry>, String>)>(old_id)).unwrap());
|
||||
}
|
||||
before.push(start.elapsed());
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(ctx.data(|d| d.get_temp::<Listing>(new_id)).unwrap());
|
||||
}
|
||||
after.push(start.elapsed());
|
||||
}
|
||||
let (_, cached) = ctx.data(|d| d.get_temp::<Listing>(new_id)).unwrap();
|
||||
assert!(Arc::ptr_eq(&original, &cached));
|
||||
assert_eq!(cached.as_ref().as_ref().unwrap().last().unwrap().name, "Clip 0999.mov");
|
||||
before.sort();
|
||||
after.sort();
|
||||
println!("1,000 Media Browser entries, median per warm cache lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,11 +84,14 @@ pub const COLUMNS: [(&str, &str, f32); 6] = [
|
||||
|
||||
/// Sort siblings by the Project panel's sort column; ties fall back to the name.
|
||||
pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) {
|
||||
if col == "size" {
|
||||
sort_by_size(items, desc, file_size);
|
||||
return;
|
||||
}
|
||||
let name = |i: &Item| i.name.to_lowercase();
|
||||
items.sort_by(|a, b| {
|
||||
let o = match col {
|
||||
"type" => a.type_name().cmp(b.type_name()),
|
||||
"size" => file_size(a).cmp(&file_size(b)),
|
||||
"fps" => a.frame_rate().map(|r| r.as_f64()).partial_cmp(&b.frame_rate().map(|r| r.as_f64())).unwrap_or(std::cmp::Ordering::Equal),
|
||||
"duration" => a.duration().cmp(&b.duration()),
|
||||
"path" => file_path(a).cmp(file_path(b)),
|
||||
@@ -101,6 +104,15 @@ pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
fn sort_by_size(items: &mut [&Item], desc: bool, mut size_of: impl FnMut(&Item) -> Option<u64>) {
|
||||
let mut key = |it: &&Item| (size_of(it), it.name.to_lowercase());
|
||||
if desc {
|
||||
items.sort_by_cached_key(|it| std::cmp::Reverse(key(it)));
|
||||
} else {
|
||||
items.sort_by_cached_key(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a drop at a row lands: into a folder row, else into the row's folder; `None` row = root.
|
||||
pub fn drop_folder(project: &effectcraft_engine::project::Project, row: Option<ItemId>) -> Option<ItemId> {
|
||||
let it = project.item(row?)?;
|
||||
@@ -1113,6 +1125,136 @@ pub fn set_column(cols: &mut Vec<String>, key: &str, on: bool) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_engine::project::{Footage, Project};
|
||||
|
||||
struct SizeFiles(std::path::PathBuf);
|
||||
|
||||
impl SizeFiles {
|
||||
fn new() -> Self {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("effectcraft-size-{}-{n}", std::process::id()));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn add(&self, project: &mut Project, name: &str, bytes: Option<u64>) {
|
||||
let path = self.0.join(name);
|
||||
if let Some(bytes) = bytes {
|
||||
std::fs::File::create(&path).unwrap().set_len(bytes).unwrap();
|
||||
}
|
||||
project.add_item(name, Label::Aqua, None, ItemKind::Footage(Footage { path: path.to_string_lossy().into_owned(), ..Default::default() }));
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SizeFiles {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_keys_are_sampled_once_with_stable_ties() {
|
||||
let mut project = Project::default();
|
||||
for name in ["Zeta", "alpha", "ALPHA", "Missing", "Zero"] {
|
||||
project.add_item(name, Label::Aqua, None, ItemKind::Folder);
|
||||
}
|
||||
for (desc, expected) in [(false, ["Missing", "Zero", "alpha", "ALPHA", "Zeta"]), (true, ["Zeta", "alpha", "ALPHA", "Zero", "Missing"])] {
|
||||
let mut reads = std::collections::BTreeMap::new();
|
||||
let mut rows = project.children(None);
|
||||
sort_by_size(&mut rows, desc, |item| {
|
||||
*reads.entry(item.id).or_insert(0) += 1;
|
||||
match item.name.as_str() {
|
||||
"Zeta" => Some(30),
|
||||
"alpha" | "ALPHA" => Some(10),
|
||||
"Zero" => Some(0),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), expected);
|
||||
assert_eq!(reads.len(), 5);
|
||||
assert!(reads.values().all(|&count| count == 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_sort_reads_files_again_after_they_change() {
|
||||
let files = SizeFiles::new();
|
||||
let mut project = Project::default();
|
||||
project.add_item("Folder", Label::Aqua, None, ItemKind::Folder);
|
||||
files.add(&mut project, "Missing", None);
|
||||
files.add(&mut project, "Zero", Some(0));
|
||||
files.add(&mut project, "Small", Some(10));
|
||||
files.add(&mut project, "Large", Some(20));
|
||||
let mut rows = project.children(None);
|
||||
sort_items(&mut rows, "size", false);
|
||||
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Folder", "Missing", "Zero", "Small", "Large"]);
|
||||
sort_items(&mut rows, "size", true);
|
||||
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Large", "Small", "Zero", "Missing", "Folder"]);
|
||||
std::fs::OpenOptions::new().write(true).open(files.0.join("Small")).unwrap().set_len(30).unwrap();
|
||||
sort_items(&mut rows, "size", false);
|
||||
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Folder", "Missing", "Zero", "Large", "Small"]);
|
||||
std::fs::remove_file(files.0.join("Large")).unwrap();
|
||||
sort_items(&mut rows, "size", false);
|
||||
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Folder", "Large", "Missing", "Zero", "Small"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_sort_accepts_empty_and_singleton_lists() {
|
||||
let mut project = Project::default();
|
||||
let id = project.add_item("Only", Label::Aqua, None, ItemKind::Folder);
|
||||
for desc in [false, true] {
|
||||
sort_items(&mut [], "size", desc);
|
||||
let mut rows = project.children(None);
|
||||
sort_items(&mut rows, "size", desc);
|
||||
assert_eq!(rows.iter().map(|item| item.id).collect::<Vec<_>>(), [id]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "filesystem benchmark; run with --ignored --nocapture"]
|
||||
fn size_sort_benchmark() {
|
||||
let files = SizeFiles::new();
|
||||
let mut project = Project::default();
|
||||
for i in 0..1000 {
|
||||
files.add(&mut project, &format!("Clip {i:04}"), Some((i * 137) % 1000 + 1));
|
||||
}
|
||||
let original = project.children(None);
|
||||
for item in &original {
|
||||
assert!(file_size(item).is_some());
|
||||
}
|
||||
let mut before_times = Vec::new();
|
||||
let mut after_times = Vec::new();
|
||||
let mut before_reads = 0;
|
||||
let mut after_reads = 0;
|
||||
for _ in 0..5 {
|
||||
let mut before = original.clone();
|
||||
before_reads = 0;
|
||||
let start = std::time::Instant::now();
|
||||
before.sort_by(|a, b| {
|
||||
before_reads += 2;
|
||||
file_size(a).cmp(&file_size(b)).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
before_times.push(start.elapsed());
|
||||
let mut after = original.clone();
|
||||
after_reads = 0;
|
||||
let start = std::time::Instant::now();
|
||||
sort_by_size(&mut after, false, |item| {
|
||||
after_reads += 1;
|
||||
file_size(item)
|
||||
});
|
||||
after_times.push(start.elapsed());
|
||||
assert_eq!(before.iter().map(|item| item.id).collect::<Vec<_>>(), after.iter().map(|item| item.id).collect::<Vec<_>>());
|
||||
assert_eq!(after_reads, 1000);
|
||||
assert!(before_reads > after_reads);
|
||||
}
|
||||
before_times.sort();
|
||||
after_times.sort();
|
||||
println!(
|
||||
"1,000 footage files; warmed metadata; five paired sorts\nmetadata reads/sort: {before_reads} -> {after_reads}\nmedian sort time: {:?} -> {:?}",
|
||||
before_times[2], after_times[2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ae_duration_and_size_formats() {
|
||||
|
||||
@@ -244,6 +244,17 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
}
|
||||
p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text);
|
||||
let vx = r.min.x + (r.width() * 0.5).max(130.0);
|
||||
let label = Rect::from_min_max(pos2(r.min.x + 42.0, r.min.y), pos2(vx - 20.0, r.max.y));
|
||||
let resp = ui.interact(label, egui::Id::new(("props-label", uid)), Sense::click());
|
||||
app.auto.add(&format!("{base}.label"), label, &pr.name);
|
||||
if resp.double_clicked() {
|
||||
app.ui.timeline.search.clear();
|
||||
app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Property(uid));
|
||||
if let Err(e) = crate::menus::frontend(app, ui.ctx(), "timeline.revealProps", json!({"props": [{"layer": lid, "prop": uid}]})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
app.show_panel(PanelKind::Timeline);
|
||||
}
|
||||
let merge = format!("props-{uid}");
|
||||
// With several layers selected the edit applies to all: a scrub by the same change, a typed
|
||||
// value as it is.
|
||||
|
||||
@@ -664,6 +664,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(180.0);
|
||||
egui::ScrollArea::vertical().id_salt("settings-pages").max_height(body_h).auto_shrink([false, false]).show(ui, |ui| {
|
||||
// The rows touch, so the pointer never falls between two of them.
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for p in &all {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click());
|
||||
let sel = page == p.id;
|
||||
|
||||
@@ -1895,6 +1895,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let Some(i) = rows.iter().position(|r| matches!(r.kind, RowKind::Layer) && Some(r.layer) == top) {
|
||||
app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum();
|
||||
}
|
||||
} else if let Some(crate::state::ScrollTo::Property(uid)) = scroll_to
|
||||
&& let Some(i) = rows.iter().position(|r| matches!(r.kind, RowKind::Prop { uid: p } if p == uid))
|
||||
{
|
||||
app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum();
|
||||
}
|
||||
if ui.rect_contains_pointer(rows_rect) {
|
||||
let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt));
|
||||
@@ -1992,8 +1996,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
gp.line_segment([pos2(graph_x0, r.max.y - 0.5), pos2(r.max.x, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
|
||||
// A/V features.
|
||||
let sw = &layer.switches;
|
||||
// Every layer has the Video switch (the eye), nulls, cameras and lights included,
|
||||
// as in After Effects: it hides a null's outline in the viewer and turns a camera or
|
||||
// light off.
|
||||
let av_items: [(Icon, bool, &str, bool); 4] = [
|
||||
(Icon::Eye, sw.video, "video", layer.source.is_av()),
|
||||
(Icon::Eye, sw.video, "video", true),
|
||||
(Icon::Speaker, sw.audio, "audio", layer.props.sub("audio").is_some()),
|
||||
(Icon::Solo, sw.solo, "solo", layer.source.is_av()),
|
||||
(Icon::Lock, sw.locked, "lock", true),
|
||||
@@ -2400,6 +2407,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
},
|
||||
);
|
||||
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
|
||||
// An effect's colour label (26.5): the same bar the Effect Controls header wears,
|
||||
// on the row's left edge.
|
||||
if let Some(lab) =
|
||||
group.filter(|g| matches!(g.kind, GroupKind::Effect { .. })).and_then(|g| g.label).filter(|l| *l != effectcraft_engine::color::Label::None)
|
||||
{
|
||||
let [lr, lg, lb] = app.session.prefs.label_rgb(lab);
|
||||
let bar = Rect::from_min_size(left.min, vec2(4.0, left.height()));
|
||||
lp.rect_filled(bar, 0.0, Color32::from_rgb(lr, lg, lb));
|
||||
app.auto.add(&format!("timeline.group.{uid}.label"), bar, &app.session.prefs.label_name(lab));
|
||||
}
|
||||
let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
|
||||
if *has_children {
|
||||
let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0));
|
||||
@@ -3330,8 +3347,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
{
|
||||
let released = ctx.input(|i| i.pointer.any_released());
|
||||
if let crate::panels::DragPayload::Effect(e) = payload.as_ref() {
|
||||
p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||||
let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
|
||||
// The layer the effect would land on (its row and its open properties) is the one
|
||||
// that lights up, not the whole timeline.
|
||||
if let Some(l) = target
|
||||
&& let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
|
||||
{
|
||||
let lit = span.intersect(rows_rect);
|
||||
let rp = p.with_clip_rect(rows_rect);
|
||||
rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
|
||||
rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||||
app.auto.add("timeline.dropTarget", lit, "Drop target layer");
|
||||
}
|
||||
if released && let Some(l) = target {
|
||||
actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]})));
|
||||
}
|
||||
@@ -3931,6 +3958,7 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps,
|
||||
}
|
||||
})
|
||||
});
|
||||
ui.menu_button(crate::i18n::tr("Layer Styles"), |ui| layer_styles_submenu(ui, layers, &mut item));
|
||||
ui.menu_button(crate::i18n::tr("Effects"), |ui| effects_submenu(ui, layers, &mut item));
|
||||
ui.menu_button(crate::i18n::tr("Arrange"), |ui| {
|
||||
item(ui, "Bring Layer to Front", "layer.arrange", json!({"layers": layers, "to": "front"}));
|
||||
@@ -3945,6 +3973,31 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps,
|
||||
item(ui, "Delete", "edit.clear", json!({"layers": layers}));
|
||||
}
|
||||
|
||||
/// The layer context menu's Layer Styles submenu, matching the Layer ▸ Layer Styles application menu.
|
||||
fn layer_styles_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut egui::Ui, &str, &str, serde_json::Value)) {
|
||||
item(ui, "Layer Style Options...", "layer.style.options", json!({}));
|
||||
ui.separator();
|
||||
item(ui, "Convert to Editable Styles", "layer.style.convertToEditable", json!({"layers": layers}));
|
||||
item(ui, "Show All", "layer.style.showAll", json!({"layers": layers}));
|
||||
item(ui, "Remove All", "layer.style.removeAll", json!({"layers": layers}));
|
||||
ui.separator();
|
||||
for (id, label) in effectcraft_engine::project::styles::STYLES {
|
||||
let cmd = match *id {
|
||||
"dropShadow" => "layer.style.dropShadow",
|
||||
"innerShadow" => "layer.style.innerShadow",
|
||||
"outerGlow" => "layer.style.outerGlow",
|
||||
"innerGlow" => "layer.style.innerGlow",
|
||||
"bevelEmboss" => "layer.style.bevelEmboss",
|
||||
"satin" => "layer.style.satin",
|
||||
"colorOverlay" => "layer.style.colorOverlay",
|
||||
"gradientOverlay" => "layer.style.gradientOverlay",
|
||||
"stroke" => "layer.style.stroke",
|
||||
_ => continue,
|
||||
};
|
||||
item(ui, label, cmd, json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer context menu's Effects submenu: every effect from the registry grouped by category,
|
||||
/// in the same order as the Effect menu's palette, so the two stay in sync automatically.
|
||||
fn effects_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut egui::Ui, &str, &str, serde_json::Value)) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user