From 9ab33d975825f0ae5e2edfd5e31dd26bcb2e0122 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Tue, 13 Oct 2026 23:25:57 +0900 Subject: [PATCH 01/37] i18n(ja): the first rows of the Japanese effect catalog (#821) --- crates/ui-egui/src/i18n/effects.rs | 47 ++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 3ab11558..e49aba2c 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -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 From 4ddf5ffd6fcee4f52a0be584dd8c851254614e68 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 02:01:30 +1100 Subject: [PATCH 02/37] CC Particle World: motion blur smears particles across the shutter (#1195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the comp's and the layer's motion-blur switches on, the renderer hands effects the shutter (angle, phase, samples) — CC Particle World now uses it: eight to sixty-four sprite fields sampled across the shutter are averaged into the transfer, so fast particles draw the motion trails After Effects shows instead of one sharp frozen field. The GPU particle pass defers to the CPU while the shutter is open (catalog::gpu_supported), keeping the oracle identical on both backends. Tests: sim::particle_world_motion_blur_trails (blurred differs from sharp, lights more pixels, keeps alpha energy) and a catalog assertion for the CPU gate; effects 450 and gpu 212 lib tests pass; fmt and clippy -D warnings clean. --- crates/effects/src/catalog.rs | 11 ++++++ crates/effects/src/sim.rs | 65 +++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 82678eb8..0be2b04f 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -98,6 +98,9 @@ pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool { match id { "ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false, "ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false, + // #1195: CC Particle World's motion trails (the averaged shutter samples) render on the + // CPU kernel until the GPU particle pass supports them. + "ec.sim.ccparticleworld" if ctx.env.shutter.filter(|(a, _, _)| *a > 0.0).is_some() => return false, _ => {} } let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true }; @@ -199,6 +202,14 @@ mod tests { assert!(ok("ec.color.lumetri", &[])); assert!(!ok("ec.color.lumetri", &[("hslSecondary/correction/saturation", Value::Scalar(50.0))])); assert!(!ok("ec.noise.addgrain", &[("preset", Value::Enum(2))])); + // #1195: CC Particle World's motion trails (shutter samples) render on the CPU. + { + let params = ctx_with("ec.sim.ccparticleworld", &[]); + let env = crate::EffectEnv { frame_rate: 30.0, shutter: Some((180.0, -90.0, 16)), ..Default::default() }; + let ctx = crate::EffectCtx { params: ¶ms, time: 0.0, layer_size: [64.0, 64.0], seed: 0, adjustment: false, env }; + assert!(!super::gpu_supported("ec.sim.ccparticleworld", &ctx)); + } + assert!(ok("ec.sim.ccparticleworld", &[])); assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))])); assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))])); assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))])); diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 2c7b97d1..14146a9f 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -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 = 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::(); + 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); From e52ce29579732af9eca6fe1128051d5965bcd1c0 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:14:42 +1000 Subject: [PATCH 03/37] Preserve CLI template inputs when output is a hard link Compare device and inode on Linux alongside existing path checks. Cover template and values aliases with input-preservation and diagnostic checks. Sort the existing footage test's expected paths for temporary-directory portability. Co-authored-by: Codex --- apps/effectcraft-cli/src/template.rs | 15 ++++++++- apps/effectcraft-cli/tests/qa_template.rs | 41 +++++++++++++++++++++-- crates/engine/src/footage_check.rs | 3 +- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/effectcraft-cli/src/template.rs b/apps/effectcraft-cli/src/template.rs index f4da23cd..a4247f2a 100644 --- a/apps/effectcraft-cli/src/template.rs +++ b/apps/effectcraft-cli/src/template.rs @@ -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(target_os = "linux")] + { + 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}"))); } diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/qa_template.rs index ac67280e..ccab23be 100644 --- a/apps/effectcraft-cli/tests/qa_template.rs +++ b/apps/effectcraft-cli/tests/qa_template.rs @@ -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(target_os = "linux")] +#[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(target_os = "linux")] +#[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); +} diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index de14b06c..ca5ff3f7 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -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:?}"); From 76a3e7eefe62e6633c48480318a8b07527c47733 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 11:04:02 -0600 Subject: [PATCH 04/37] Masks: addVertex and setVertex fill in missing tangents of a loaded path (#1250) A path's vertices and its two tangent arrays deserialize independently, and nothing on load makes them agree. mask.new pads them, but a path read from a project file may carry fewer tangents than vertices. mask.addVertex checked its index against the vertices only and then inserted into both tangent arrays, and mask.setVertex indexed the tangent arrays after the same check, so either command panicked on such a path (Vec insert/index out of bounds). ShapePath::fit_tangents gives both arrays one entry per vertex (missing tangents become corners, extras are dropped), and edit_path_counted calls it on the value it edits, so every mask/shape-path vertex command works on a consistent path. Other keyframes of an animated path were already padded by apply_count_op when the vertex count changes. Tests: two regression tests save a project, cut every mask path's tangent arrays to one entry (static value and keyframes), reopen it through Project::from_json and run setVertex and addVertex. Both panic at mask.rs:209 without the fix. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/mask.rs | 3 ++ crates/engine/src/tests_timeline.rs | 60 +++++++++++++++++++++++++++++ crates/keyframe/src/value.rs | 10 +++++ 3 files changed, 73 insertions(+) diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs index 359de14d..8da0330a 100644 --- a/crates/engine/src/commands/mask.rs +++ b/crates/engine/src/commands/mask.rs @@ -132,6 +132,9 @@ fn edit_path_counted(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); diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 71a9943c..50f60014 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -331,6 +331,66 @@ 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 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(); diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index fa66711b..1dff4477 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -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(); From 36ebe2eada940f8f8fa3589fbdb41adcab4810f4 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:21:23 +1000 Subject: [PATCH 05/37] Honor duration rounding in timeToFrames Round nonzero duration frame counts away from zero while retaining absolute-time rounding, default argument selection and numeric coercion. Cover fractional durations, absolute controls, defaults and coercion. Sort expected paths in the existing footage test for temporary-directory portability. Co-authored-by: Codex --- crates/engine/src/footage_check.rs | 3 +- crates/expr/src/prelude.js | 4 +- crates/expr/src/tests.rs | 77 ++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index de14b06c..ca5ff3f7 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -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:?}"); diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 5f6dd015..e28f2cd6 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -261,7 +261,9 @@ 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; + if (isDuration && frames !== 0) return frames < 0 ? Math.floor(frames) : Math.ceil(frames); + return Math.floor(frames + 1e-6); } function framesToTime(frames, fps) { diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index d71db390..fd1cebbb 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -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(1e-10, 24, true)", 1.0), + ("timeToFrames(-1e-10, 24, true)", -1.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}"); + } +} From a15a7a4818d241ad4af2b35959af0f181bc72447 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:15:26 +0200 Subject: [PATCH 06/37] Null layers: the Video switch hides them in the Composition viewer, as in After Effects Every layer gets the Video switch (eye) in the Timeline (nulls, cameras and lights were excluded by an is_av() check). A selected layer whose eye is off no longer draws its box, handles, anchor or paths in the viewer. Alt-click on an eye and Layer > Switches > Hide Other Video now take nulls along. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 2 ++ crates/engine/src/commands/layer_menu.rs | 2 +- crates/engine/src/tests_menu_cmds.rs | 32 ++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 5 +++- crates/ui-egui/src/panels/viewer.rs | 4 ++- crates/ui-egui/tests/ui_viewer.rs | 38 ++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 7 files changed, 81 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index ca8aa865..b798493b 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -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; diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 7737b8af..83a1510a 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -115,7 +115,7 @@ fn hide_other_video(s: &mut Session, p: &Value) -> Result { 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; } } diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index ec63651f..942ea64b 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..b42f06c5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1992,8 +1992,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), diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a88..f6d9bcbb 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index e60226b1..e6097188 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -2270,3 +2270,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"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22da668d..81c34085 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -17,7 +17,7 @@ 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 | From 7a9ca5810a5e8392a88abbe09d33aa8289112a65 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 14:47:51 -0600 Subject: [PATCH 07/37] Scripts: a script can't confirm executing a file for itself (#1246) With Warn User When Executing Files on, file.executeFile asks the frontend first, and the frontend's answer re-runs it with `confirmed: true`. That flag was taken from the parameters whoever called, so a script could pass it itself (app.run('file.executeFile', {path, confirmed: true}), also inside engine.batch) and the file opened without the question, as long as the user had allowed file and network access. `confirmed` now counts only when the command isn't running for a script (Session::running_for_script, which #590 uses for the same reason and which stays set through nested batches). A script's request always gets the question; the user's answer from the frontend's form still opens the file; with the warning off nothing changes. The permission gate itself is unchanged. Tests: engine a_script_cannot_confirm_executing_a_file_for_itself (execute_for_script directly and through engine.batch; the frontend's confirmation; warning off) and the same through real JavaScript in the script crate (app.run, app.run('engine.batch'), File.execute()). Both fail on main. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/app_more.rs | 6 ++++-- crates/engine/src/tests_m145.rs | 28 ++++++++++++++++++++++++++ crates/script/src/tests.rs | 26 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 020880e8..1bb94952 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -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 { 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 { 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})); } diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index e52e3ffb..3c0423cd 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -349,6 +349,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] diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 2c821e6a..3eece60c 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -430,6 +430,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] From 9f02bd2a722f6d2b8328aa694b247a8fdcc12a9f Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Wed, 14 Oct 2026 00:59:11 +0200 Subject: [PATCH 08/37] #1368: dragging an effect lights up the layer it will land on Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/timeline.rs | 12 +++++- .../ui-egui/tests/ui_effect_drop_highlight.rs | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/ui_effect_drop_highlight.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..c63df878 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3330,8 +3330,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]}))); } diff --git a/crates/ui-egui/tests/ui_effect_drop_highlight.rs b/crates/ui-egui/tests/ui_effect_drop_highlight.rs new file mode 100644 index 00000000..6c0ede47 --- /dev/null +++ b/crates/ui-egui/tests/ui_effect_drop_highlight.rs @@ -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:?}"); +} From fc3643ad1754c4fb92ad6d8a77728c52310c1fba Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:43:53 +1000 Subject: [PATCH 09/37] Honor --empty when rendering from the CLI Keep ordinary --empty renders from loading the demo. Preserve explicit project, template, and demo selection, and cover them with real GIF renders. Sort the relative-footage test's expected paths consistently with its output. Co-authored-by: Codex --- apps/effectcraft-cli/src/main.rs | 9 +++-- apps/effectcraft-cli/tests/cli.rs | 55 ++++++++++++++++++++++++++++++ crates/engine/src/footage_check.rs | 3 +- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index fe523807..d36b3ffe 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -660,11 +660,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") { diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 62aefd5d..252f4ab6 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -491,3 +491,58 @@ fn render_bitrate_applies_to_vp9_webm() { assert!(high > low, "100 kbps: {low} bytes, 8000 kbps: {high} bytes"); let _ = std::fs::remove_dir_all(&root); } + +/// Render uses the same empty/project/demo selection as the other CLI commands. +#[test] +fn render_empty_does_not_load_the_demo() { + let root = tmp("render-empty"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let project = root.join("control.ecproj"); + let template = root.join("control.ectemplate"); + let project_path = project.to_str().unwrap(); + let template_path = template.to_str().unwrap(); + let build = r#" +var comp = app.project.items.addComp("Empty control", 64, 36, 1, 1, 30); +var solid = comp.layers.addSolid([0, 1, 0], "Green", 64, 36, 1); +solid.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Opacity"); +"#; + ok_json(&["script", "--eval", build, "--save-as", project_path]); + let params = json!({"comp": "Empty control", "path": template_path}).to_string(); + let exported = ok_json(&["exec", "essential.exportTemplate", ¶ms, "--project", project_path]); + assert_eq!(exported["controls"], 1, "{exported}"); + let empty = ok_json(&["info", "--empty"]); + assert!(empty["project"]["items"].as_array().unwrap().is_empty(), "{empty}"); + + let render = |options: &[&str], name: &str| { + let output = root.join(name); + assert!(!output.exists()); + let mut args = vec!["render", "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", output.to_str().unwrap()]; + args.extend_from_slice(options); + let reply = ok_json(&args); + assert_eq!(reply["rendered"].as_array().unwrap().len(), 1, "{reply}"); + assert_eq!(PathBuf::from(reply["rendered"][0]["output"].as_str().unwrap()), output); + let bytes = std::fs::read(&output).unwrap(); + assert!(bytes.len() >= 10 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")), "{reply}"); + let dimensions = (u16::from_le_bytes([bytes[6], bytes[7]]), u16::from_le_bytes([bytes[8], bytes[9]])); + (reply["rendered"][0]["comp"].clone(), dimensions) + }; + // Defaults still render the demo, and an explicit demo wins over --empty. + let demo = render(&[], "default.gif"); + assert_eq!(render(&["--demo", "--empty"], "demo.gif"), demo); + // A supplied project wins over both flags, while template mode also accepts --empty. + let project_result = render(&["--project", project_path, "--demo", "--empty"], "project.gif"); + assert_eq!(project_result, (json!("Empty control"), (16, 9))); + assert_eq!(render(&["--template", template_path, "--empty"], "template.gif").1, (16, 9)); + + let output = root.join("empty.gif"); + assert!(!output.exists()); + let (code, reply) = + run_json(&["render", "--empty", "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", output.to_str().unwrap()]); + let output_exists = output.exists(); + eprintln!("EMPTY_RENDER_OBSERVATION status={code} output_exists={output_exists} reply={reply}"); + 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); +} diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index de14b06c..ca5ff3f7 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -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:?}"); From ce437747eda192e41a861469c17bf68f8e3cb632 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 05:28:14 +0200 Subject: [PATCH 10/37] Expressions: accepting a function completion inserts the call parentheses (fixes #1393) --- crates/ui-egui/src/panels/expr_editor.rs | 77 +++++++++++++++++++++--- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index f800b1a4..f342d563 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -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 { + 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 { + 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\")"); + } +} From 0f0fef2b150dcbddd6f8dcc7246a472cdb2c80b2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 05:28:41 +0200 Subject: [PATCH 11/37] Contributors: add my real name --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 705feb25..20e4736f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -78,3 +78,7 @@ sync_github_name = true [people.moecr7] real_name = "Moein Saharkhiz" display_name = "onlymoein" + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +sync_github_name = false From e26dd21af742818ede2d489b1a7a1107f45c3e2e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 13:47:04 +0200 Subject: [PATCH 12/37] CLI: a failed render under run/exec exits 1, like render --queue (part of #1426) --- apps/effectcraft-cli/src/main.rs | 21 +++++++++++++++++++++ apps/effectcraft-cli/tests/cli.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index fe523807..b355e820 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -493,6 +493,9 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { }; let mut b = backend(args, true)?; let r = b.exec(&id, params)?; + if let Some(e) = failed_render(&id, &r) { + return Err(Failure::Error(e)); + } let saved = maybe_save(&mut b, args)?; emit(&with_saved(r, saved), json_out); } @@ -515,6 +518,9 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { }; i += 1; let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?; + if let Some(e) = failed_render(id, &r) { + return Err(Failure::Error(format!("{id}: {e}"))); + } if !json_out { emit(&r, false); } @@ -637,6 +643,21 @@ fn positional(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 { + if id != "renderQueue.render" { + return None; + } + let failed: Vec = 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) -> Value { match saved { None => v, diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 62aefd5d..9ae64a69 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -462,6 +462,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() { From 9846c7255d8d92ae9bb976949731acd1bbef7855 Mon Sep 17 00:00:00 2001 From: wixar172 Date: Sun, 11 Oct 2026 18:59:47 +0300 Subject: [PATCH 13/37] Shapes: a stroke's buffer is padded by the miter limit only where it has miter joins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit draw_bounds padded every stroke by width × miter limit / 2 on each side, joins or not. A straight 1080 px wide wipe used as a track matte became a 4326×6366 layer buffer (~440 MB as RGBA f32), made anew every frame while its trim animates, then cached on the CPU and uploaded to the GPU. With 12 frames exported in parallel a 16 GB Mac ran out of memory and froze. Now the miter limit applies only to miter-joined paths with a join (two or more segments, or closed); square caps reach √2 half widths, other strokes one; Taper and Wave keep the widest padding. Rendered pixels are unchanged. A full-res frame of the affected ad: 1.7 GB → 0.7 GB peak. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/shapes.rs | 17 +++++++++++++- crates/render/src/shapes/tests.rs | 37 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index aa46fa50..bd59ad83 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -542,10 +542,25 @@ fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option { 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). diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index 0a5d6893..3946fc9d 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -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); +} From ba37283aa4266af6555855fb82ef1dc538c90e85 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:14:18 +0700 Subject: [PATCH 14/37] fix(lottie): serialize Offset Paths miter limits as animatable values --- crates/lottie/src/shapes.rs | 6 ++--- crates/lottie/src/tests.rs | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index d7e3cc08..1283a249 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -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 set(im, &mut g, "amount", j.get("a"), &anim::to_scalar), "tw" => { diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index a677def1..b4638cb8 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -358,6 +358,51 @@ 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] From 790e2387d653668ca2b92bfefeee4a7f15eaa566 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:56:27 +0300 Subject: [PATCH 15/37] pdf: endchar takes its width by operand parity like the stems and rmoveto --- crates/pdf/src/cff.rs | 46 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs index 1c8c603e..59b49ec0 100644 --- a/crates/pdf/src/cff.rs +++ b/crates/pdf/src/cff.rs @@ -835,7 +835,9 @@ impl T2<'_> { } 11 => return, 14 => { - self.take_width(false); + // A width precedes the operands when the count is odd (5 with seac, 1 alone); + // the no-width counts (0 and 4) are even, like the stems' and rmoveto's. + self.take_width(true); if self.stack.len() >= 4 { let n = self.stack.len(); let s = &self.stack[n - 4..]; @@ -1048,4 +1050,46 @@ mod tests { assert!((p.area().abs() - 250_000.0).abs() < 1e-6, "{}", p.area()); assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0)); } + + /// 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 { + 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); + } } From bb2b7a6f870af76bdf71d50760412abb5e3a8680 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:02:49 +0300 Subject: [PATCH 16/37] engine: atomic writes use an exclusive per-call temporary, so parallel writers cannot mix or follow a planted symlink (#1233) --- crates/engine/src/config.rs | 38 ++++++++++++++------ crates/engine/src/tests_settings.rs | 54 +++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs index c333c973..68f403dd 100644 --- a/crates/engine/src/config.rs +++ b/crates/engine/src/config.rs @@ -164,10 +164,15 @@ impl ConfigStore for DirConfig { } } -/// The temporary file an atomic write of `path` goes through. +/// The temporary file an atomic write of `path` goes through. Unique per call (process id plus a +/// counter), so two writers saving the same destination never share a temporary — one cannot +/// truncate, rename or clean up another's — and there is no predictable name for a pre-existing +/// file or symlink to sit at. pub fn temp_path(path: &Path) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default(); - name.push(".tmp"); + name.push(format!(".{}-{}.tmp", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); path.with_file_name(name) } @@ -177,19 +182,30 @@ pub fn temp_path(path: &Path) -> PathBuf { pub fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { atomic_write_with(path, data, |tmp, data| { use std::io::Write; - let mut f = std::fs::File::create(tmp)?; + // Exclusive creation: an entry already at the temporary's name (a leftover from a crashed + // writer, or something planted there) is never opened, truncated or written through. + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)?; f.write_all(data)?; f.sync_all() }) } -/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write). A -/// failed write removes the temporary file and leaves `path` untouched. -pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&Path, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> { - let tmp = temp_path(path); - if let Err(e) = write(&tmp, data) { - let _ = std::fs::remove_file(&tmp); - return Err(e); +/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write). The +/// temporary belongs to this call alone: the writer must create it exclusively, and a failed +/// write removes only its own temporary and leaves `path` untouched. An `AlreadyExists` from the +/// writer means the name was taken (never opened by us): that entry is left alone and another +/// unique name is tried. +pub fn atomic_write_with(path: &Path, data: &[u8], mut write: impl FnMut(&Path, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> { + for _ in 0..16 { + let tmp = temp_path(path); + match write(&tmp, data) { + Ok(()) => return std::fs::rename(&tmp, path), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + } } - std::fs::rename(&tmp, path) + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{}: no free temporary name", path.display()))) } diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 458c886f..4ce956ca 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::json; -use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with, temp_path}; +use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with}; use crate::prefs::{PREFS_FILE, Prefs}; use crate::shortcuts::{DEFAULT_PRESET, Keymaps, normalize}; use crate::{Session, autosave}; @@ -560,7 +560,9 @@ fn atomic_write_survives_a_crash_mid_write() { }); assert!(r.is_err()); assert_eq!(std::fs::read(&f).unwrap(), b"version 1"); - assert!(!temp_path(&f).exists(), "the partial temp file is cleaned up"); + let leftovers: Vec = + std::fs::read_dir(&d).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).filter(|n| n != "p.ecproj").collect(); + assert!(leftovers.is_empty(), "the partial temporary is cleaned up: {leftovers:?}"); // A temp file left by a hard kill doesn't count as an auto-save. std::fs::write(d.join("p auto-save 1.ecproj.tmp"), b"junk").unwrap(); assert!(autosave::existing(&d, "p").is_empty()); @@ -569,6 +571,54 @@ fn atomic_write_survives_a_crash_mid_write() { let _ = std::fs::remove_dir_all(d); } +#[cfg(unix)] +#[test] +fn atomic_write_does_not_follow_a_planted_temporary_symlink() { + let d = tmp("atomic-link"); + let f = d.join("p.ecproj"); + let sentinel = d.join("sentinel.txt"); + std::fs::write(&sentinel, b"precious").unwrap(); + // Someone plants a symlink at the temporary name the write used to go through. + std::os::unix::fs::symlink(&sentinel, d.join("p.ecproj.tmp")).unwrap(); + atomic_write(&f, b"new").unwrap(); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"precious", "a symlink at the temporary name is never followed"); + assert_eq!(std::fs::read(&f).unwrap(), b"new"); + let _ = std::fs::remove_dir_all(d); +} + +#[cfg(unix)] +#[test] +fn atomic_writes_in_parallel_never_publish_a_mixture() { + use std::sync::{Arc, Barrier}; + let d = tmp("atomic-race"); + let f = d.join("p.ecproj"); + let a = vec![b'A'; 64]; + let b = vec![b'B'; 64]; + // Writer A writes the first half of its temporary, then waits while writer B runs against + // the same destination, and only then finishes. + let (half, published) = (Arc::new(Barrier::new(2)), Arc::new(Barrier::new(2))); + let (f2, a2) = (f.clone(), a.clone()); + let (half2, published2) = (half.clone(), published.clone()); + let t = std::thread::spawn(move || { + atomic_write_with(&f2, &a2, |tmp, data| { + use std::io::Write; + let mut fh = std::fs::File::create(tmp)?; + fh.write_all(&data[..32])?; + half2.wait(); + published2.wait(); + fh.write_all(&data[32..])?; + fh.sync_all() + }) + }); + half.wait(); + atomic_write(&f, &b).unwrap(); + published.wait(); + let _ = t.join(); + let got = std::fs::read(&f).unwrap(); + assert!(got == a || got == b, "the destination holds one complete write, never a mixture: {}", String::from_utf8_lossy(&got)); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn autosave_rotates_through_max_versions() { let d = tmp("rotate"); From 2603a6f0e0c7d0b439086e80f6c9f3760ad7e50e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:30:54 +0200 Subject: [PATCH 17/37] Export: render in parallel once the GPU stops for good --- crates/export/src/pipeline.rs | 64 ++++++++++++++++++++++++++++++++--- crates/gpu/src/lib.rs | 4 +++ crates/render/src/lib.rs | 6 ++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index a0093f4c..8dcee03e 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -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( ks: Vec, cap: Option, render: impl Fn(u64) -> I + Sync + Send, encode: impl Fn(u64, I) -> T + Sync + Send, +) -> Vec { + 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( + ks: Vec, + cap: Option, + capped: impl Fn() -> bool, + render: impl Fn(u64) -> I + Sync + Send, + encode: impl Fn(u64, I) -> T + Sync + Send, ) -> Vec { 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::>()); + 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 = (0..40).map(|k| k * 10).collect(); diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 252145c1..1cff8be0 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -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 { let img = self.render(r, comp, t); if img.is_none() { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6..4b215a23 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -456,6 +456,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 From 38d6fb2ed269743a59bdf1808135cfd7e5d29c06 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 15 Oct 2026 01:42:17 +0200 Subject: [PATCH 18/37] #1509: Timeline effect rows show the effect's colour label Problem: effect colour labels (26.5) were drawn only on the Effect Controls header; the Timeline's effect rows looked unlabelled. Fix: an effect group row in the Timeline paints the label colour as a 4 px bar on its left edge (same look as the Effect Controls header) and registers `timeline.group..label` with the label's name for agents. Docs: gaps.md row 21 and ui-parity.md updated. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/timeline.rs | 10 +++++ .../ui-egui/tests/ui_timeline_effect_label.rs | 45 +++++++++++++++++++ docs/gaps.md | 2 +- docs/ui-parity.md | 2 +- 4 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/ui_timeline_effect_label.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..fe3b1d62 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2400,6 +2400,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)); diff --git a/crates/ui-egui/tests/ui_timeline_effect_label.rs b/crates/ui-egui/tests/ui_timeline_effect_label.rs new file mode 100644 index 00000000..deeee9e5 --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_effect_label.rs @@ -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"); +} diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fb..8a26bda6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22da668d..ea17bcd4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -22,7 +22,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. | 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 | +| 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) | | 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 | From 217f0ffd59acdd7a5e073c21c2e25148129cc375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:27:41 +0200 Subject: [PATCH 19/37] GPU tests: print why the GPU didn't start, and fail with EFFECTCRAFT_REQUIRE_GPU=1 instead of skipping --- crates/gpu/src/tests.rs | 29 ++++++++++++++++++++--------- crates/gpu/src/tests_adv3d.rs | 10 ++++++---- crates/gpu/src/tests_deferred.rs | 20 ++++++++++---------- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 696402d6..84f64a6d 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -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> = OnceLock::new(); + static G: OnceLock> = 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(tests: &str, g: Result) -> Option { + 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). diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs index ee99c5c0..869c60aa 100644 --- a/crates/gpu/src/tests_adv3d.rs +++ b/crates/gpu/src/tests_adv3d.rs @@ -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 { 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)) { diff --git a/crates/gpu/src/tests_deferred.rs b/crates/gpu/src/tests_deferred.rs index e798bd8f..b6e43b55 100644 --- a/crates/gpu/src/tests_deferred.rs +++ b/crates/gpu/src/tests_deferred.rs @@ -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> = OnceLock::new(); + static G: OnceLock> = 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()))) } From 749e810174e9f10785f94532b8769d2ad6c2a893 Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Thu, 15 Oct 2026 00:26:44 +0200 Subject: [PATCH 20/37] Effects: Black & White keeps faint colour noise in shadows faint (#1463) Black & White offset each pixel's luminance by (hue weight - 0.5) times its HSL saturation. HSL saturation is near 1 for any tinted near-black pixel, so the code or two of colour noise in compressed footage's shadows came out as bright and black blocks, and a 32-bpc overbright such as (1.5, 0.5, 0.5) divided by zero and went black. The gray is now the pixel's smallest channel plus its chroma (largest - smallest channel) times the same hue-interpolated slider weight. Neutral pixels keep their value, a pure hue becomes its slider's percentage (Reds 40% turns pure red into 40% gray), noise moves the gray by no more than its own size, and nothing divides. The CPU effect and the GPU kernel change the same way. --- crates/effects/src/color_fx.rs | 73 ++++++++++++++++++++++++++-- crates/gpu/src/shaders/fx_color.wgsl | 5 +- crates/gpu/src/tests_fx_color.rs | 1 + 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index e431ff22..cf6698fe 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -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]; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index c7d6010e..6b8641dd 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -154,8 +154,9 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { 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(gray); if (P.u[0].y != 0u) { o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0)); diff --git a/crates/gpu/src/tests_fx_color.rs b/crates/gpu/src/tests_fx_color.rs index 28e80278..a3fe3f65 100644 --- a/crates/gpu/src/tests_fx_color.rs +++ b/crates/gpu/src/tests_fx_color.rs @@ -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))]); From 9635321f1a11a16ec094dc3685afc938ff03953e Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:42:50 +0300 Subject: [PATCH 21/37] fix(script): per-key methods act on keys outside the active comp (#1531) `exec_on_keys` put the named keys in the key selection, then `exec` made the keys' comp active. Since comps keep their own selections (#1037), activating the comp stored the injected keys with the comp that had been active and brought back the target comp's own key selection, usually empty, so `keys.interpolation` and `keys.velocity` had nothing to act on: setInterpolationTypeAtKey and setTemporalEaseAtKey were silently ignored in any comp other than the active one. The keys' comp is now made active before the keys are selected, and the comp that was active comes back afterwards, as `exec` does for its own target (now `leave_comp`). --- crates/script/src/runtime.rs | 22 ++++++++++++++++------ crates/script/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index b94bd80f..5835f6b6 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -157,11 +157,16 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result { 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) { + 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 { } } } - 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 1.0), "{v}"); +} + /// #808: sourcePointToComp and compPointToSource follow the layer's transform. #[test] fn source_point_conversions_follow_the_layer_transform() { From a8b25043a9d7b48a0f8337afbceecd8b61c01678 Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:52:58 +0300 Subject: [PATCH 22/37] fix(viewer): Shift-click with the Pen draws a straight segment (#1544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pen_press` placed each new vertex where it was clicked (after snapping) whatever the modifiers, so Shift did nothing and a vertical, horizontal or 45° segment had to be lined up by eye. With Shift held, the new vertex now moves onto the nearest line at a multiple of 45° through the vertex before it, as far along that line as the click lies (`constrain_45`). Masks and shape paths both go through this path. --- crates/ui-egui/src/panels/viewer.rs | 32 +++++++++++++++++++++++++++ crates/ui-egui/tests/ui_viewer.rs | 34 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a88..98d86568 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2221,6 +2221,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})) { @@ -2282,6 +2288,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: only active AV layers activate it, even when hidden or locked. pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { @@ -2684,6 +2704,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°. diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index e60226b1..4dd3c2a3 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -761,6 +761,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). From 400379fd20594ca0b8e64a29b71ada8abf1af55d Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:05:32 +0300 Subject: [PATCH 23/37] pdf: a soft mask at an unsupported bit depth is ignored, not fatal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mask_image read /BitsPerComponent from the attacker-controlled /SMask dict with no validation and fed it straight into 1u32 << bpc — the main image path rejects anything but 1|2|4|8|16 before its own identical shift, the mask path did not. /BitsPerComponent 32 panics in debug and test builds and wraps the shift in release, mistranslating every sample into alpha. Mirror decode()'s depth check: an unsupported mask depth means no usable mask, so the image draws with the mask treated as absent. Regression test: smask_with_absurd_bpc_is_ignored_not_fatal — panics at crates/pdf/src/image.rs:184 on main, passes with the check. --- crates/pdf/src/image.rs | 5 +++++ crates/pdf/src/tests_import.rs | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/pdf/src/image.rs b/crates/pdf/src/image.rs index 5e6c49a5..1b39f062 100644 --- a/crates/pdf/src/image.rs +++ b/crates/pdf/src/image.rs @@ -181,6 +181,11 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, 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); diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index add447e3..064b5614 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -263,6 +263,31 @@ fn hostile_structures_return_errors_or_draw_nothing() { assert!(!doc.skipped.is_empty()); } +#[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\ From 228ceb308e49464030ff2646658fa65d108ca557 Mon Sep 17 00:00:00 2001 From: rahal Date: Thu, 15 Oct 2026 16:15:41 +0100 Subject: [PATCH 24/37] fix(web): scripts can open files in browser storage; stored footage isn't reported missing In the browser editor a .jsx script couldn't open any file, even one the page holds next to the project: File.open("r") returned false with "File not found" and File.exists was false. The footage check after File > Open also marked imported media as missing. Both asked whether a file exists on the local disk (std::fs), which has no files on wasm32: - scripts read files through Session.services but checked existence with std::fs (File.open's access check, File.exists, Folder.exists); they now ask services.exists / services.is_dir, after the folder check; - WebServices kept the default exists (std::fs); it now looks in the file table. Desktop and CLI are unchanged (FsServices keeps the std::fs defaults). Tests: script tests_services (a host with in-memory files), web smoke step hostFiles (a script reads a stored file, footage check finds stored media). --- apps/effectcraft-web/src/files.rs | 4 +++ apps/effectcraft-web/tests/smoke.mjs | 12 +++++++ crates/script/src/lib.rs | 2 ++ crates/script/src/runtime.rs | 7 +++-- crates/script/src/tests_services.rs | 47 ++++++++++++++++++++++++++++ docs/web.md | 3 +- 6 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 crates/script/src/tests_services.rs diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs index 76b79912..3f3f3a99 100644 --- a/apps/effectcraft-web/src/files.rs +++ b/apps/effectcraft-web/src/files.rs @@ -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 } diff --git a/apps/effectcraft-web/tests/smoke.mjs b/apps/effectcraft-web/tests/smoke.mjs index ee272a67..3a6c1337 100644 --- a/apps/effectcraft-web/tests/smoke.mjs +++ b/apps/effectcraft-web/tests/smoke.mjs @@ -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 diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index 067b85fd..f2125de9 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -107,4 +107,6 @@ mod tests_ui_more; #[cfg(test)] mod tests_evalfile; #[cfg(test)] +mod tests_services; +#[cfg(test)] mod tests_ui_mouse; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index b94bd80f..21d4f706 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -380,11 +380,14 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult 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)?; diff --git a/crates/script/src/tests_services.rs b/crates/script/src/tests_services.rs new file mode 100644 index 00000000..c0ecfab4 --- /dev/null +++ b/crates/script/src/tests_services.rs @@ -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>>); + +impl Services for Mem { + fn read_file(&self, path: &str) -> std::io::Result> { + 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()]); +} diff --git a/docs/web.md b/docs/web.md index e73f7a89..23ee1da1 100644 --- a/docs/web.md +++ b/docs/web.md @@ -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`: From f9dddfeb1010a4261e5f3a064efece431f02c18b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 08:53:04 +0200 Subject: [PATCH 25/37] fix(graph): speed graph Fit Selection frames the curve between the keys (fixes #1617) --- crates/ui-egui/src/panels/graph.rs | 46 ++++++++++++++++++++-------- crates/ui-egui/tests/ui_keyframes.rs | 36 ++++++++++++++++++++++ 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index fc6d40e8..0b372c0a 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -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> { 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::(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. diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 4b5d1338..cbe0b387 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -466,3 +466,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"); +} From e228f712a9838915e7b5c68e61d7dd2222303c4f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 10:23:00 +0200 Subject: [PATCH 26/37] Window menu: choosing a checked panel closes it, as in After Effects (fixes #1630) --- crates/ui-egui/src/menus.rs | 15 ++++++++++++++- crates/ui-egui/src/native_menu.rs | 18 ++++++++++++++++++ crates/ui-egui/tests/ui_panels.rs | 20 ++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8b5fd1c3..ba765739 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1521,6 +1521,19 @@ pub(crate) fn panel_shown(app: &EffectcraftApp, p: PanelKind) -> bool { } } +/// The command a menu entry runs when chosen from a menu: Window ▸ 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 { let v = &app.ui.viewer; @@ -1990,7 +2003,7 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { }); }); crate::menu_keys::handle(&ctx, &nav); - if let Some((id, params)) = clicked + if let Some((id, params)) = clicked.map(|(id, params)| menu_choice(app, id, params)) && let Err(e) = invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params }) { app.ui.status = e; diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 3e098449..ad30ded5 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -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(); diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index 15439a02..3cadafce 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -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(); From 00c1eb54a8ad21d9807b7b805529116dcc384196 Mon Sep 17 00:00:00 2001 From: krinry <121806532+krinry@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:01:34 +0530 Subject: [PATCH 27/37] #1611: layer context menu has Layer Styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Layer Styles submenu to the Timeline and Composition viewer layer context menu, matching After Effects parity and the application Layer ▸ Layer Styles menu. - Adds Layer Styles submenu between Blending Mode and Effects - Exposes Layer Style Options..., Convert to Editable Styles, Show All, Remove All, and the 9 individual styles - Translates Layer Styles across all 7 UI catalogs - Adds regression test verifying submenu presence, position, and action invocation --- crates/ui-egui/src/i18n/ui.rs | 7 +++++ crates/ui-egui/src/panels/timeline.rs | 26 +++++++++++++++++++ .../ui-egui/tests/ui_blank_context_menus.rs | 25 ++++++++++++++++++ docs/ui-parity.md | 2 +- 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index bca5f66c..2543081b 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -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: {}"), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 2de97a4b..ad8b17d1 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3935,6 +3935,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 +3950,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)) { diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs index 96789fe7..9b06add9 100644 --- a/crates/ui-egui/tests/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -260,6 +260,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] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22da668d..1a201232 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -23,7 +23,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. | 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) | +| 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) | From da23c9a650951eb8b9949b7c76f6270228e5c456 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 18:38:08 +0700 Subject: [PATCH 28/37] i18n(ja): the effect catalog test guards Japanese as a partly filled table Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/effects.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index e49aba2c..5c6d09ba 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -564,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 { @@ -649,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"); } From cc138fcb90366ded87ed5439b41e5a1a3f862394 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 18:38:56 +0700 Subject: [PATCH 29/37] Expressions: timeToFrames keeps whole durations whole under float error Co-Authored-By: Claude Opus 5.5 --- crates/expr/src/prelude.js | 3 ++- crates/expr/src/tests.rs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index e28f2cd6..a1dff678 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -262,7 +262,8 @@ function timeToFrames(t, fps, isDuration) { if (t === undefined) t = time + thisComp.displayStartTime; if (fps === undefined) fps = 1 / __S.fd; var frames = t * fps; - if (isDuration && frames !== 0) return frames < 0 ? Math.floor(frames) : Math.ceil(frames); + // 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); } diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index fd1cebbb..8a4db485 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1350,8 +1350,8 @@ fn time_to_frames_preserves_coercion_and_small_durations() { ("timeToFrames(null, 24, true)", 0.0), ("timeToFrames(0.05, null, true)", 0.0), ("timeToFrames(0.05, 0, true)", 0.0), - ("timeToFrames(1e-10, 24, true)", 1.0), - ("timeToFrames(-1e-10, 24, true)", -1.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), From ced1857637bbe97fcb90c806a6b4e5f97e29f2c9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 18:39:03 +0700 Subject: [PATCH 30/37] Templates: the hard-link check covers every Unix, not only Linux Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/src/template.rs | 2 +- apps/effectcraft-cli/tests/qa_template.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/effectcraft-cli/src/template.rs b/apps/effectcraft-cli/src/template.rs index a4247f2a..f84303dd 100644 --- a/apps/effectcraft-cli/src/template.rs +++ b/apps/effectcraft-cli/src/template.rs @@ -40,7 +40,7 @@ pub(super) fn validate(cmd: &str, args: &Args) -> Result<(), Failure> { if path == output { return true; } - #[cfg(target_os = "linux")] + #[cfg(unix)] { use std::os::unix::fs::MetadataExt; // Distinct canonical paths can still name the same file through hard links. diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/qa_template.rs index ccab23be..2765b163 100644 --- a/apps/effectcraft-cli/tests/qa_template.rs +++ b/apps/effectcraft-cli/tests/qa_template.rs @@ -654,7 +654,7 @@ fn template_missing_output_does_not_replace_extracted_media() { let _ = std::fs::remove_dir_all(&dir); } -#[cfg(target_os = "linux")] +#[cfg(unix)] #[test] fn template_frame_output_cannot_alias_its_template_through_a_hard_link() { use std::os::unix::fs::MetadataExt; @@ -672,7 +672,7 @@ fn template_frame_output_cannot_alias_its_template_through_a_hard_link() { let _ = std::fs::remove_dir_all(&dir); } -#[cfg(target_os = "linux")] +#[cfg(unix)] #[test] fn template_frame_output_cannot_alias_its_values_through_a_hard_link() { use std::os::unix::fs::MetadataExt; From 3725c321a6e12f825e1df9ce9948a8cc8d08754e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 18:39:06 +0700 Subject: [PATCH 31/37] CLI: drop a debug print from the --empty render test Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/tests/cli.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 252f4ab6..b1912d55 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -540,7 +540,6 @@ solid.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraph 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(); - eprintln!("EMPTY_RENDER_OBSERVATION status={code} output_exists={output_exists} reply={reply}"); 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}"); From 0fbf4e887eafbec649ec4dd91e10d98f642b06f5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 18:39:10 +0700 Subject: [PATCH 32/37] pdf: leave the atomic-write test to its own PR Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_settings.rs | 36 ----------------------------- 1 file changed, 36 deletions(-) diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index ee4c6ebe..65e39b46 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -629,42 +629,6 @@ fn atomic_write_survives_a_crash_mid_write() { let _ = std::fs::remove_dir_all(d); } -/// A writer that is paused mid-temporary while another writer fully publishes to the same -/// destination must not let either one publish a mixture: the bounded, exclusively-created -/// temporaries keep the two writes apart (#1233). Unlike -/// `concurrent_atomic_writes_each_publish_a_whole_file`, this pins the interleaving -/// deterministically instead of sampling it under real contention. -#[test] -fn atomic_writes_in_parallel_never_publish_a_mixture() { - use std::sync::{Arc, Barrier}; - let d = tmp("atomic-race"); - let f = d.join("p.ecproj"); - let a = vec![b'A'; 64]; - let b = vec![b'B'; 64]; - // Writer A writes the first half of its temporary, then waits while writer B fully publishes - // against the same destination, and only then finishes. - let (half, published) = (Arc::new(Barrier::new(2)), Arc::new(Barrier::new(2))); - let (f2, a2) = (f.clone(), a.clone()); - let (half2, published2) = (half.clone(), published.clone()); - let t = std::thread::spawn(move || { - atomic_write_with(&f2, &a2, |fh, data| { - use std::io::Write; - fh.write_all(&data[..32])?; - half2.wait(); - published2.wait(); - fh.write_all(&data[32..])?; - fh.sync_all() - }) - }); - half.wait(); - atomic_write(&f, &b).unwrap(); - published.wait(); - assert!(t.join().unwrap().is_ok(), "the paused writer still publishes"); - let got = std::fs::read(&f).unwrap(); - assert!(got == a || got == b, "the destination holds one complete write, never a mixture: {}", String::from_utf8_lossy(&got)); - let _ = std::fs::remove_dir_all(d); -} - #[test] fn autosave_rotates_through_max_versions() { let d = tmp("rotate"); From 1bcf1a9f61deccbe0d6ad191fc7198dc89680f35 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 18:39:16 +0700 Subject: [PATCH 33/37] Contributors: keep the people.toml entry that landed with #1608 Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 20e4736f..705feb25 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -78,7 +78,3 @@ sync_github_name = true [people.moecr7] real_name = "Moein Saharkhiz" display_name = "onlymoein" - -[people.marcoarnulfo] -real_name = "Marco Arnulfo" -sync_github_name = false From 2cf08555a6555367b42409b7416ee9918a38e98a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 18:39:25 +0700 Subject: [PATCH 34/37] Lottie: rustfmt the Offset Paths test Co-Authored-By: Claude Opus 5.5 --- crates/lottie/src/tests.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index b4638cb8..19aac0f9 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -363,10 +363,7 @@ 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)), - ]; + 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); @@ -396,10 +393,17 @@ fn offset_paths_miter_uses_animatable_lottie_value() { 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(); + 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)); } From c0b505b41d635b4376b5d97325a5357f54ffe717 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 18:40:14 +0700 Subject: [PATCH 35/37] Masks: deleting vertices fills in missing tangents first, like add and set (#1250) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/mask.rs | 1 + crates/engine/src/tests_timeline.rs | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs index 8da0330a..778dfb54 100644 --- a/crates/engine/src/commands/mask.rs +++ b/crates/engine/src/commands/mask.rs @@ -290,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); diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 50f60014..72b35964 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -373,6 +373,17 @@ fn vertex_commands_fill_in_missing_tangents_of_a_loaded_path() { 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(); From 4d8f88302d328207ae41acf278b3bea9b45a79a0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 18:53:59 +0700 Subject: [PATCH 36/37] Scripts: the project-folder check compares Windows paths in one form normalize() canonicalizes whatever part of a path exists, which on Windows adds the verbatim prefix to some paths and not others, so a file under a folder that isn't on disk failed the project-folder check against an existing project folder. The check failed closed (access refused); it now drops the prefix from canonical drive paths so both sides compare. Co-Authored-By: Claude Opus 5.5 --- crates/script/src/runtime.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index ce51c573..5c8c11f9 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -336,9 +336,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 @@ -834,4 +844,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")); + } } From a8bbcd3aa23b185ea1f6d04da23e907a289cd0e4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 18:56:18 +0700 Subject: [PATCH 37/37] Scripts: the browser-storage test runs where /browser paths are absolute Co-Authored-By: Claude Opus 5.5 --- crates/script/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index f2125de9..19904c2a 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -106,7 +106,9 @@ mod tests_ui_more; // #760: `$.evalFile` and `BridgeTalk`. #[cfg(test)] mod tests_evalfile; -#[cfg(test)] +// `/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;