Merge branch 'main' into expr-property-group-1619

This commit is contained in:
Gert Pellin
2026-10-16 14:28:34 +02:00
128 changed files with 4016 additions and 346 deletions
+3 -13
View File
@@ -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
View File
@@ -1455,6 +1455,7 @@ dependencies = [
"log",
"rayon",
"serde_json",
"symphonia",
"thiserror 2.0.21",
]
+3
View File
@@ -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"] }
+48 -6
View File
@@ -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() {
+14 -1
View File
@@ -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}")));
}
+104 -2
View File
@@ -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", &params, "--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);
}
+39 -2
View File
@@ -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);
}
+4
View File
@@ -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
}
+12
View File
@@ -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
+10 -1
View File
@@ -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
+11
View File
@@ -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: &params, 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))]));
+70 -3
View File
@@ -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];
+30 -1
View File
@@ -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;
+23 -1
View File
@@ -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))];
+15
View File
@@ -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();
+63 -2
View File
@@ -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);
+4 -2
View File
@@ -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}));
}
+7 -6
View File
@@ -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 }));
}
}
+3 -3
View File
@@ -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)
})?;
+1 -2
View File
@@ -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}));
}
+2
View File
@@ -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;
+1 -1
View File
@@ -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;
}
}
+4
View File
@@ -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);
+16 -11
View File
@@ -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 -3
View File
@@ -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();
+4 -3
View File
@@ -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);
}
+7
View File
@@ -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() {
+68 -3
View File
@@ -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");
+9 -2
View File
@@ -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);
+50
View File
@@ -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]
+75 -1
View File
@@ -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();
+58
View File
@@ -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();
+150
View File
@@ -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();
+18 -8
View File
@@ -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);
}
+1 -1
View File
@@ -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))
}
+59 -5
View File
@@ -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();
+25
View File
@@ -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}");
}
}
+4 -1
View File
@@ -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) {
+77
View File
@@ -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;
+13 -7
View File
@@ -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) {
+4
View File
@@ -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() {
+3 -2
View File
@@ -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));
+4 -2
View File
@@ -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
View File
@@ -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})");
}
+6 -4
View File
@@ -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)) {
+10 -10
View File
@@ -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())))
}
+1
View File
@@ -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))]);
+22
View File
@@ -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 };
+3
View File
@@ -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() {
+2 -1
View File
@@ -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}),
+2 -2
View File
@@ -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:?}");
+91 -17
View File
@@ -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");
}
}
+28
View File
@@ -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}");
}
}
+10
View File
@@ -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();
+2 -4
View File
@@ -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" => {
+49
View File
@@ -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]
+2
View File
@@ -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"] }
+1 -1
View File
@@ -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}")),
+2 -4
View File
@@ -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`
+3 -1
View File
@@ -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
View File
@@ -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);
}
}
+12 -4
View File
@@ -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);
}
}
+9 -1
View File
@@ -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);
+21
View File
@@ -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)]
+1 -1
View File
@@ -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) {
+45
View File
@@ -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]);
}
+37
View File
@@ -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
View File
@@ -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"));
}
}
+26 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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(&params)
.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: &params, 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()
+16 -1
View File
@@ -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).
+37
View File
@@ -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);
}
+57
View File
@@ -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}");
}
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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
+4
View File
@@ -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;
+1
View File
@@ -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) {
+53 -15
View 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"));
}
}
+107
View File
@@ -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));
}
+7
View File
@@ -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]));
}
+47
View File
@@ -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()]);
}
+64
View File
@@ -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)
}
+23
View File
@@ -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)]
+1 -17
View File
@@ -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);
}
+12 -2
View File
@@ -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
View File
@@ -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());
+53 -4
View File
@@ -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");
}
+18 -4
View File
@@ -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: {}"),
+20 -2
View File
@@ -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;
+18
View File
@@ -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();
+79 -19
View File
@@ -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 == &copy_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(&copy_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);
}
}
+69 -8
View File
@@ -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\")");
}
}
+10 -1
View File
@@ -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),
],
),
+33 -13
View File
@@ -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.
+63 -4
View File
@@ -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);
}
}
+143 -1
View File
@@ -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() {
+11
View File
@@ -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.
+2
View File
@@ -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;
+55 -2
View File
@@ -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