Merge remote-tracking branch 'origin/main' into menu-bar-native-behaviour

# Conflicts:
#	crates/ui-egui/src/menus.rs
This commit is contained in:
Egon 5000
2026-10-17 13:58:19 +02:00
56 changed files with 1510 additions and 97 deletions
+25 -5
View File
@@ -503,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);
}
@@ -525,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);
}
@@ -647,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,
@@ -670,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") {
+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}")));
}
+80
View File
@@ -484,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() {
@@ -513,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
+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];
+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}));
}
+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);
+2 -1
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:?}");
+28
View File
@@ -371,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]
+32
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();
+71
View File
@@ -410,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();
+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();
+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
@@ -1285,3 +1285,80 @@ fn layer_control_property_stands_for_its_layer() {
assert_close!(f.num("effect('Map')(1).transform.scale[0]", 0.0), 100.0);
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}");
}
}
+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));
+20 -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).
+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))]);
+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]
+45 -1
View File
@@ -848,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..];
@@ -1145,4 +1147,46 @@ mod tests {
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);
}
}
+5
View File
@@ -184,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);
+22 -1
View File
@@ -274,11 +274,32 @@ fn inline_image_with_absurd_dimensions_falls_back_to_ei_scan() {
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 EI Q", " ".repeat(5000));
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\
+6
View File
@@ -455,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
+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);
}
+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;
+41 -8
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)?;
@@ -834,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"));
}
}
+56
View File
@@ -442,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]
@@ -1270,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() {
+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()]);
}
+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");
}
+7
View File
@@ -291,6 +291,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Layer {}", "レイヤー{}"),
("Layer:", "レイヤー:"),
("Layer: {}", "レイヤー:{}"),
("Layer Styles", "レイヤースタイル"),
("Learn", "学習"),
("Leave all attributes in \"{}\"", "すべての属性を「{}」に残す"),
("Licence: {}", "ライセンス:{}"),
@@ -836,6 +837,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Layer {}", "图层 {}"),
("Layer:", "图层:"),
("Layer: {}", "图层:{}"),
("Layer Styles", "图层样式"),
("Learn", "学习"),
("Leave all attributes in \"{}\"", "将所有属性保留在“{}”中"),
("Licence: {}", "许可:{}"),
@@ -1569,6 +1571,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Layer {}", "圖層 {}"),
("Layer:", "圖層:"),
("Layer: {}", "圖層:{}"),
("Layer Styles", "圖層樣式"),
("Learn", "學習"),
("Leave all attributes in \"{}\"", "將所有屬性保留在“{}”中"),
("Licence: {}", "許可:{}"),
@@ -2305,6 +2308,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Layer {}", "Шар {}"),
("Layer:", "Шар:"),
("Layer: {}", "Шар: {}"),
("Layer Styles", "Стилі шару"),
("Learn", "Навчання"),
("Leave all attributes in \"{}\"", "Залишити всі атрибути в «{}»"),
("Licence: {}", "Ліцензія: {}"),
@@ -2856,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: {}"),
@@ -3308,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: {}"),
@@ -3865,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: {}"),
+14 -1
View File
@@ -1526,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;
@@ -2075,7 +2088,7 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
egui::Popup::close_id(&ctx, top);
}
}
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();
+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\")");
}
}
+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.
+51 -2
View File
@@ -1996,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),
@@ -2404,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));
@@ -3334,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]})));
}
@@ -3935,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"}));
@@ -3949,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)) {
+35 -1
View File
@@ -996,7 +996,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Layer Handles and Paths.
let hs = app.session.prefs.general.path_point_size as f32 + 2.0;
let label_handles = app.session.prefs.appearance.use_label_color_for_handles;
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) {
// A layer whose Video switch is off has no box, handles or paths in the viewer (a hidden
// null disappears from the comp, as in After Effects), even while it is selected.
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time) && l.switches.video) {
let col = if label_handles { t.label(l.label) } else { t.accent };
// Motion path of animated position (keyframes and spatial tangents are draggable).
ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits, &mut motion_frames);
@@ -2228,6 +2230,12 @@ fn pen_press(
});
return;
}
// Shift keeps the new segment at a multiple of 45° from the vertex before it (#1544).
let last = p.sp.vertices.last().map(|v| p.m.apply(gv2(v[0], v[1])));
let c = match last {
Some(q) if mods.shift => constrain_45([q.x, q.y], c),
_ => c,
};
let lp = inv.apply(gv2(c[0], c[1]));
let lp = [lp.x, lp.y];
if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": uid, "point": lp})) {
@@ -2289,6 +2297,20 @@ fn pen_press(
});
}
/// `to` moved onto the nearest line through `from` at a multiple of 45° (horizontal, vertical or
/// diagonal), as far along it as `to` lies: the Pen's Shift-click.
fn constrain_45(from: [f64; 2], to: [f64; 2]) -> [f64; 2] {
let (dx, dy) = (to[0] - from[0], to[1] - from[1]);
if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) {
return to;
}
let step = std::f64::consts::FRAC_PI_4;
let a = (dy.atan2(dx) / step).round() * step;
let (ux, uy) = (a.cos(), a.sin());
let along = dx * ux + dy * uy;
[from[0] + ux * along, from[1] + uy * along]
}
/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order.
/// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After
/// Effects), even when hidden, locked or outside its in-out range.
@@ -2692,6 +2714,18 @@ mod tests {
use super::*;
use effectcraft_engine::geom::{Mat4, vec3};
/// #1544: Shift-clicking with the Pen keeps the new segment horizontal, vertical or diagonal.
#[test]
fn pen_shift_click_keeps_the_segment_at_45_degrees() {
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9;
let from = [100.0, 100.0];
assert!(near(constrain_45(from, [104.0, 180.0]), [100.0, 180.0]), "straight down");
assert!(near(constrain_45(from, [30.0, 97.0]), [30.0, 100.0]), "left");
assert!(near(constrain_45(from, [150.0, 46.0]), [152.0, 48.0]), "up and right, on the diagonal");
assert!(near(constrain_45(from, from), from), "on the vertex");
assert!(constrain_45(from, [f64::NAN, 3.0])[0].is_nan(), "a non-finite point stays as it is");
}
/// The Rotation tool counts past a full turn in both directions, as in After Effects: the
/// pointer going round the anchor 1.5 turns clockwise gives 540°, then back 2 turns gives
/// −180°, with no jump where atan2 wraps from 180° to −180°.
@@ -274,6 +274,31 @@ fn timeline_layer_context_menu_has_the_effects_submenu_and_applies_an_effect() {
assert_eq!(names, ["Gaussian Blur"]);
}
/// #1611: the layer context menu has Layer Styles between Blending Mode and Effects.
#[test]
fn timeline_layer_context_menu_has_layer_styles_submenu() {
let mut h = harness();
let solid = h.state_mut().session.execute("layer.newSolid", json!({"name": "Solid", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
h.run_steps(3);
let r = rect(&h, &format!("timeline.layer.{solid}.row"));
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
let blending = h.get_by_label("Blending Mode ⏵").rect();
let styles = h.get_by_label("Layer Styles ⏵").rect();
let effects = h.get_by_label("Effects ⏵").rect();
assert!(blending.bottom() <= styles.top() && styles.bottom() <= effects.top(), "Layer Styles between Blending Mode and Effects");
h.get_by_label("Layer Styles ⏵").click();
h.run_steps(3);
assert!(h.query_by_label("Layer Style Options...").is_some());
assert!(h.query_by_label("Drop Shadow").is_some());
assert!(h.query_by_label("Stroke").is_some());
h.get_by_label("Drop Shadow").click();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(Dialog::LayerStyles));
let comp = h.state().session.active_comp().unwrap();
let l = comp.layer(effectcraft_engine::project::LayerId(solid)).unwrap();
assert!(l.layer_styles().is_some_and(|g| g.sub("dropShadow").is_some()));
}
/// #976: a layer's context menu has After Effects' Markers ▸ Add Marker, which marks the layer that
/// was right-clicked (not the one selected before, and not the composition) at the current time.
#[test]
@@ -0,0 +1,40 @@
//! Dragging an effect over the Timeline lights up the layer it would land on, not the whole panel.
use effectcraft_engine::Session;
use effectcraft_ui_egui::{EffectcraftApp, panels::DragPayload};
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn drag_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
egui::DragAndDrop::set_payload(&h.ctx, DragPayload::Effect("CC Bend It".into()));
h.run_steps(2);
}
#[test]
fn effect_drag_highlights_the_layer_under_the_pointer() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap();
let a = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
assert!(h.state().auto.find("timeline.dropTarget").is_none(), "nothing is lit without a drag");
let (row_a, row_b) = (rect(&h, &format!("timeline.layer.{a}.row")), rect(&h, &format!("timeline.layer.{b}.row")));
let panel = rect(&h, "timeline.layerMarquee");
drag_to(&mut h, row_b.center());
let lit = rect(&h, "timeline.dropTarget");
assert!(lit.contains(row_b.center()) && !lit.contains(row_a.center()), "lit {lit:?} for row {row_b:?}");
assert!(lit.height() < panel.height(), "only the layer is lit, not the whole timeline");
drag_to(&mut h, row_a.center());
let lit = rect(&h, "timeline.dropTarget");
assert!(lit.contains(row_a.center()) && !lit.contains(row_b.center()), "the highlight follows the pointer: {lit:?}");
}
+36
View File
@@ -562,3 +562,39 @@ fn speed_graph_handle_drags_upward_past_the_top() {
// The speed per pixel depends on the graph's fitted scale; only the proportion matters.
assert!(short > 0.0 && long > 9.0 * short, "40 px up gave {short}, 400 px up gave {long}");
}
/// #1617: with Easy Ease both keys sit at speed 0 in the speed graph, so fitting the selection to
/// its keys and handles framed -1..1 while the curve between them peaks far above. Opening the
/// Graph Editor on selected keys and Fit Selection both frame the curve's peak, and a lone
/// selected key frames its neighbouring segments.
#[test]
fn speed_graph_fit_selection_frames_the_curve_between_the_keys() {
let (mut h, id, uid) = harness();
let s = &mut h.state_mut().session;
s.execute("prop.select", json!({"layer": id.0, "prop": uid})).unwrap();
s.execute("keys.selectAll", json!({})).unwrap();
s.execute("keys.easyEase", json!({})).unwrap();
let keys = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys.clone();
let peak = (0..=100)
.map(|i| effectcraft_engine::keyframe::velocity(&keys, effectcraft_engine::time::Tick::from_seconds_f64(i as f64 / 100.0), false)[0].abs())
.fold(0.0, f64::max);
assert!(peak > 100.0, "the eased curve peaks above its average speed: {peak}");
h.state_mut().ui.timeline.graph_mode = "speed".into();
h.state_mut().ui.timeline.graph_editor = true;
h.run_steps(4);
let frames_peak = |h: &Harness<'_, EffectcraftApp>, what: &str| {
let (lo, hi) = h.state().ui.timeline.graph_range.expect("a fitted range");
assert!(lo <= 0.0 && hi > peak * 0.9, "{what}: range {lo}..{hi}, the curve peaks at {peak}");
};
frames_peak(&h, "on open");
h.state_mut().ui.timeline.graph_range = Some((-1.0, 1.0));
let fit = h.state().auto.find("timeline.graph.fitSelection").expect("the Fit Selection button").rect;
click_with(&mut h, pos2(fit[0] + fit[2] / 2.0, fit[1] + fit[3] / 2.0), Default::default());
frames_peak(&h, "Fit Selection");
let last = keys[1].time.seconds();
h.state_mut().session.execute("keys.select", json!({"keys": [{"layer": id.0, "prop": uid, "time": last}]})).unwrap();
assert_eq!(h.state().session.state.selected_keys.len(), 1);
h.state_mut().ui.timeline.graph_range = Some((-1.0, 1.0));
click_with(&mut h, pos2(fit[0] + fit[2] / 2.0, fit[1] + fit[3] / 2.0), Default::default());
frames_peak(&h, "lone key");
}
+20
View File
@@ -67,6 +67,26 @@ fn window_menu_opens_the_new_panels() {
}
}
/// #1630: choosing a checked panel in the Window menu closes it, as in After Effects (it only
/// brought the panel to the front, so the check mark never went away); choosing it again opens it.
#[test]
fn window_menu_closes_a_checked_panel() {
let mut h = harness();
let shown = |h: &Harness<'_, EffectcraftApp>| {
h.state().ui.dock.contains(PanelKind::Align) || h.state().ui.floating.iter().any(|f| f.panels.contains(&PanelKind::Align))
};
open(&mut h, "align");
assert!(shown(&h));
click(&mut h, "menu.Window");
let at = h.query_by_label("✔ Align").expect("Window ▸ Align, checked").rect().center();
click_at(&mut h, at);
assert!(!shown(&h), "Align closed");
click(&mut h, "menu.Window");
let at = h.query_by_label(" Align").expect("Window ▸ Align, unchecked").rect().center();
click_at(&mut h, at);
assert!(shown(&h), "Align open again");
}
#[test]
fn scopes_switch_kind() {
let mut h = harness();
@@ -0,0 +1,45 @@
//! An effect's colour label (26.5) shows on its Timeline row as well as in Effect Controls.
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::vec2;
use egui_kittest::Harness;
use serde_json::json;
fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let layer = s.execute("layer.newSolid", json!({"name": "A", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let fx = s.execute("effect.apply", json!({"layers": [layer], "effect": "ec.blur.gaussian"})).unwrap()["effects"][0].as_u64().unwrap();
s.state.selected_layers = vec![LayerId(layer)];
s.state.selected_props = vec![(LayerId(layer), fx)];
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::Timeline);
app.ui.timeline.open_layers.insert(layer);
let effects = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))).and_then(|l| l.props.sub("effects")).map(|g| g.uid);
app.ui.timeline.open_groups.extend(effects);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app);
h.run_steps(4);
(h, layer, fx)
}
/// The label bar appears on the effect's row once the effect has a label, carries the label's
/// name for agents, and goes away when the label is cleared.
#[test]
fn the_effect_row_wears_its_colour_label() {
let (mut h, layer, fx) = harness();
let id = format!("timeline.group.{fx}.label");
assert!(h.state().auto.find(&format!("timeline.group.{fx}.fx")).is_some(), "the effect's row is on screen");
assert!(h.state().auto.find(&id).is_none(), "an unlabelled effect has no bar");
h.state_mut().session.execute("effect.setLabel", json!({"layer": layer, "effect": fx, "label": "Blue"})).unwrap();
h.run_steps(3);
let blue = h.state().session.prefs.label_name(effectcraft_engine::color::Label::Blue);
assert_eq!(h.state().auto.find(&id).map(|e| e.label.clone()), Some(blue));
h.state_mut().session.execute("effect.setLabel", json!({"layer": layer, "effect": fx, "label": 0})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find(&id).is_none(), "clearing the label removes the bar");
}
+72
View File
@@ -763,6 +763,40 @@ fn mask_path(h: &Harness<'_, EffectcraftApp>, name: &str) -> effectcraft_engine:
l.masks().unwrap().groups().next().unwrap().get("path").unwrap().value.as_path().unwrap().clone()
}
/// #1544: a Shift-click with the Pen puts the next point straight below, beside or diagonal to the
/// one before it; without Shift it goes where it was clicked.
#[test]
fn pen_shift_click_draws_a_straight_segment() {
let mut h = harness();
let plate = layer_id(&h, "Plate");
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.state_mut().ui.tool = Tool::Pen;
h.run_steps(2);
let first = screen(&h, [100.0, 60.0]);
click(&mut h, first);
let shift_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
let shift = egui::Modifiers::SHIFT;
h.input_mut().events.push(Event::ModifiersChanged(shift));
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: shift });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: shift });
h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(2);
};
let down = screen(&h, [112.0, 250.0]);
shift_click(&mut h, down);
let free = screen(&h, [300.0, 290.0]);
click(&mut h, free);
let drawn = mask_path(&h, "Plate");
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1.0 && (a[1] - b[1]).abs() < 1.0;
assert_eq!(drawn.vertices.len(), 3, "{drawn:?}");
assert!(near(drawn.vertices[1], [100.0, 250.0]), "straight down: {drawn:?}");
assert!(near(drawn.vertices[2], [300.0, 290.0]), "no Shift, no constraint: {drawn:?}");
}
/// Drawing a mask with the Pen, moving a point and undoing take one action back per undo; a
/// double-click on a point selects all of them in a free-transform box, and dragging inside it
/// (or, once the box is gone, dragging a point) moves the whole mask (#290).
@@ -2272,3 +2306,41 @@ fn mask_marquee_selects_vertices() {
let sel = &h.state().session.state.selected_vertices;
assert_eq!(sel.len(), 2, "shift-marquee adds: {sel:?}");
}
/// A null's eye in the Timeline hides it in the Composition viewer, as in After Effects: its box
/// and handles go even while it is selected and a click on its box doesn't pick it; the eye back
/// on brings them back.
#[test]
fn null_eye_hides_it_in_the_viewer() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Nulls", "width": 640, "height": 360, "duration": 4})).unwrap();
let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [n]})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
h.run_steps(3);
let handle = format!("viewer.handle.{n}.0");
let eye = format!("timeline.layer.{n}.video");
assert!(h.state().auto.find(&handle).is_some(), "a shown null has handles");
// The eye is there for a null and turns its video off.
let at = rect(&h, &eye).center();
click(&mut h, at);
assert!(!h.state().session.active_comp().unwrap().layer(LayerId(n)).unwrap().switches.video);
h.run_steps(2);
assert!(h.state().auto.find(&handle).is_none(), "a hidden null has no handles");
// A click on its box doesn't pick it.
h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap();
h.run_steps(40); // A new click, not a double-click.
let centre = screen(&h, [320.0, 180.0]);
click(&mut h, centre);
assert!(h.state().session.state.selected_layers.is_empty());
// The eye back on: selectable again, with its handles.
h.run_steps(40);
let at = rect(&h, &eye).center();
click(&mut h, at);
assert!(h.state().session.active_comp().unwrap().layer(LayerId(n)).unwrap().switches.video);
h.run_steps(40);
click(&mut h, centre);
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(n)]);
h.run_steps(2);
assert!(h.state().auto.find(&handle).is_some(), "handles back with the eye on");
}
+1 -1
View File
@@ -77,7 +77,7 @@ are grouped by area; the issue tracker has the rest.
| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) |
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
| 20 | **Multi-monitor and video output** | #486: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), the rebuilt Effect Controls (26.5). Percentage guides (View ▸ Add Percentage Guide, Convert Guides to Percentage / Pixels) and effect colour labels (Effect Controls header bar and right-click Label menu, `effect.setLabel`) are in (#1095), built from the release notes' one-line descriptions and never compared with After Effects: the Timeline shows no effect label, and the labels' exact UI there is unknown | Users of the current version expect them | 10–20 h left | — · [ui-parity.md](ui-parity.md) |
| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), the rebuilt Effect Controls (26.5). Percentage guides (View ▸ Add Percentage Guide, Convert Guides to Percentage / Pixels) and effect colour labels (Effect Controls header bar and right-click Label menu, `effect.setLabel`) are in (#1095), built from the release notes' one-line descriptions and never compared with After Effects: the Timeline's effect rows wear the label as a colour bar on the left edge (#1509), but After Effects' exact UI there is unknown | Users of the current version expect them | 10–20 h left | — · [ui-parity.md](ui-parity.md) |
| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 and #611 closed | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) |
| 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #425 keyframe confusion (#355 and #428 closed) | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) |
+3 -3
View File
@@ -17,13 +17,13 @@ feel and platform differences, and the 26.x interface changes are not in yet.
|---|---|---:|---|
| Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) |
| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed |
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties |
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there) |
| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu |
| Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed |
| Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed |
| Numeric entry | Scrubbable values, typed entry with Tab / Shift+Tab, angle revolutions, Escape cancels, non-finite rejected | 85% | `ui_angles`, `ui_inline_edit` (#93, #141, #361); rename arrows on Linux (#488) |
| Effect Controls | After Effects' twirl-downs, angle dial, point crosshair, eyedropper, curves/levels editors, effect menu on right-click | 70% | `ui_effect_controls`, `ui_fx_editors`; 26.5 rebuilt this panel with icon controls and effect colour labels: labels exist (a colour bar on the effect header, right-click Label menu, `effect.setLabel`; not in the Timeline), the rebuilt controls are not matched |
| Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`, `ui_project_rows`; #227, #290, #407 fixed; a comp's Project row menu has Composition Settings… (#1058) |
| Effect Controls | After Effects' twirl-downs, angle dial, point crosshair, eyedropper, curves/levels editors, effect menu on right-click | 70% | `ui_effect_controls`, `ui_fx_editors`; 26.5 rebuilt this panel with icon controls and effect colour labels: labels exist (a colour bar on the effect header and on the effect's Timeline row, right-click Label menu, `effect.setLabel`), the rebuilt controls are not matched |
| Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`, `ui_project_rows`; #227, #290, #407 fixed; a comp's Project row menu has Composition Settings… (#1058); layer context menu has Layer Styles (#1611) |
| Keyboard shortcuts | After Effects' defaults; Keyboard Shortcuts editor that rebinds and saves sets | 75% | Shortcut editor in Settings; #467 (adding shortcuts on Linux), #428 and #336 are closed; no shortcuts for scripts |
| Drag and drop | Project → Timeline / viewer / New Comp / Render Queue; effects onto layers; files from the OS | 65% | `ui_drag_drop`; OS drops fail on Wayland (winit) and some Linux setups (#268); #552, #351 (multi-file drops) and #511 (crash dragging over the import dialog) are closed |
| Text editing on canvas | Point and paragraph text, per-character styles, Character / Paragraph panels, OpenType and variable axes | 70% | `ui_text_edit`, `ui_font_search`; paragraph alignment inverted (#316); toolbar font options (#550); font search by typing (#583) is closed; the font menus' variable-font filter (26.3) landed (#741) |
+2 -1
View File
@@ -413,7 +413,8 @@ no npm packages): load, the GPU path (WebGPU → GPU compositor, viewer on the G
in the viewer, `render.frame`, Render Queue GIF and PNG sequence (`.zip`) downloads, a background
render in a worker while measuring the page's event-loop gaps (must stay under 400 ms) and its
progress, the AudioContext starting on a (simulated) key press and a tone playing through it
with the meters moving, Save As and re-opening the saved project, persistence across a reload
with the meters moving, Save As and re-opening the saved project, a script opening a file from
browser storage and the footage check finding stored media, persistence across a reload
(session snapshot, a stored project, imported media, a setting, Open Recent) and an offline
reload through the service worker. It writes screenshots and `report.json`: