From fb715d9015503d22b1da8ac511857e6fd485d35f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 00:32:56 +1100 Subject: [PATCH 01/50] Render: drop the blanket layer cover from effect stacks; effects pay only what they need (#1205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #659 every text or shape layer with an enabled video effect grew its effect buffer to the comp-sized layer rectangle before the stack ran, so glow and blur — which only need their own margins — allocated a 1920x1080 RGBA32F texture per layer per frame in flight (~5 GB peak VRAM in parallel export, measured on an RTX 3090). Effects that actually place pixels outside the painted content already grow the buffer themselves by exactly what they need (CC Tiler, CC Smear, CC Mosaic sharp via layer_cover_pad / Buf::pad), on CPU and GPU. The blanket target.cover(layer_size) in Renderer::run_effects_on, the FxTarget::cover trait method with its CPU default, and the GPU GpuTarget::cover texture allocation are removed; Buf::cover/cover_plan stay as the effects-crate helpers they were designed as. The #659 regression tests (tiling across the layer region, centred-shape edge pinning) and the render/effects/gpu suites pass unchanged. --- crates/gpu/src/effects.rs | 15 --------------- crates/render/src/lib.rs | 24 +++++------------------- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab71..426931d78 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -145,21 +145,6 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { let out = f(Buf { img, offset: b.offset, scale: b.scale }); self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); } - - /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no - /// readback. A region the device can't allocate leaves the buffer as it is. - fn cover(&mut self, size: [f64; 2]) { - let Some(b) = self.b.take() else { return }; - let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) { - Some((dx, dy, w, h)) if self.e.g.fits(w, h) => { - let img = self.e.image(w, h); - self.e.copy_into(&b.img, &img, dx, dy); - GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale } - } - _ => b, - }; - self.b = Some(out); - } } fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..d9c87af9f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -490,16 +490,6 @@ pub trait FxTarget { fn gpu(&mut self, steps: &[FxStep]) -> bool; /// Run one CPU effect: `f` maps the buffer. fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); - /// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own - /// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`]. - /// Authored buffers (text, shapes) only span their painted content; this gives the stack - /// the room a sized source's buffer already has (#659). - fn cover(&mut self, size: [f64; 2]) { - self.cpu(&mut |mut buf| { - buf.cover(size); - buf - }); - } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -1132,15 +1122,11 @@ impl<'a> Renderer<'a> { let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; - // #659: an authored layer's buffer spans only its painted content while the stack works - // on the layer's whole region (`layer_size`): give the buffer the room a sized source - // already has, so tiling or displacement can place pixels outside the content instead - // of losing them at its edge. Only when something will actually run, and not for an - // adjustment layer, whose buffer is the comp below and already spans it. - if !adjustment - && fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) { - target.cover(layer_size); - } + // #659 / #1205: an authored layer's buffer spans only its painted content. Effects that + // place pixels outside it grow the buffer themselves, by exactly what they need + // (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of + // the whole layer rectangle on every stack — which paid a comp-sized texture per layer, + // per frame in flight, for glow and blur that never use the extra room. let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) { From 9a202b133ae15a232090e2468143a351e8c1e7ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:53:10 +0200 Subject: [PATCH 02/50] Render: effect stacks keep the cover's pixel-grid phase, only known-margin stacks skip the cover, and displacement and Repeat Edge Pixels get the room the cover gave them (#1205) --- crates/effects/src/distort2.rs | 3 ++ crates/effects/src/lib.rs | 53 ++++++++++++++++++++++++++++++++++ crates/effects/src/misc.rs | 6 ++++ crates/gpu/src/effects.rs | 46 +++++++++++++++++++++++++++++ crates/gpu/src/fx_distort.rs | 3 ++ crates/render/src/lib.rs | 38 +++++++++++++++++++++++- 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 10ec59735..4f09b95e2 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -93,6 +93,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(amount.abs().ceil() as u32 + 1); + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1)); } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..a968f1aad 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -286,6 +286,32 @@ impl Buf { self.offset[0] += origin[0] * self.scale; self.offset[1] += origin[1] * self.scale; } + /// Grow by `left` and `top` transparent pixels on those sides only. + pub fn grow_left_top(&mut self, left: u32, top: u32) { + if left == 0 && top == 0 { + return; + } + let src = std::mem::replace(&mut self.img, Image::new(0, 0)); + let (w, h) = (src.width + left, src.height + top); + let mut out = Image::new(w, h); + let sw = src.width as usize; + for y in 0..src.height as usize { + let d = (y + top as usize) * w as usize + left as usize; + out.data[d..d + sw].copy_from_slice(&src.data[y * sw..(y + 1) * sw]); + } + self.img = out; + self.offset[0] += left as f64; + self.offset[1] += top as f64; + } + /// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of + /// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled + /// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at + /// its content's corner instead of the cover's resamples to different pixels (#1205). + pub fn align(&mut self, size: [f64; 2]) { + if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) { + self.grow_left_top(l, t); + } + } /// Grow by `pad` transparent pixels on every side. pub fn pad(&mut self, pad: u32) { if pad == 0 { @@ -301,6 +327,33 @@ impl Buf { /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer /// already covers that region unchanged (footage, solids, the comp below an adjustment). +/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge +/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these +/// skips the renderer's cover of the whole layer rectangle (#1205). +pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"]; + +/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels +/// of the resampling pyramid used for downscaled layers. +pub const GRID_ALIGN: u32 = 64; + +/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None` +/// when there is nothing to add. +pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> { + let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?; + let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN); + (l != 0 || t != 0).then_some((l, t)) +} + +/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment +/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so +/// the grid phase stays. 0 once the buffer covers the layer. +pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 { + if ctx.adjustment { + return 0; + } + want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN +} + pub fn cover_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> { if scale <= 0.0 || !scale.is_finite() || !offset[0].is_finite() || !offset[1].is_finite() || !size[0].is_finite() || !size[1].is_finite() { return None; diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index c119118db..fed4ce45c 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } b.img = gaussian_blur(&b.img, s * kx, s * ky, repeat || ctx.adjustment); b @@ -49,6 +52,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((r * it) as u32 + 1); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } b.img = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, repeat || ctx.adjustment); b diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 426931d78..4bf503940 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -23,6 +23,20 @@ pub(crate) struct GBuf { } impl GBuf { + /// [`effectcraft_effects::Buf::cover`] on the device; `None` when it can't hold it. + pub fn cover(&mut self, e: &mut Enc, size: [f64; 2]) -> Option<()> { + let Some((dx, dy, w, h)) = effectcraft_effects::cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return Some(()) }; + if !e.g.fits(w, h) { + return None; + } + let out = e.image(w, h); + e.copy_into(&self.img, &out, dx, dy); + self.img = out; + self.offset[0] += dx as f64; + self.offset[1] += dy as f64; + Some(()) + } + pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } @@ -145,6 +159,32 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { let out = f(Buf { img, offset: b.offset, scale: b.scale }); self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); } + + /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no + /// readback. A region the device can't allocate leaves the buffer as it is. + fn cover(&mut self, size: [f64; 2]) { + let Some(mut b) = self.b.take() else { return }; + let keep = (b.img.clone(), b.offset); + if b.cover(self.e, size).is_none() { + (b.img, b.offset) = keep; + } + self.b = Some(b); + } + + /// [`effectcraft_effects::Buf::align`] on the device: a texture grown left/top, the old one + /// copied in. A size the device can't allocate leaves the buffer as it is. + fn align(&mut self, size: [f64; 2]) { + let Some(b) = self.b.take() else { return }; + let out = match effectcraft_effects::align_plan(b.offset, b.scale, size, b.img.width, b.img.height) { + Some((l, t)) if self.e.g.fits(b.img.width + l, b.img.height + t) => { + let img = self.e.image(b.img.width + l, b.img.height + t); + self.e.copy_into(&b.img, &img, l, t); + GBuf { img, offset: [b.offset[0] + l as f64, b.offset[1] + t as f64], scale: b.scale } + } + _ => b, + }; + self.b = Some(out); + } } fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { @@ -249,6 +289,9 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (s * 3.0).ceil() as u32)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = gaussian_blur(e, &b.img, s * kx, s * ky, repeat || ctx.adjustment); Some(b) @@ -264,6 +307,9 @@ fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (r * it) as u32 + 1)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = box_passes(e, &b.img, &vec![r * kx as usize; it], &vec![r * ky as usize; it], repeat || ctx.adjustment); Some(b) diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index a62ad6831..e4e17c64c 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -177,6 +177,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(e, amount.abs().ceil() as u32 + 1)?; + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(e, effectcraft_effects::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1))?; } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d9c87af9f..041052b8e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -490,6 +490,24 @@ pub trait FxTarget { fn gpu(&mut self, steps: &[FxStep]) -> bool; /// Run one CPU effect: `f` maps the buffer. fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); + /// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own + /// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`]. + /// Authored buffers (text, shapes) only span their painted content; this gives the stack + /// the room a sized source's buffer already has (#659). + fn cover(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.cover(size); + buf + }); + } + /// Give the buffer the pixel-grid phase the layer rectangle's cover would + /// ([`effectcraft_effects::Buf::align`]): grows left/top by less than 64 pixels. + fn align(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.align(size); + buf + }); + } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -1126,7 +1144,25 @@ impl<'a> Renderer<'a> { // place pixels outside it grow the buffer themselves, by exactly what they need // (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of // the whole layer rectangle on every stack — which paid a comp-sized texture per layer, - // per frame in flight, for glow and blur that never use the extra room. + // per frame in flight, for glow and blur that never use the extra room. The buffer still + // takes the cover's pixel-grid phase: a downscaled layer resamples along that grid. + // Effects not known to stay inside their own margins (Twirl, Bulge, Transform, ...) still + // get the cover: they move content past the painted bounds without growing the buffer. + let video = || { + fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind { + GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)) => { + Some(effect.as_str()) + } + _ => None, + }) + }; + if !adjustment && video().next().is_some() { + if video().all(|id| effectcraft_effects::SKIPS_COVER.contains(&id)) { + target.align(layer_size); + } else { + target.cover(layer_size); + } + } let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) { From b51c91a126e9a3a100057bd51132f7060d6a0c63 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 12:14:27 +1100 Subject: [PATCH 03/50] Render: ask the effect spec whether a stack can skip the layer cover Matej's follow-up kept the known-margin set as SKIPS_COVER and the renderer looked the ids up. Ask EffectSpec::skips_cover instead so a new margin effect opts in on the spec rather than in run_effect_stack. Tests pin align_plan to cover_shift mod 64 and that Twirl still takes the full cover. --- crates/effects/src/lib.rs | 29 +++++++++++++++++++++++++++++ crates/render/src/lib.rs | 6 ++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a968f1aad..cf59a56c8 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -514,6 +514,15 @@ pub struct EffectSpec { pub float: bool, } +impl EffectSpec { + /// True when this effect grows its own buffer (blur/glow margins, Repeat Edge + /// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the + /// renderer's full-layer cover and keep the pyramid grid phase (#1205). + pub fn skips_cover(&self) -> bool { + SKIPS_COVER.contains(&self.id) + } +} + pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec { ParamSpec { id, name, default, ui } } @@ -1504,4 +1513,24 @@ mod tests { assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0))); assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1); } + + #[test] + fn align_plan_is_cover_shift_mod_grid() { + // Content inset from the layer origin: pixel 0 maps to layer (50, 30). + let offset = [-50.0, -30.0]; + let scale = 1.0; + let size = [1920.0, 1080.0]; + let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap(); + assert_eq!((dx, dy), (50, 30)); + assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30))); + assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None); + } + + #[test] + fn known_margin_effects_skip_cover_and_twirl_does_not() { + for id in SKIPS_COVER { + assert!(find(id).expect(id).skips_cover(), "{id}"); + } + assert!(!find("ec.distort.twirl").unwrap().skips_cover()); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 041052b8e..313cb73ff 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1150,14 +1150,12 @@ impl<'a> Renderer<'a> { // get the cover: they move content past the painted bounds without growing the buffer. let video = || { fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind { - GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)) => { - Some(effect.as_str()) - } + GroupKind::Effect { effect } => effectcraft_effects::find(effect).filter(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)), _ => None, }) }; if !adjustment && video().next().is_some() { - if video().all(|id| effectcraft_effects::SKIPS_COVER.contains(&id)) { + if video().all(|s| s.skips_cover()) { target.align(layer_size); } else { target.cover(layer_size); From 7b3d3455cb7bb66ddf30ac2c4af0edfdfbf883b4 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 02:35:07 +1100 Subject: [PATCH 04/50] UI: rescan system fonts from the font menu; Linux scan smoke test (#1089) Fonts installed after EffectCraft launched (dnf, GNOME Font Viewer, a drop into ~/.local/share/fonts) stayed invisible until restart: only the startup background scan ran, and the report in #1089 could not tell discovery gaps apart from a stale scan. - The font menu's filter row gains a Rescan Fonts button (Character and Properties panels share the popup) that runs the existing text.fonts {rescan} command and reports the number added in the status line. The press doesn't flip the Variable-fonts filter (the button is above the row's click area and closes the popup on top of it). - system_scan_finds_fonts now asserts, on a native fontconfig desktop (any machine with /etc/fonts, which includes the Fedora reporter's and the CI runner), that the scan lists installed families rather than silently returning only the bundled fonts: 870 faces in ~10 ms on an Arch box. Tests: ui_font_search 4 passed (new rescan test drives the real popup); effectcraft-text lib system scan green; fmt and clippy --all-targets -D warnings clean. Translations for 'Rescan Fonts' in all six catalogs. --- crates/text/src/fonts.rs | 5 +++++ crates/ui-egui/src/i18n/ui.rs | 7 +++++++ crates/ui-egui/src/panels/text_panels.rs | 11 +++++++++++ crates/ui-egui/tests/ui_font_search.rs | 21 +++++++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d555954c9..98ed85493 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -943,6 +943,11 @@ mod tests { let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); assert_eq!(scan_system(), 0, "scans once"); + if cfg!(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))) && std::path::Path::new("/etc/fonts").exists() { + // #1089: a fontconfig desktop (Fedora, Arch, Ubuntu, the CI runner): the scan must + // list the installed system families, not just the bundled three. + assert!(n > 20, "fontconfig system fonts missing: {n}"); + } if cfg!(target_os = "macos") { assert!(n > 20, "{n}"); let r = resolve("Helvetica", "Bold"); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 58cbe8349..d554c45fa 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -351,6 +351,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("No layer selected", "レイヤーが選択されていません"), ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), ("No matching fonts", "一致するフォントはありません"), + ("Rescan Fonts", "フォントを再スキャン"), ("Variable fonts only", "可変フォントのみ"), ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), ("No nested compositions", "ネストされたコンポジションはありません"), @@ -893,6 +894,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未选择图层"), ("No matching effects or presets", "没有匹配的效果或预设"), ("No matching fonts", "没有匹配的字体"), + ("Rescan Fonts", "重新扫描字体"), ("Variable fonts only", "仅可变字体"), ("No model credits were built into this copy.", "此副本未内置模型致谢。"), ("No nested compositions", "没有嵌套合成"), @@ -1626,6 +1628,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未選取圖層"), ("No matching effects or presets", "沒有匹配的效果或預設集"), ("No matching fonts", "沒有符合的字體"), + ("Rescan Fonts", "重新掃描字型"), ("Variable fonts only", "僅可變字型"), ("No model credits were built into this copy.", "此副本未內置模型致謝。"), ("No nested compositions", "沒有嵌套合成"), @@ -2368,6 +2371,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("No layer selected", "Шар не виділено"), ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), ("No matching fonts", "Не знайдено відповідних шрифтів"), + ("Rescan Fonts", "Повторно сканувати шрифти"), ("Variable fonts only", "Лише змінні шрифти"), ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), ("No nested compositions", "Немає вкладених композицій"), @@ -2895,6 +2899,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("New", "Novo"), ("New Comp from Selection", "Nova composição a partir da seleção"), ("Replace with Precomposition", "Substituir por pré-composição"), + ("Rescan Fonts", "Reanalisar fontes"), ("used once", "usado uma vez"), ("used {} times", "usado {} vezes"), ("Compositions that use this item", "Composições que usam este item"), @@ -3349,6 +3354,7 @@ const CZECH: &[(&str, &str)] = &[ ("New", "Nový"), ("New Comp from Selection", "Nová kompozice z výběru"), ("Replace with Precomposition", "Nahradit předkompozicí"), + ("Rescan Fonts", "Znovu nalézt písma"), ("used once", "použito jednou"), ("used {} times", "použito {}×"), ("Compositions that use this item", "Kompozice, které tuto položku používají"), @@ -3926,6 +3932,7 @@ const SPANISH: &[(&str, &str)] = &[ ("No layer selected", "No hay ninguna capa seleccionada"), ("No matching effects or presets", "No hay efectos ni ajustes preestablecidos que coincidan"), ("No matching fonts", "No hay fuentes coincidentes"), + ("Rescan Fonts", "Volver a analizar fuentes"), ("Variable fonts only", "Solo fuentes variables"), ("No model credits were built into this copy.", "No se incluyeron créditos de modelos en esta copia."), ("No nested compositions", "No hay composiciones anidadas"), diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index ae2294f1f..631a75eba 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -642,6 +642,17 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: // Typing goes on into the search field. ui.memory_mut(|m| m.request_focus(search_id)); } + // #1089: fonts installed after launch (dnf, GNOME Fonts, a drop into ~/.fonts) appear + // without restarting EffectCraft: the system scan also runs in the background at + // startup, this re-runs it on demand. + let resc = Rect::from_min_size(pos2(row.rect.max.x - 124.0, row.rect.min.y + 2.0), vec2(120.0, filter_h - 4.0)); + if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() { + match app.session.execute("text.fonts", json!({"rescan": true})) { + Ok(v) => app.ui.status = format!("{} +{}", tr("Rescan Fonts"), v.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0)), + Err(e) => app.ui.status = e.to_string(), + } + } + app.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts"); let query = state.query.trim().to_lowercase(); let mut seen = std::collections::HashSet::new(); let filtered: Vec<_> = rows diff --git a/crates/ui-egui/tests/ui_font_search.rs b/crates/ui-egui/tests/ui_font_search.rs index 6be69cb7a..0d9c7b79a 100644 --- a/crates/ui-egui/tests/ui_font_search.rs +++ b/crates/ui-egui/tests/ui_font_search.rs @@ -176,3 +176,24 @@ fn font_menu_filters_variable_fonts() { key(&mut h, Key::Escape); assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "filtering changes nothing"); } + +/// #1089: the font menu can re-run the system scan without restarting the app, and the press +/// does not also flip the "Variable fonts only" filter. +#[test] +fn font_menu_rescan_button_adds_fonts_installed_later() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + h.run_steps(3); + assert!(h.state().auto.find("character.font.rescan").is_some(), "the filter row offers a rescan"); + click(&mut h, "character.font.rescan"); + assert!(h.state().ui.status.contains("Rescan Fonts"), "the status reports the rescan: {}", h.state().ui.status); + // The popup stays open, the variable filter did not flip, and the list still has the bundled families. + assert!(h.state().auto.find("character.font.search").is_some(), "the popup stays open"); + let rows = h.state().auto.query("character.font.option."); + assert!(rows.iter().any(|r| r.label == "Inter"), "{:?}", rows.iter().map(|r| &r.label).collect::>()); +} From f676bf08204dc261e9b8f8d978307a0d538e8022 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 16 Oct 2026 22:20:10 +1100 Subject: [PATCH 05/50] Keep the font rescan off the filter label and off the UI thread. The 180 px font menu pinned Rescan Fonts over "Variable fonts only", and the click walked every fontconfig directory on the frame thread. The button is now its own row, and the scan runs on a background thread that the frame polls. The Linux face-count assert is left to the native-fonts gate. --- crates/text/src/fonts.rs | 64 +++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/text_panels.rs | 29 +++++++---- 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 98ed85493..4f6dceb13 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -360,7 +360,8 @@ pub fn scan_system() -> usize { } /// Scan the system font folders again, adding fonts installed since the last scan (faces already -/// known are kept, so font ids stay valid). Returns the number of faces added. +/// known are kept, so font ids stay valid). Returns the number of faces added. This walks every +/// fontconfig directory, so the UI calls [`start_rescan`] instead of running it on the frame thread. pub fn rescan_system() -> usize { let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner()); let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) }; @@ -368,6 +369,60 @@ pub fn rescan_system() -> usize { n } +/// A rescan started by [`start_rescan`], if one is in flight or finished and not yet polled. +#[cfg(not(target_arch = "wasm32"))] +static RESCAN: std::sync::Mutex>> = std::sync::Mutex::new(None); + +/// Start [`rescan_system`] on a background thread. Returns false when a rescan is already +/// running, the thread could not be spawned, or this build has no system font folders (wasm). +pub fn start_rescan() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(|e| e.into_inner()); + if slot.as_ref().is_some_and(|h| !h.is_finished()) { + return false; + } + match std::thread::Builder::new().name("font-rescan".into()).spawn(rescan_system) { + Ok(handle) => { + *slot = Some(handle); + true + } + Err(_) => false, + } + } +} + +/// Whether a background rescan is still walking the font folders. +pub fn rescan_busy() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + RESCAN.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|h| !h.is_finished()) + } +} + +/// Faces added by the latest background rescan, once that thread has finished. `None` while it +/// is still running or when nothing was started. +pub fn poll_rescan() -> Option { + #[cfg(target_arch = "wasm32")] + { + None + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(|e| e.into_inner()); + let finished = slot.as_ref().is_some_and(std::thread::JoinHandle::is_finished); + if finished { slot.take().and_then(|h| h.join().ok()) } else { None } + } +} + /// Start the system font scan on a background thread, so the font menus are complete by the /// time someone opens them without the first open waiting for the scan. Native only; on wasm /// (no threads, no font folders) this does nothing. @@ -943,11 +998,8 @@ mod tests { let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); assert_eq!(scan_system(), 0, "scans once"); - if cfg!(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))) && std::path::Path::new("/etc/fonts").exists() { - // #1089: a fontconfig desktop (Fedora, Arch, Ubuntu, the CI runner): the scan must - // list the installed system families, not just the bundled three. - assert!(n > 20, "fontconfig system fonts missing: {n}"); - } + // A minimum face count on Linux belongs with the native-fonts gate in #1591. A container + // that has fontconfig and only a handful of faces must still pass this portable test. if cfg!(target_os = "macos") { assert!(n > 20, "{n}"); let r = resolve("Helvetica", "Bold"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..108c6db23 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1871,6 +1871,7 @@ impl eframe::App for EffectcraftApp { // Settings ▸ General ▸ Language instead of only the first frame. let language = crate::i18n::language(self); crate::i18n::set_current(language); + crate::panels::text_panels::poll_font_rescan(self, ctx); if !self.styled || self.styled_language != language { theme::install(ctx, &self.tokens, language); self.styled_language = language; diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 631a75eba..c6ade5634 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -568,6 +568,17 @@ struct FontSearch { selected: usize, } +/// Finish a font rescan started from the font menu and report how many faces it added. +/// While the scan is still walking font folders, ask for another frame so the menu updates. +pub(crate) fn poll_font_rescan(app: &mut EffectcraftApp, ctx: &egui::Context) { + if effectcraft_engine::text::fonts::rescan_busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + if let Some(n) = effectcraft_engine::text::fonts::poll_rescan() { + app.ui.status = format!("{} +{n}", tr("Rescan Fonts")); + } +} + /// Shared font menu for Character and Properties: search, recent fonts and previews. pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option { let state_id = id.with("search-state"); @@ -642,15 +653,13 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: // Typing goes on into the search field. ui.memory_mut(|m| m.request_focus(search_id)); } - // #1089: fonts installed after launch (dnf, GNOME Fonts, a drop into ~/.fonts) appear - // without restarting EffectCraft: the system scan also runs in the background at - // startup, this re-runs it on demand. - let resc = Rect::from_min_size(pos2(row.rect.max.x - 124.0, row.rect.min.y + 2.0), vec2(120.0, filter_h - 4.0)); - if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() { - match app.session.execute("text.fonts", json!({"rescan": true})) { - Ok(v) => app.ui.status = format!("{} +{}", tr("Rescan Fonts"), v.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0)), - Err(e) => app.ui.status = e.to_string(), - } + // #1089: fonts installed after launch appear without restarting. The button is its own + // row: the popup is only 180 px wide, so a control pinned to the filter row covers + // "Variable fonts only". The scan itself runs off the UI thread (`poll_font_rescan`). + let rescan_h = 22.0; + let (resc, _) = ui.allocate_exact_size(vec2(ui.available_width(), rescan_h), egui::Sense::hover()); + if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() && effectcraft_engine::text::fonts::start_rescan() { + app.ui.status = format!("{}…", tr("Rescan Fonts")); } app.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts"); let query = state.query.trim().to_lowercase(); @@ -683,7 +692,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: } // Give the result list its actual height even when the previous query had // only one row. A cached tiny Area must not collapse a reopened long list. - let filter_room = filter_h + ui.spacing().item_spacing.y; + let filter_room = filter_h + rescan_h + ui.spacing().item_spacing.y * 2.0; let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, (max_height - filter_room).max(28.0)); // A minimum, from below the filter row (a maximum would move the cursor back up). ui.set_min_height(height); From 5939735c0b79cad7ec80a386272f1417d8c42cfd Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Sun, 18 Oct 2026 02:25:40 +0000 Subject: [PATCH 06/50] fix(i18n): follow browser language preferences (#1700) --- crates/ui-egui/src/i18n.rs | 51 +++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 81343f9f8..2ed342a52 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -66,16 +66,36 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { /// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language /// where EffectCraft has it, else English (#229), as After Effects installs in the system's -/// language. The browser build stays in English because it has no system-locale probe. +/// language. In the browser, use its ordered language preferences (#1700). fn system_language() -> &'static str { #[cfg(not(target_arch = "wasm32"))] { static LANGUAGE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); LANGUAGE.get_or_init(|| supported(sys_locale::get_locale().as_deref())) } - // No system locale in the browser build: English. #[cfg(target_arch = "wasm32")] - supported(None) + { + static LANGUAGE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); + LANGUAGE.get_or_init(|| { + let Some(window) = eframe::web_sys::window() else { return "en" }; + let navigator = window.navigator(); + let locales: Vec = navigator.languages().iter().filter_map(|v| v.as_string()).collect(); + browser_language(locales.iter().map(String::as_str), navigator.language().as_deref()) + }) + } +} + +/// Pick the first supported browser preference, including an explicit English preference. +/// Older browsers may only supply `language`; unknown or absent languages use English. +#[cfg(any(target_arch = "wasm32", test))] +fn browser_language<'a>(mut locales: impl Iterator, fallback: Option<&str>) -> &'static str { + locales + .find_map(|locale| { + let language = supported(Some(locale)); + let english = locale.split(['-', '_']).next().is_some_and(|primary| primary.eq_ignore_ascii_case("en")); + (language != "en" || english).then_some(language) + }) + .unwrap_or_else(|| supported(fallback)) } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, @@ -90,6 +110,9 @@ fn supported(locale: Option<&str>) -> &'static str { "uk" => "uk", "es" => "es", "cs" => "cs", + "ru" => "ru", + "fr" => "fr", + "de" => "de", // The only Portuguese catalog: Brazil's, also for Portugal rather than English. "pt" => "pt-br", "zh" => { @@ -6887,6 +6910,24 @@ mod tests { app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); assert_eq!(label(&app, "", "Composition"), "Composition"); } + #[test] + fn browser_preferences_choose_a_supported_language_in_order() { + for (locales, fallback, expected) in [ + (vec!["es-ES"], None, "es"), + (vec!["ja-JP", "es-ES"], None, "ja"), + (vec!["xx-XX", "es-MX"], None, "es"), + (vec!["en-US", "es-ES"], None, "en"), + (vec!["zh-TW"], None, "zh-hant"), + (vec!["de-DE", "es-ES"], None, "de"), + (vec![], Some("ja-JP"), "ja"), + (vec!["xx-XX"], Some("es-ES"), "es"), + (vec![], None, "en"), + (vec!["xx-XX"], Some("xx-XX"), "en"), + ] { + assert_eq!(browser_language(locales.into_iter(), fallback), expected); + } + } + #[test] fn match_system_uses_the_system_language_where_there_is_a_catalog() { for (locale, language) in [ @@ -6918,7 +6959,9 @@ mod tests { (Some("CS_cz.UTF-8"), "cs"), (Some("en-US"), "en"), (Some("jv-ID"), "en"), - (Some("de-DE"), "en"), + (Some("de-DE"), "de"), + (Some("fr-CA"), "fr"), + (Some("ru-RU"), "ru"), (None, "en"), ] { assert_eq!(supported(locale), language, "{locale:?}"); From efe7581f9395343af94871d0388eff95dbe5c308 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 23:47:45 -0700 Subject: [PATCH 07/50] Scripts: the Startup and Shutdown folders run at launch and quit Scripts in Scripts/Startup and Scripts/Shutdown in the settings folder were never run. The desktop and web apps now run the Startup scripts at launch, before a project opens, and the desktop app runs the Shutdown scripts as it quits, after the project is closed. Each folder runs in alphabetical order with case ignored, and files larger than 16 MiB are not run. A Startup script that fails is reported in a toast and in the log, a Shutdown script that fails is reported in the log, and the next one still runs. A Startup script that waits in a modal dialog goes on when the dialog closes, while the next scripts run. A Shutdown script's dialogs close as if canceled. With the settings folder on disk, each script runs as its file, so #include, $.fileName and $.evalFile resolve against the script's own folder. The new commands file.runStartupScripts and file.runShutdownScripts return the scripts that ran, those still waiting in a dialog, and those that failed. Like script.run, both are refused in confined automation sessions, in batches and in script steps, which now read one list of script commands (RUNS_SCRIPTS). The list also holds window.scriptPanel and window.restoreScriptPanels, which run ScriptUI panel scripts and were not refused on main either. The apps still call window.restoreScriptPanels at launch. plugins.md, scripting-parity.md, gaps.md, target-app-parity.md and agents.md describe the folders. Each script still runs in its own global scope, so a Startup script's variables are not available to later scripts. --- apps/effectcraft-web/src/app.rs | 2 + apps/effectcraft/src/main.rs | 5 + crates/automation/src/confine.rs | 7 +- crates/automation/src/tests.rs | 12 ++ crates/engine/src/commands/batch.rs | 7 +- crates/engine/src/commands/file_more.rs | 11 +- crates/engine/src/commands/scripts.rs | 99 +++++++++ crates/script/src/lib.rs | 2 + crates/script/src/runtime.rs | 2 +- crates/script/src/tests_startup.rs | 269 ++++++++++++++++++++++++ docs/agents.md | 4 +- docs/gaps.md | 5 +- docs/plugins.md | 6 +- docs/scripting-parity.md | 4 +- docs/target-app-parity.md | 2 +- 15 files changed, 421 insertions(+), 16 deletions(-) create mode 100644 crates/script/src/tests_startup.rs diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 3f462bd2a..d5a767c03 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -238,6 +238,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { } let mut session = session(); session.load_settings(); + // Scripts in Scripts/Startup run before a project opens. + let _ = session.execute("file.runStartupScripts", json!({})); let recovery = session.begin_recovery(); // `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's // project (the demo on a first visit). diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 2bc3265dd..99dc7ce1c 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -243,6 +243,9 @@ fn main() -> eframe::Result { // Fit the window to its monitor, or put it back where it was (still hidden). let saved_window = session.config.as_deref().and_then(window::load); let opened = cc.winit_window().map(|w| window::restore(w, saved_window)); + // Scripts in Scripts/Startup run before a project opens (a failing one is reported in + // a toast). + let _ = session.execute("file.runStartupScripts", json!({})); let recovery = if control_port.is_none() { session.begin_recovery() } else { None }; let project = files.iter().find(|f| is_project_file(f)).cloned(); if let Some(p) = project { @@ -455,6 +458,8 @@ impl eframe::App for Desktop { self.launch.exited(); self.window.save(self.app.session.config.as_deref()); self.app.on_exit(); + // Scripts in Scripts/Shutdown run with the project closed. + let _ = self.app.session.execute("file.runShutdownScripts", json!({})); } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index f396b3da0..d2f4c70eb 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -28,10 +28,9 @@ pub struct Confinement { write: Vec, } -/// Commands that run code, act outside the project or open other applications. +/// Commands that run code, act outside the project or open other applications, besides the +/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]). const REFUSED: &[&str] = &[ - "script.run", - "file.runScript", "file.installScript", "file.installScriptUIPanel", "file.uninstallScript", @@ -135,7 +134,7 @@ impl Confinement { /// Check engine command `id` with `params` before it runs. Returns what must still be looked /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. pub fn check_command(&self, id: &str, params: &Value) -> Result> { - if REFUSED.contains(&id) { + if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) { return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); } if id == "engine.batch" { diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6e622b377..241eae720 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1376,6 +1376,18 @@ fn automation_roots_confine_file_access() { // Scripts can read and write anything: refused. denied(&mut s, "run_script", json!({"code": "1 + 1"})); denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}})); + for (id, params) in [ + ("file.runStartupScripts", json!({})), + ("file.runShutdownScripts", json!({})), + // ScriptUI panels run their script when they open. + ("window.scriptPanel", json!({"name": "Layer Tools.jsx"})), + ("window.restoreScriptPanels", json!({})), + ] { + let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params})); + assert!(msg.contains("not available while file access is confined"), "{id}: {msg}"); + let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]})); + assert!(msg.contains("not available while file access is confined"), "{id}: {msg}"); + } // Writes to the files the agent named: none outside. assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save"); let _ = std::fs::remove_dir_all(&base); diff --git a/crates/engine/src/commands/batch.rs b/crates/engine/src/commands/batch.rs index 4bfd2b2b1..aecb42a8d 100644 --- a/crates/engine/src/commands/batch.rs +++ b/crates/engine/src/commands/batch.rs @@ -9,8 +9,9 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, str_p}; use crate::{EngineError, Result, Session}; -/// Commands that can't run inside a batch (they replace the project or nest batches). -const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"]; +/// Commands that can't run inside a batch (they nest batches), besides the commands that run +/// scripts ([`super::scripts::RUNS_SCRIPTS`]). +const NOT_IN_BATCH: &[&str] = &["engine.batch"]; /// Resolve a `$N` / `$N.key.0.key` reference against the results so far. fn reference(s: &str, results: &[Value]) -> Option> { @@ -64,7 +65,7 @@ fn batch(s: &mut Session, p: &Value) -> Result { .or_else(|| step.get("id")) .and_then(Value::as_str) .ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?; - if NOT_IN_BATCH.contains(&id) { + if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) { return Err(bad("engine.batch", format!("`{id}` can't run inside a batch"))); } let params = substitute(step.get("params").unwrap_or(&json!({})), results)?; diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 74a86e1d8..f5314e64b 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -480,7 +480,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))), }; - if id == "file.runScript" || id == "script.run" { + if super::scripts::RUNS_SCRIPTS.contains(&id.as_str()) { return Err(bad("file.runScript", "scripts can't run scripts")); } let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) }; @@ -505,7 +505,7 @@ fn is_jsxbin(name: &str) -> bool { } /// A script error becomes a toast and a command error. -fn report_js(s: &mut Session, name: &str, out: Value) -> Result { +pub(crate) fn report_js(s: &mut Session, name: &str, out: Value) -> Result { if let Some(e) = out.get("error").filter(|e| !e.is_null()) { let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default(); let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error"); @@ -521,6 +521,13 @@ pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result Result { + let out = run_js(s, code, path, false)?; + report_js(s, name, out) +} + /// Run JavaScript through the session's scripting engine. fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result { let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?; diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 24049459c..396c1c474 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -6,6 +6,9 @@ //! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI //! panels appear at the bottom of the Window menu and open as dockable panels //! (`window.scriptPanel`), running with `this` = the panel as in After Effects. +//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by +//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft +//! quits, after the project is closed (`file.runShutdownScripts`). //! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control //! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]). //! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by @@ -22,6 +25,18 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui}; /// The settings-store folders of installed scripts. pub const SCRIPTS_DIR: &str = "Scripts"; pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels"; +/// The settings-store folders whose scripts run at launch and as EffectCraft quits. +pub const STARTUP_DIR: &str = "Scripts/Startup"; +pub const SHUTDOWN_DIR: &str = "Scripts/Shutdown"; +/// Commands that run script files or script code (a ScriptUI panel runs its script when it +/// opens). Scripts and batches can't run them, and confined automation sessions refuse them. +pub const RUNS_SCRIPTS: &[&str] = + &["script.run", "file.runScript", "file.runStartupScripts", "file.runShutdownScripts", "window.scriptPanel", "window.restoreScriptPanels"]; +/// Largest script file the Startup and Shutdown folders run (bytes), as `#include` and +/// `$.evalFile` allow. +const MAX_SCRIPT_BYTES: usize = 16 << 20; +/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled. +const MAX_SHUTDOWN_DIALOGS: usize = 64; /// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch. pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json"; /// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects @@ -308,6 +323,72 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result { Ok(json!({"opened": opened, "failed": failed})) } +/// Run the scripts in `dir` of the settings store one after another, in alphabetical order (case +/// ignored), as After Effects runs its Startup and Shutdown folders. A script that fails is +/// reported in a toast and the log and listed in `failed`; the next one still runs. A script waiting in a +/// modal dialog is listed in `waiting` and goes on when the dialog closes; the next one runs +/// meanwhile. With `quitting`, no window is left to show a dialog in, so each dialog closes as +/// if canceled (`show()` returns 2) and the script runs on. +fn run_folder(s: &mut Session, dir: &str, quitting: bool) -> Value { + let mut names: Vec = s.config.as_ref().map(|c| c.list(dir)).unwrap_or_default().into_iter().filter(|n| is_script(n) && is_script_name(n)).collect(); + names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b))); + let (mut ran, mut waiting, mut failed) = (vec![], vec![], vec![]); + for name in names { + let code = match s.config.as_ref().map(|c| c.read_bounded(&format!("{dir}/{name}"), MAX_SCRIPT_BYTES)) { + Some(Ok(Some(code))) => code, + read => { + let why = match read { + Some(Err(e)) => format!(": {e}"), + _ => String::new(), + }; + let error = format!("{name}: cannot read the script{why}"); + log::warn!("{dir}: {error}"); + s.events.push(Event::Toast { message: error.clone(), error: true }); + failed.push(json!({"name": name, "error": error})); + continue; + } + }; + let is_waiting = |r: &Result| r.as_ref().is_ok_and(|v| v.get("waiting").and_then(Value::as_bool) == Some(true)); + // In a settings folder on disk, the script runs as its file, so `#include`, `$.fileName` + // and `$.evalFile` find the files beside it. + let path = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(dir).join(&name).to_string_lossy().into_owned()).unwrap_or_else(|| name.clone()); + // Windows open before this script runs belong to other scripts, perhaps of the same name. + let before: Vec = s.script_ui.windows.iter().map(|w| w.id).collect(); + let mut r = super::file_more::run_js_file(s, &code, &path, &name); + let mut closed = 0; + while quitting && closed < MAX_SHUTDOWN_DIALOGS && is_waiting(&r) { + let Some(id) = s.script_ui.windows.iter().rev().find(|w| w.modal && w.script == path && !before.contains(&w.id)).map(|w| w.id) else { break }; + // report_js reports an error the script throws after its dialog closes, as it does for an + // error in the script's first run. + r = scriptui::close(s, &json!({"window": id})).and_then(|out| super::file_more::report_js(s, &name, out)); + closed += 1; + } + match r { + Ok(_) if is_waiting(&r) => waiting.push(name), + Ok(_) => ran.push(name), + Err(e) => { + // Also in the log: no frame draws a Shutdown script's toast. + log::warn!("{dir}: {e}"); + failed.push(json!({"name": name, "error": e.to_string()})); + } + } + } + json!({"ran": ran, "waiting": waiting, "failed": failed}) +} + +/// `file.runStartupScripts`: run the scripts in `Scripts/Startup` (frontends call it once at +/// launch, before a project opens). +fn run_startup(s: &mut Session, _: &Value) -> Result { + Ok(run_folder(s, STARTUP_DIR, false)) +} + +/// `file.runShutdownScripts`: close the project, then run the scripts in `Scripts/Shutdown` +/// (frontends call it as the app quits, after the unsaved-changes prompt). +fn run_shutdown(s: &mut Session, _: &Value) -> Result { + s.replace_project(effectcraft_project::Project::default(), None); + Ok(run_folder(s, SHUTDOWN_DIR, true)) +} + /// Window ▸ : run the panel script (or bring its panel forward). fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; @@ -374,6 +455,24 @@ pub fn specs() -> Vec { always, restore_panels ), + cmd!( + "file.runStartupScripts", + "Run Startup Scripts", + [], + None, + "{} → runs the scripts in Scripts/Startup in alphabetical order (frontends call it at launch) → {ran: [name], waiting: [name] (in a modal dialog), failed: [{name, error}]}", + always, + run_startup + ), + cmd!( + "file.runShutdownScripts", + "Run Shutdown Scripts", + [], + None, + "{} → closes the project, then runs the scripts in Scripts/Shutdown in alphabetical order (frontends call it as the app quits; their modal dialogs close as if canceled) → {ran: [name], waiting: [name], failed: [{name, error}]}", + always, + run_shutdown + ), crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get), cmd!( "script.settings.save", diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index 5c1f8cdbb..8472881d9 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -97,6 +97,8 @@ mod tests_include; #[cfg(test)] mod tests_panels; #[cfg(test)] +mod tests_startup; +#[cfg(test)] mod tests_tasks; #[cfg(test)] mod tests_ui; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 192da5740..22e5279c8 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -126,7 +126,7 @@ pub(crate) fn js_str(s: &str) -> JsValue { fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let id = arg_string(args, 0, ctx)?; let mut params = arg_json(args, 1, ctx)?; - if id == "script.run" || id == "file.runScript" { + if effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id.as_str()) { return Err(throw("scripts can't run scripts")); } let keys = params.as_object_mut().and_then(|o| o.remove("__keys")); diff --git a/crates/script/src/tests_startup.rs b/crates/script/src/tests_startup.rs new file mode 100644 index 000000000..6c13aaf30 --- /dev/null +++ b/crates/script/src/tests_startup.rs @@ -0,0 +1,269 @@ +//! Startup and Shutdown scripts, as in After Effects: the scripts in the user's `Scripts/Startup` +//! and `Scripts/Shutdown` folders (in the settings folder) run in alphabetical order at launch and +//! as the app quits, after the project is closed. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::commands::scripts::{OPEN_PANELS_FILE, PANELS_DIR, SCRIPTS_DIR, SHUTDOWN_DIR, STARTUP_DIR}; +use effectcraft_engine::config::{ConfigStore, MemoryConfig}; +use effectcraft_engine::scriptui::WindowKind; +use serde_json::json; + +fn session(store: &Arc) -> Session { + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(store.clone()); + s +} + +/// A script that appends `mark` to the `Startup Test` / `order` script setting. +fn mark(mark: &str) -> String { + format!( + r#"var k = app.settings.haveSetting("Startup Test", "order") ? app.settings.getSetting("Startup Test", "order") : ""; +app.settings.saveSetting("Startup Test", "order", k + "{mark}");"# + ) +} + +fn setting(s: &mut Session, key: &str) -> serde_json::Value { + s.execute("script.settings.get", json!({"section": "Startup Test", "key": key})).unwrap()["value"].clone() +} + +#[test] +fn startup_scripts_run_in_alphabetical_order_and_a_failing_one_does_not_stop_the_rest() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/b.jsx"), &mark("b")).unwrap(); + store.write(&format!("{STARTUP_DIR}/A.jsx"), &mark("A")).unwrap(); + store.write(&format!("{STARTUP_DIR}/B2.jsx"), &mark("B")).unwrap(); + store.write(&format!("{STARTUP_DIR}/C.js"), "throw new Error('no');").unwrap(); + store.write(&format!("{STARTUP_DIR}/d.jsx"), &mark("d")).unwrap(); + // Not scripts, or not in the Startup folder: not run. + store.write(&format!("{STARTUP_DIR}/notes.txt"), &mark("x")).unwrap(); + store.write(&format!("{SCRIPTS_DIR}/Menu Script.jsx"), &mark("m")).unwrap(); + store.write(&format!("{SHUTDOWN_DIR}/Quit.jsx"), &mark("q")).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r["ran"], json!(["A.jsx", "b.jsx", "B2.jsx", "d.jsx"]), "{r}"); + let failed: Vec<&str> = r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(failed, ["C.js"]); + assert_eq!(setting(&mut s, "order"), "AbBd"); + // The failing script is reported in a toast. + let events = s.drain_events(); + assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("C.js"))), "{events:?}"); + // The Startup folder's scripts are not offered in File ▸ Scripts. + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().all(|e| e["name"] != "b.jsx"), "{list}"); +} + +#[test] +fn shutdown_scripts_run_after_the_project_is_closed() { + let store = Arc::new(MemoryConfig::default()); + let probe = r#"app.settings.saveSetting("Startup Test", "items", String(app.project.numItems));"#; + store.write(&format!("{SHUTDOWN_DIR}/Save State.jsx"), probe).unwrap(); + let mut s = session(&store); + s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap(); + assert!(!s.project.items.is_empty()); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Save State.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "items"), "0"); + assert!(s.project.items.is_empty()); +} + +#[test] +fn a_startup_dialog_stays_open_and_its_script_goes_on_when_it_closes() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/A Dialog.jsx"), &format!("new Window('dialog', 'Welcome').show();\n{}", mark("A"))).unwrap(); + store.write(&format!("{STARTUP_DIR}/B.jsx"), &mark("B")).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["B.jsx"], "waiting": ["A Dialog.jsx"], "failed": []})); + assert_eq!(setting(&mut s, "order"), "B"); + assert!(s.script_ui.windows.iter().any(|w| w.modal && w.title == "Welcome")); + s.execute("scriptui.close", json!({})).unwrap(); + assert_eq!(setting(&mut s, "order"), "BA"); +} + +#[test] +fn shutdown_dialogs_close_as_if_canceled() { + let store = Arc::new(MemoryConfig::default()); + let code = r#"var a = new Window("dialog", "One").show(); +var b = new Window("dialog", "Two").show(); +app.settings.saveSetting("Startup Test", "shown", a + "," + b);"#; + store.write(&format!("{SHUTDOWN_DIR}/Ask.jsx"), code).unwrap(); + // An error after the dialog closes is a failure. + store.write(&format!("{SHUTDOWN_DIR}/Broken.jsx"), "new Window('dialog', 'Three').show();\nthrow new Error('late');").unwrap(); + let mut s = session(&store); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!((&r["ran"], &r["waiting"]), (&json!(["Ask.jsx"]), &json!([])), "{r}"); + assert_eq!(r["failed"][0]["name"], "Broken.jsx", "{r}"); + assert!(r["failed"][0]["error"].as_str().is_some_and(|e| e.contains("late")), "{r}"); + assert_eq!(setting(&mut s, "shown"), "2,2"); + assert!(s.script_ui.windows.is_empty()); +} + +#[test] +fn shutdown_closes_only_its_own_scripts_dialogs() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/Same.jsx"), &format!("new Window('dialog', 'Startup').show();\n{}", mark("S"))).unwrap(); + store.write(&format!("{SHUTDOWN_DIR}/Same.jsx"), &format!("new Window('dialog', 'Shutdown').show();\n{}", mark("Q"))).unwrap(); + let mut s = session(&store); + assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap()["waiting"], json!(["Same.jsx"])); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Same.jsx"], "waiting": [], "failed": []})); + // The Startup script's dialog is still open, and its script has not gone on. + assert_eq!(setting(&mut s, "order"), "Q"); + let titles: Vec<&str> = s.script_ui.windows.iter().map(|w| w.title.as_str()).collect(); + assert_eq!(titles, ["Startup"]); +} + +#[test] +fn oversized_scripts_are_not_run() { + let store = Arc::new(MemoryConfig::default()); + let big = format!("{}\n{}", "//".repeat(8 << 20), mark("x")); + store.write(&format!("{STARTUP_DIR}/Big.jsx"), &big).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r["ran"], json!([])); + assert_eq!(r["failed"][0]["name"], "Big.jsx"); + let events = s.drain_events(); + assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("Big.jsx"))), "{events:?}"); + assert_eq!(setting(&mut s, "order"), ""); +} + +#[test] +fn scripts_in_a_settings_folder_on_disk_include_files_beside_them() { + use effectcraft_engine::config::DirConfig; + let root = std::env::temp_dir().join(format!("ec-startup-include-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + for (file, code) in [ + (format!("{STARTUP_DIR}/lib/helper.jsx"), mark("i")), + (format!("{STARTUP_DIR}/Main.jsx"), format!("#include \"lib/helper.jsx\"\n{}", mark("m"))), + (format!("{SHUTDOWN_DIR}/Ask.jsx"), format!("new Window('dialog', 'Bye').show();\n{}", mark("q"))), + ] { + let path = root.join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, code).unwrap(); + } + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(Arc::new(DirConfig::new(&root))); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Main.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "order"), "im"); + // A Shutdown script run as its file still has its dialog closed. + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Ask.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "order"), "imq"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A session with one comp, for checking that a refused command leaves the project open. +fn session_with_comp(store: &Arc) -> Session { + let mut s = session(store); + s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap(); + assert!(!s.project.items.is_empty()); + s +} + +const FOLDER_COMMANDS: [&str; 2] = ["file.runStartupScripts", "file.runShutdownScripts"]; + +#[test] +fn scripts_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {{}})")})).unwrap(); + assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +#[test] +fn command_scripts_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let e = s.execute("file.runScript", json!({"steps": [{"command": id}]})).unwrap_err().to_string(); + assert!(e.contains("scripts can't run scripts"), "{id}: {e}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +#[test] +fn batches_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let e = s.execute("engine.batch", json!({"steps": [{"command": id}]})).unwrap_err().to_string(); + assert!(e.contains("can't run inside a batch"), "{id}: {e}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +/// A ScriptUI panel whose script appends `p` to the `Startup Test` / `order` setting each time it +/// runs, remembered as open at the last quit. +fn session_with_panel(store: &Arc) -> Session { + store.write(&format!("{PANELS_DIR}/Mark Panel.jsx"), &format!("this.add('statictext', undefined, 'Mark');\n{}", mark("p"))).unwrap(); + store.write(OPEN_PANELS_FILE, &json!({"open": ["Mark Panel.jsx"]}).to_string()).unwrap(); + session(store) +} + +fn panel_open(s: &Session) -> bool { + s.script_ui.windows.iter().any(|w| w.kind == WindowKind::Panel && w.script == "Mark Panel.jsx") +} + +/// The commands that open ScriptUI panels, which run the panel's script. +fn panel_commands() -> [(&'static str, serde_json::Value); 2] { + [("window.scriptPanel", json!({"name": "Mark Panel.jsx"})), ("window.restoreScriptPanels", json!({}))] +} + +#[test] +fn scripts_cannot_open_script_panels() { + for (id, params) in panel_commands() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + // Opened by the user: a script's call would only bring it forward. + s.execute("window.scriptPanel", json!({"name": "Mark Panel.jsx"})).unwrap(); + let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {params})")})).unwrap(); + assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}"); + assert_eq!(setting(&mut s, "order"), "p", "{id}"); + assert_eq!(store.read(OPEN_PANELS_FILE).unwrap_or_default(), json!({"open": ["Mark Panel.jsx"]}).to_string(), "{id}"); + } +} + +#[test] +fn command_scripts_and_batches_cannot_open_script_panels() { + for (id, params) in panel_commands() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + let e = s.execute("file.runScript", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string(); + assert!(e.contains("scripts can't run scripts"), "{id}: {e}"); + let e = s.execute("engine.batch", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string(); + assert!(e.contains("can't run inside a batch"), "{id}: {e}"); + assert!(!panel_open(&s), "{id}"); + assert_eq!(setting(&mut s, "order"), "", "{id}"); + } +} + +#[test] +fn the_app_still_reopens_script_panels_at_launch() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + // As the desktop and web apps call it at launch. + let r = s.execute("window.restoreScriptPanels", json!({})).unwrap(); + assert_eq!(r, json!({"opened": ["Mark Panel.jsx"], "failed": []})); + assert!(panel_open(&s)); + assert_eq!(setting(&mut s, "order"), "p"); +} + +#[test] +fn empty_or_missing_folders_run_nothing() { + let none = json!({"ran": [], "waiting": [], "failed": []}); + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap(), none); + // A session without a settings folder. + let mut s = Session::default(); + crate::install(&mut s); + assert_eq!(s.execute("file.runShutdownScripts", json!({})).unwrap(), none); +} diff --git a/docs/agents.md b/docs/agents.md index 5c58d24b7..42b88b066 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -580,7 +580,9 @@ A dialog's `show()` waits for the user: the `script.run` / `file.runScript` repl `"waiting": true`, and the script continues (its final output arrives in the reply of the click that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript {"name": …}`, `file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`, -`window.restoreScriptPanels` (the panels open when the app last quit). Scripts' `app.settings`: +`window.restoreScriptPanels` (the panels open when the app last quit), `file.runStartupScripts` and +`file.runShutdownScripts` (the scripts in `Scripts/Startup` and `Scripts/Shutdown`; the second closes +the project first). Scripts' `app.settings`: `script.settings.get {"section": …, "key": …}` and `script.settings.save {…, "value": …}`. Extension points and the bundled extensions: [plugins.md](plugins.md). diff --git a/docs/gaps.md b/docs/gaps.md index f8fcd64c2..a956ed953 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -299,7 +299,10 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins ([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel - and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard + and open again at the next launch. The scripts in the Startup folder run at launch, and the desktop + app runs the Shutdown folder's scripts as it quits. Missing: a global scope that scripts share + for the session (a Startup script's variables and functions are not available to later + scripts), keyboard shortcuts for scripts and `.jsxbin`; `#include` / `#target` directives landed (#759, [scripting-parity.md](scripting-parity.md)). - Done so far: ease presets (#254) cover what ease-curve plug-ins are used for: easing curves kept diff --git a/docs/plugins.md b/docs/plugins.md index a688a89b0..a72eb1561 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -15,7 +15,7 @@ Guide): | Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same | | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | | ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same | -| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap | +| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | `Scripts/Startup/` and `Scripts/Shutdown/` in the settings folder, in alphabetical order (case ignored): the Startup scripts run at launch before a project opens, and the Shutdown scripts run as the desktop app quits, after the project is closed. A Startup script that fails is reported in a toast and in the log, a Shutdown script that fails is reported in the log, and the next script still runs | Partial: each script runs in its own global scope, so variables and functions a Startup script defines are not available to later scripts; a Startup script's modal dialog stays open while the next scripts run and the app starts; a Shutdown script's modal dialogs close as if canceled; the web app runs no Shutdown scripts | | `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | | Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap | | Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points | @@ -38,6 +38,10 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs file.jsx`, or `script.run {code, name}` over the control channel and MCP. * Install one: File ▸ Scripts ▸ Install Script File… (or copy it into `Scripts/` in the settings folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart). +* Run one at every launch or quit: copy it into `Scripts/Startup/` or `Scripts/Shutdown/` in the + settings folder. These scripts are not listed in File ▸ Scripts. `file.runStartupScripts` and + `file.runShutdownScripts` run them and return `{ran, waiting, failed}`; the desktop app calls them at + launch and quit, and the web app calls `file.runStartupScripts` at launch. * `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs one; `file.uninstallScript {name}` removes an installed one. diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 7ef7e9080..55bd4a212 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -23,7 +23,7 @@ our own scripts; no real-world script library has been run against both apps. | ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | | `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | | Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) | -| Startup / Shutdown folders | yes | not run | missing | plugins.md | +| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `tests_startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) | | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification | | Keyboard shortcuts for scripts | yes | no | missing | plugins.md | @@ -34,7 +34,7 @@ our own scripts; no real-world script library has been run against both apps. ## Estimate -**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown remain), script shortcuts 2–4 h, +**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown landed; a global scope shared between scripts remains), script shortcuts 2–4 h, a corpus of public scripts and expressions with the fixes it finds 10–20 h, an expression performance benchmark 4–8 h. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ff7cc3ef9..831c5e2d4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -117,7 +117,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed | | Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed | | Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) | -| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | +| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, a global scope shared between scripts, script shortcuts (`#include` landed, #759; Startup/Shutdown folders run); external editor (#364); see [scripting-parity.md](scripting-parity.md) | | Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Open mask and shape issues (#1004, #999, #1042, #932); #510, #513, #527, #509 and #303 are closed; paste SVG/AI as shape layers (26.3; SVG markup pastes since #738) | | Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) | | Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 54 built-in presets (text, Transitions, Backgrounds) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | From 9de2c5ee8a54310f3f4d170ac7c7fab6a547a9da Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 08:28:48 -0700 Subject: [PATCH 08/50] Interpret Footage: Cineon Settings convert DPX and Cineon log scans DPX and Cineon footage showed its log code values as stored, with no way to convert them. Interpret Footage now has the Cineon Settings that After Effects' help page describes: Logarithmic Conversion, the Converted Black and White Points, the 10 Bit Black and White Points, Current Gamma, Highlight Rolloff and Units. With Logarithmic Conversion on, the code values of each decoded frame are converted from printing density to the Current Gamma between the converted points, before Separate Fields, Frame Blending, Interpret As Linear Light and the working space use them. With it off (the default), the values stay as stored. file.interpretFootage and file.interpretProxy take the settings as parameters and report them. They return an error for out-of-range values, and for Cineon parameters when none of the items is a DPX or Cineon file. Apply Interpretation copies the settings between DPX and Cineon files. --- crates/engine/src/commands/file_more.rs | 157 +++++++++++++++++++++++- crates/engine/src/commands/proxy.rs | 3 +- crates/engine/src/tests_proxy.rs | 116 ++++++++++++++++- crates/project/src/lib.rs | 139 +++++++++++++++++++++ crates/project/src/tests.rs | 43 +++++++ crates/render/src/lib.rs | 37 ++++-- crates/ui-egui/src/panels/forms.rs | 67 +++++++++- docs/file-format-parity.md | 2 +- docs/footage.md | 9 +- docs/gaps.md | 2 +- 10 files changed, 553 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 74a86e1d8..52d338573 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use effectcraft_color::Label; -use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; +use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -563,6 +563,131 @@ pub(crate) struct Interpretation { preserve_rgb: Option, /// Start Timecode. start: Option, + /// Cineon Settings (DPX and Cineon footage). + cineon: CineonEdit, +} + +/// Interpret Footage ▸ Cineon Settings parsed from command parameters (every field optional). +#[derive(Clone, Debug, Default)] +struct CineonEdit { + /// Logarithmic Conversion. + log: Option, + units: Option, + /// The units the Converted Black and White Points are given in (`pointUnits`, else `cineonUnits`, + /// else the footage's own Units). + point_units: Option, + converted_black: Option, + converted_white: Option, + /// 10 Bit Black and White Points. + black: Option, + white: Option, + gamma: Option, + rolloff: Option, +} + +impl CineonEdit { + fn parse(p: &Value, cmd: &str) -> Result { + let units = |key: &str| -> Result> { + match str_p(p, key) { + None => Ok(None), + Some(u) => CineonUnits::parse(u).map(Some).ok_or_else(|| bad(cmd, format!("{key}: 10bit|8bit|float, not `{u}`"))), + } + }; + let num = |key: &str, lo: f64, hi: f64| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|x| x.is_finite() && (lo..=hi).contains(x)) + .map(Some) + .ok_or_else(|| bad(cmd, format!("{key}: a number from {lo} to {hi}"))), + } + }; + let units_now = units("cineonUnits")?; + Ok(CineonEdit { + log: b_p(p, "logConversion"), + units: units_now, + point_units: units("pointUnits")?.or(units_now), + // Range-checked once their units are known (`settings_for`). + converted_black: num("convertedBlack", f64::MIN, f64::MAX)?, + converted_white: num("convertedWhite", f64::MIN, f64::MAX)?, + black: num("tenBitBlack", 0.0, 1023.0)?, + white: num("tenBitWhite", 0.0, 1023.0)?, + gamma: num("currentGamma", 0.1, 10.0)?, + rolloff: num("highlightRolloff", 0.0, 100.0)?, + }) + } + + fn is_empty(&self) -> bool { + self.log.is_none() + && self.units.is_none() + && self.converted_black.is_none() + && self.converted_white.is_none() + && self.black.is_none() + && self.white.is_none() + && self.gamma.is_none() + && self.rolloff.is_none() + } + + /// Footage `f`'s Cineon Settings after this edit; `None` when nothing changes them (no Cineon + /// parameters, or `f` is not a DPX or Cineon file). + fn settings_for(&self, f: &Footage, cmd: &str) -> Result> { + if self.is_empty() || !f.is_cineon_file() { + return Ok(None); + } + let mut c = f.cineon; + let units = self.point_units.unwrap_or(c.units); + let point = |v: f64| -> Result { + let ten = units.to_ten_bit(v); + let max = units.from_ten_bit(f64::from(CineonSettings::MAX)); + if (0.0..=f64::from(CineonSettings::MAX)).contains(&ten) { + Ok(ten as f32) + } else { + Err(bad(cmd, format!("converted points: 0 to {max} in {} units", units.label()))) + } + }; + if let Some(v) = self.converted_black { + c.converted_black = point(v)?; + } + if let Some(v) = self.converted_white { + c.converted_white = point(v)?; + } + if let Some(v) = self.log { + c.log_conversion = v; + } + if let Some(u) = self.units { + c.units = u; + } + if let Some(v) = self.black { + c.black = v as f32; + } + if let Some(v) = self.white { + c.white = v as f32; + } + if let Some(v) = self.gamma { + c.gamma = v as f32; + } + if let Some(v) = self.rolloff { + c.rolloff = v as f32; + } + if c.black >= c.white { + return Err(bad(cmd, "the 10 Bit Black Point must be below the 10 Bit White Point")); + } + Ok(Some(c)) + } +} + +/// Footage `f`'s Cineon Settings as commands report them (`null` for files they don't apply to). +fn cineon_json(f: &Footage) -> Value { + if !f.is_cineon_file() { + return Value::Null; + } + let c = &f.cineon; + json!({ + "logConversion": c.log_conversion, "units": c.units.id(), + "convertedBlack": c.units.from_ten_bit(f64::from(c.converted_black)), "convertedWhite": c.units.from_ten_bit(f64::from(c.converted_white)), + "tenBitBlack": c.black, "tenBitWhite": c.white, "currentGamma": c.gamma, "highlightRolloff": c.rolloff, + }) } /// Interpret Footage ▸ Start Timecode. @@ -656,9 +781,23 @@ impl Interpretation { v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?, )), }; + it.cineon = CineonEdit::parse(p, cmd)?; Ok(it) } + /// Check the Cineon Settings this interpretation gives each of `footage`: an error names a + /// value out of range, or Cineon parameters when none of the footage is DPX or Cineon. + pub(crate) fn check<'a>(&self, footage: impl IntoIterator, cmd: &str) -> Result<()> { + if self.cineon.is_empty() { + return Ok(()); + } + let mut any = false; + for f in footage { + any |= self.cineon.settings_for(f, cmd)?.is_some(); + } + if any { Ok(()) } else { Err(bad(cmd, "Cineon Settings apply to DPX and Cineon footage only")) } + } + pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) { let before = f.frame_rate; self.apply_settings(f, guessed); @@ -718,6 +857,10 @@ impl Interpretation { if let Some(v) = self.preserve_rgb { f.preserve_rgb = v; } + // Checked before the edit (`Interpretation::check`). + if let Ok(Some(c)) = self.cineon.settings_for(f, "file.interpretFootage") { + f.cineon = c; + } // After the frame rate: timecode is read at the footage's rate. if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) { f.start_timecode = start; @@ -764,6 +907,11 @@ fn interpret(s: &mut Session, p: &Value) -> Result { return Err(bad("file.interpretFootage", "select footage")); } let it = Interpretation::parse(p, "file.interpretFootage")?; + let footage = items.iter().filter_map(|i| match s.project.item(*i).map(|x| &x.kind) { + Some(ItemKind::Footage(f)) => Some(f), + _ => None, + }); + it.check(footage, "file.interpretFootage")?; let guesses: Vec> = items .iter() .map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) { @@ -799,7 +947,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result { "item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha, "fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(), "linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(), - "frames": f.sequence_frames(), + "frames": f.sequence_frames(), "cineon": cineon_json(f), })), _ => None, }) @@ -830,6 +978,9 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result { f.linear_light = src.linear_light; f.preserve_rgb = src.preserve_rgb; f.color_profile = src.color_profile; + if f.is_cineon_file() && src.is_cineon_file() { + f.cineon = src.cineon; + } if src.native_rate.is_some() { if f.native_rate.is_none() { f.native_rate = Some(f.frame_rate); @@ -1035,7 +1186,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}", + "{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}", has_footage_selection, interpret ), diff --git a/crates/engine/src/commands/proxy.rs b/crates/engine/src/commands/proxy.rs index b7d644b94..358275112 100644 --- a/crates/engine/src/commands/proxy.rs +++ b/crates/engine/src/commands/proxy.rs @@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "the selected items have no proxy")); } let it = super::file_more::Interpretation::parse(p, C)?; + it.check(items.iter().filter_map(|i| s.project.item(*i).and_then(|x| x.proxy.as_ref()).map(|px| &px.footage)), C)?; let guesses: Vec<_> = items .iter() .map(|i| { @@ -229,7 +230,7 @@ pub fn specs() -> Vec { "Proxy...", ["File", "Interpret Footage"], None, - "{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}", + "{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, logConversion? (Cineon Settings of DPX and Cineon proxies, as file.interpretFootage), cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff?}", has_proxy, interpret_proxy ), diff --git a/crates/engine/src/tests_proxy.rs b/crates/engine/src/tests_proxy.rs index 82cd1dcfd..2480030ca 100644 --- a/crates/engine/src/tests_proxy.rs +++ b/crates/engine/src/tests_proxy.rs @@ -1,6 +1,6 @@ //! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and -//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through -//! a synthetic footage source. +//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light, Cineon Settings), +//! rendered through a synthetic footage source. use std::sync::Arc; @@ -35,6 +35,12 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] { "matte_black.png" => [0.0, 0.5, 0.0, 0.5], "matte_white.png" => [0.5, 1.0, 0.5, 0.5], "half.png" => [1.0, 1.0, 1.0, 0.25], + // A log film scan: code values 95, 685, 400 and 1023 across, as the DPX reader hands + // them out (code / 1023). + "scan.dpx" | "plate.dpx" => { + let v = [95.0, 685.0, 400.0, 1023.0][x.min(3) as usize] / 1023.0; + [v, v, v, 1.0] + } _ => { let _ = x; [1.0, 1.0, 1.0, 1.0] @@ -46,19 +52,24 @@ fn size_of(path: &str) -> (u32, u32) { match std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("") { "red16.png" => (16, 16), "blue8.png" => (8, 8), - "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4), + "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" | "scan.dpx" | "plate.dpx" => (4, 4), _ => (10, 10), } } impl FootageSource for Synthetic { - fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option> { + fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option> { let (w, h) = size_of(&f.path); let mut img = Image::new(w, h); for y in 0..h { for x in 0..w { // Decoders hand out premultiplied pixels (straight file colour × alpha). - let p = file_px(&f.path, x, y); + let mut p = file_px(&f.path, x, y); + // A log scan sequence: code 95 on even frames, 685 on odd ones. + if f.path.ends_with("frames.dpx") { + let v = if f.frame_rate.frame_at(t) % 2 == 0 { 95.0 } else { 685.0 } / 1023.0; + p = [v, v, v, 1.0]; + } img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]); } } @@ -70,7 +81,7 @@ struct Probe; impl crate::Importer for Probe { fn probe(&self, path: &str) -> Result { let (w, h) = size_of(path); - let video = path.ends_with(".mov"); + let video = path.ends_with(".mov") || path.ends_with("frames.dpx"); Ok(Footage { path: path.into(), kind: if video { FootageKind::Video } else { FootageKind::Still }, @@ -281,3 +292,96 @@ fn guess_alpha_invert_and_linear_light() { s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap(); assert!(foot(&s, w).preserve_rgb); } + +/// Color ▸ Cineon Settings: Logarithmic Conversion turns a DPX file's log code values into the +/// Current Gamma between the converted points (off, they show as stored); the converted points +/// are read and reported in the chosen Units; out-of-range values and footage that is not DPX +/// or Cineon are refused; Remember / Apply Interpretation and undo carry the settings. +#[test] +fn cineon_settings_convert_dpx_code_values() { + let mut s = session(); + let d = import(&mut s, "/m/scan.dpx"); + let other = import(&mut s, "/m/plate.dpx"); + let png = import(&mut s, "/m/red16.png"); + let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": d.0})).unwrap(); + let at = |s: &Session, x: i64| px(s, c, 0.0, RenderOpts::default(), x, 0)[0]; + let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; + // Off: the code values as stored. + assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); + // The checked path (control channel, MCP) accepts every Cineon parameter the doc lists. + let out = s.execute_checked("file.interpretFootage", json!({"items": [d.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); + assert_eq!(out["items"][0]["cineon"]["logConversion"], true, "{out}"); + assert!(near(at(&s, 0), 0.0) && near(at(&s, 1), 1.0), "{} {}", at(&s, 0), at(&s, 1)); + assert!(near(at(&s, 2), 0.26190), "code 400 at gamma 1.7: {}", at(&s, 2)); + s.execute("file.interpretFootage", json!({"item": d.0, "currentGamma": 1})).unwrap(); + assert!(near(at(&s, 2), 0.10251), "code 400 in linear light: {}", at(&s, 2)); + // Converted points, in 8-bit units. + let out = s + .execute_checked("file.interpretFootage", json!({"items": [d.0], "cineonUnits": "8bit", "convertedBlack": 16, "convertedWhite": 235, "currentGamma": 1, "tenBitBlack": 95, "tenBitWhite": 685})) + .unwrap(); + assert_eq!(out["items"][0]["cineon"]["units"], "8bit"); + assert!((out["items"][0]["cineon"]["convertedWhite"].as_f64().unwrap() - 235.0).abs() < 1e-3, "{out}"); + assert!(near(at(&s, 0), 16.0 / 255.0) && near(at(&s, 1), 235.0 / 255.0), "{} {}", at(&s, 0), at(&s, 1)); + // The form sends the points in the units it showed them in (`pointUnits`) with a new Units. + s.execute_checked("file.interpretFootage", json!({"items": [d.0], "pointUnits": "8bit", "cineonUnits": "float", "convertedWhite": 255})).unwrap(); + let f = foot(&s, d); + assert_eq!(f.cineon.units, effectcraft_project::CineonUnits::Float); + assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); + // Refused: a black point at or above the white point, values out of range, and footage + // that is not DPX or Cineon. Nothing changes. + let before = foot(&s, d).cineon; + for bad in [ + json!({"item": d.0, "tenBitBlack": 700}), + json!({"item": d.0, "tenBitWhite": 2000}), + json!({"item": d.0, "currentGamma": 0}), + json!({"item": d.0, "highlightRolloff": -1}), + json!({"item": d.0, "convertedWhite": 1.5}), + json!({"item": d.0, "cineonUnits": "12bit"}), + json!({"item": png.0, "logConversion": true}), + ] { + assert!(s.execute("file.interpretFootage", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(foot(&s, d).cineon, before); + assert!(foot(&s, png).cineon == effectcraft_project::CineonSettings::default()); + // With DPX and other footage selected, only the DPX file takes the settings. + s.execute("file.interpretFootage", json!({"items": [png.0, other.0], "logConversion": true})).unwrap(); + assert!(foot(&s, other).cineon.log_conversion && !foot(&s, png).cineon.log_conversion); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!foot(&s, other).cineon.log_conversion); + // Remember / Apply Interpretation. + s.state.project_selection = vec![d]; + s.execute("file.rememberInterpretation", json!({"item": d.0})).unwrap(); + s.execute("file.applyInterpretation", json!({"items": [other.0, png.0]})).unwrap(); + assert_eq!(foot(&s, other).cineon, before); + assert_eq!(foot(&s, png).cineon, effectcraft_project::CineonSettings::default()); + // Off again: as stored. + s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": false})).unwrap(); + assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); + // Frame Mix blends converted frames: halfway between code 95 (black) and 685 (white) at + // Current Gamma 1 is 0.5, not the conversion of the blended code values. + let seq = import(&mut s, "/m/frames.dpx"); + s.execute("file.interpretFootage", json!({"item": seq.0, "logConversion": true, "currentGamma": 1, "highlightRolloff": 0})).unwrap(); + s.execute("comp.new", json!({"name": "Blend", "width": 10, "height": 10, "frameRate": 20, "duration": 1})).unwrap(); + let bc = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": seq.0})).unwrap(); + s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap(); + let mid = px(&s, bc, 0.05, RenderOpts::default(), 5, 5)[0]; + assert!(near(mid, 0.5), "frame-mixed log frames: {mid}"); + // A DPX proxy of a composition: Interpret Proxy's settings convert it where it stands in. + s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/scan.dpx"})).unwrap(); + let proxied = |s: &Session, x: i64| px(s, outer, 0.0, RenderOpts::default(), x, 0)[0]; + assert!(near(proxied(&s, 1), 685.0 / 1023.0), "{}", proxied(&s, 1)); + s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); + assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1)); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index e3cbf8f51..c8f838a36 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1110,6 +1110,130 @@ pub enum AlphaMode { Ignore, } +/// Interpret Footage ▸ Cineon Settings ▸ Units: how the Converted Black and White Points are +/// shown and entered. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CineonUnits { + /// 10-bit code values (0–1023). + #[default] + TenBit, + /// 8-bit values (0–255). + EightBit, + /// Floating point (0–1). + Float, +} + +impl CineonUnits { + pub const ALL: [CineonUnits; 3] = [CineonUnits::TenBit, CineonUnits::EightBit, CineonUnits::Float]; + + /// The id commands use: `10bit`, `8bit` or `float`. + pub fn id(self) -> &'static str { + match self { + CineonUnits::TenBit => "10bit", + CineonUnits::EightBit => "8bit", + CineonUnits::Float => "float", + } + } + + pub fn label(self) -> &'static str { + match self { + CineonUnits::TenBit => "10-bit", + CineonUnits::EightBit => "8-bit", + CineonUnits::Float => "Float", + } + } + + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "10bit" | "10" => Some(CineonUnits::TenBit), + "8bit" | "8" => Some(CineonUnits::EightBit), + "float" | "32bit" => Some(CineonUnits::Float), + _ => None, + } + } + + /// A 10-bit value (0–1023) in these units. + pub fn from_ten_bit(self, v: f64) -> f64 { + match self { + CineonUnits::TenBit => v, + CineonUnits::EightBit => v * 255.0 / 1023.0, + CineonUnits::Float => v / 1023.0, + } + } + + /// A value in these units as a 10-bit value (0–1023). + pub fn to_ten_bit(self, v: f64) -> f64 { + match self { + CineonUnits::TenBit => v, + CineonUnits::EightBit => v * 1023.0 / 255.0, + CineonUnits::Float => v * 1023.0, + } + } +} + +/// Interpret Footage ▸ Cineon Settings of DPX and Cineon footage: how the file's logarithmic +/// (printing density) code values become the footage's pixels. Code values are counted in 10-bit +/// units whatever the file's bit depth. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CineonSettings { + /// Logarithmic Conversion: convert the code values from log to the Current Gamma. When it + /// is off, the code values are shown as stored. + pub log_conversion: bool, + /// Converted Black Point: the level the 10 Bit Black Point becomes (10-bit units). + pub converted_black: f32, + /// Converted White Point: the level the 10 Bit White Point becomes (10-bit units). + pub converted_white: f32, + /// 10 Bit Black Point: the code value of minimum density. + pub black: f32, + /// 10 Bit White Point: the code value of maximum density. + pub white: f32, + /// Current Gamma: the gamma the converted values are encoded with (1: linear light). + pub gamma: f32, + /// Highlight Rolloff (0–100): values near and above the white point ramp off gradually + /// instead of clipping; 0 keeps over-range values. + pub rolloff: f32, + /// Units of the Converted Black and White Points. + pub units: CineonUnits, +} + +impl Default for CineonSettings { + fn default() -> Self { + CineonSettings { + log_conversion: false, + converted_black: 0.0, + converted_white: 1023.0, + black: 95.0, + white: 685.0, + gamma: 1.7, + rolloff: 20.0, + units: CineonUnits::TenBit, + } + } +} + +impl CineonSettings { + /// The highest 10-bit value. + pub const MAX: f32 = 1023.0; + + /// A code value (0–1 of the 10-bit range) converted from log to the Current Gamma and the + /// converted black and white levels (0–1; above 1 for over-range highlights). + pub fn convert(&self, v: f32) -> f32 { + // Kodak printing density: 0.002 density per code value, negative gamma 0.6. + let exposure = |code: f32| 10f32.powf((code - self.white) * 0.002 / 0.6); + let offset = exposure(self.black); + let mut lin = ((exposure(v * Self::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0); + lin = lin.powf(1.0 / self.gamma.max(0.01)); + let knee = 1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99); + if knee < 1.0 && lin > knee { + let w = 1.0 - knee; + lin = knee + w * (1.0 - (-(lin - knee) / w).exp()); + } + let out = (self.converted_black + (self.converted_white - self.converted_black) * lin) / Self::MAX; + if out.is_finite() { out } else { 0.0 } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Footage { pub path: String, @@ -1156,6 +1280,9 @@ pub struct Footage { /// colour profile, Interpret As Linear Light and the working space don't apply. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub preserve_rgb: bool, + /// Interpret Footage ▸ Cineon Settings (DPX and Cineon files only; [`Footage::cineon_applies`]). + #[serde(default, skip_serializing_if = "is_default")] + pub cineon: CineonSettings, /// Data footage ([`FootageKind::Data`]): the file's text (JSON, CSV or TSV), kept in the /// project so expressions read it everywhere (and on the web). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1203,6 +1330,7 @@ impl Default for Footage { invert_alpha: false, linear_light: false, preserve_rgb: false, + cineon: CineonSettings::default(), data: None, layer: None, page: 0, @@ -1213,6 +1341,17 @@ impl Default for Footage { } impl Footage { + /// The file is DPX or Cineon (the formats Interpret Footage ▸ Cineon Settings applies to). + pub fn is_cineon_file(&self) -> bool { + let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + matches!(ext.as_str(), "dpx" | "cin") + } + + /// Cineon Settings ▸ Logarithmic Conversion applies: it is on and the file is DPX or Cineon. + pub fn cineon_applies(&self) -> bool { + self.cineon.log_conversion && self.is_cineon_file() + } + /// Interpret As Linear Light applies (Preserve RGB passes the file's values through). pub fn linear_light_applies(&self) -> bool { self.linear_light && !self.preserve_rgb diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index fe36c7724..5bfa46307 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -290,3 +290,46 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() { assert!(p.comp_contains(c[0], c[n - 1])); assert!(!p.comp_contains(c[n - 1], c[0])); } + +/// Interpret Footage ▸ Cineon Settings: the 10 Bit Black and White Points become the converted +/// points, Current Gamma 1 gives linear light, Highlight Rolloff 0 keeps over-range values and +/// a higher one ramps them off; the settings apply only to DPX and Cineon files, and projects +/// saved without them read the defaults. +#[test] +fn cineon_settings_convert_log_code_values() { + use crate::{CineonSettings, CineonUnits, Footage}; + let code = |v: f32| v / 1023.0; + let c = CineonSettings { log_conversion: true, rolloff: 0.0, ..Default::default() }; + assert!(c.convert(code(95.0)).abs() < 1e-6, "black point"); + assert!((c.convert(code(685.0)) - 1.0).abs() < 1e-5, "white point"); + // Code 400: (10^(-0.95) - 10^(-1.9667)) / (1 - 10^(-1.9667)) = 0.10251 linear, ^(1/1.7). + assert!((c.convert(code(400.0)) - 0.26190).abs() < 1e-4, "{}", c.convert(code(400.0))); + let linear = CineonSettings { gamma: 1.0, ..c }; + assert!((linear.convert(code(400.0)) - 0.10251).abs() < 1e-4, "{}", linear.convert(code(400.0))); + assert!(c.convert(code(1023.0)) > 1.5, "over range kept: {}", c.convert(code(1023.0))); + // The default rolloff (20) ramps the white point and above off, up to 1. + let rolled = CineonSettings { rolloff: 20.0, ..c }; + let (w, mid, top) = (rolled.convert(code(685.0)), rolled.convert(code(720.0)), rolled.convert(code(1023.0))); + assert!(w > 0.8 && w < mid && mid < top && top <= 1.0, "{w} {mid} {top}"); + let points = CineonSettings { converted_black: 64.0, converted_white: 940.0, ..c }; + assert!((points.convert(code(95.0)) - 64.0 / 1023.0).abs() < 1e-5); + assert!((points.convert(code(685.0)) - 940.0 / 1023.0).abs() < 1e-5); + // Units. + assert!((CineonUnits::EightBit.from_ten_bit(1023.0) - 255.0).abs() < 1e-9); + assert!((CineonUnits::Float.to_ten_bit(0.5) - 511.5).abs() < 1e-9); + assert_eq!(CineonUnits::parse("8-bit"), Some(CineonUnits::EightBit)); + assert!(CineonUnits::ALL.iter().all(|u| CineonUnits::parse(u.id()) == Some(*u))); + // DPX and Cineon files only, and only with Logarithmic Conversion on. + let on = CineonSettings { log_conversion: true, ..Default::default() }; + let dpx = Footage { path: "/m/plate.0001.DPX".into(), cineon: on, ..Default::default() }; + assert!(dpx.cineon_applies()); + assert!(Footage { path: "/m/scan.cin".into(), cineon: on, ..Default::default() }.cineon_applies()); + assert!(!Footage { path: "/m/still.png".into(), cineon: on, ..Default::default() }.cineon_applies()); + assert!(!Footage { path: "/m/plate.dpx".into(), ..Default::default() }.cineon_applies()); + // Saved only when changed; read back, and absent in older projects. + let plain = serde_json::to_value(Footage { path: "/m/a.dpx".into(), ..Default::default() }).unwrap(); + assert!(plain.get("cineon").is_none(), "{plain}"); + let saved = serde_json::to_string(&dpx).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap().cineon, on); + assert_eq!(serde_json::from_value::(plain).unwrap().cineon, CineonSettings::default()); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ebe0a018a..eabe9db4a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1266,7 +1266,7 @@ impl<'a> Renderer<'a> { if let Some(pf) = self.proxy_for(*item) && let Some(nc) = self.project.comp(*item) { - let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?; + let img = cineon_frame(self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?, pf); return Some(self.footage_buf(&img, pf, nc.width, nc.height)); } // Essential Properties overrides render the nested comp with this instance's @@ -1377,16 +1377,17 @@ impl<'a> Renderer<'a> { if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den); let i = field_rate.frame_at(t).max(0); - let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?; + let img = cineon_frame(self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?, f); let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; // Upper field = even lines (0, 2, …). let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f)))); } // Interpretation is per pixel but not linear: it runs on the frame before resampling. - let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies())); + let per_pixel = f.invert_alpha || f.linear_light_applies(); + let size = size.filter(|_| !(per_pixel || f.cineon_applies())); let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?; - if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } + if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { @@ -1400,6 +1401,10 @@ impl<'a> Renderer<'a> { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h); + if f.cineon_applies() { + let c = &f.cineon; + [c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h); + } h.finish() }); if let (Some(c), Some(k)) = (self.cache, key) @@ -1407,8 +1412,9 @@ impl<'a> Renderer<'a> { { return Some(Arc::new(b.img.clone())); } - let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float); - let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float); + // Log scans are converted before they blend: the conversion is not linear. + let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float).map(|a| cineon_frame(a, f)); + let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float).map(|b| cineon_frame(b, f)); if let (Some(a), Some(b)) = (a, b) { let img = blend_frames(mode, &a, &b, w as f32); if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { @@ -1418,7 +1424,7 @@ impl<'a> Renderer<'a> { } } } - self.footage.frame_with(item, f, t, size, float) + self.footage.frame_with(item, f, t, size, float).map(|img| cineon_frame(img, f)) } /// Source → masks, clamped/quantised to the project depth. @@ -2312,6 +2318,23 @@ pub fn field_frame(img: &Image, parity: usize) -> Image { out } +/// Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion converts the log code values of +/// a decoded (premultiplied) frame of `f` to the Current Gamma. When the conversion does not +/// apply, the frame is returned unchanged. +pub fn cineon_frame(img: Arc, f: &Footage) -> Arc { + if !f.cineon_applies() { + return img; + } + let cs = f.cineon; + let mut out = (*img).clone(); + out.data.par_iter_mut().for_each(|p| { + let a = p[3]; + let c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] }.map(|v| cs.convert(v)); + *p = [c[0] * a, c[1] * a, c[2] * a, a]; + }); + Arc::new(out) +} + /// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied) /// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]). pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image { diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 942bd9fe0..60d28a275 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -213,7 +213,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let layer = comp.and_then(|c| s.state.selected_layers.first().and_then(|l| c.layer(*l))); let t = s.time(); let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t); - let base = p.clone(); + let mut base = p.clone(); let (title, fields): (String, Vec) = match id { // Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). Dropped // files start at Settings ▸ Import ▸ Default Drag Import As. @@ -477,6 +477,30 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb), Field::bool("linearLight", "Interpret As Linear Light", f.linear_light), ]; + // Color ▸ Cineon Settings (DPX and Cineon files). The converted points show in the + // footage's Units, and `pointUnits` reads them back in those units when Units changes. + if f.is_cineon_file() { + use effectcraft_engine::project::CineonUnits; + let c = f.cineon; + let units: Vec<(&str, Value)> = CineonUnits::ALL.iter().map(|u| (u.label(), json!(u.id()))).collect(); + fields.extend([ + Field::note("cineonSettings", "Cineon Settings"), + Field::bool("logConversion", "Logarithmic Conversion", c.log_conversion), + Field::num("convertedBlack", "Converted Black Point", c.units.from_ten_bit(f64::from(c.converted_black))), + Field::num("convertedWhite", "Converted White Point", c.units.from_ten_bit(f64::from(c.converted_white))), + Field::num("tenBitBlack", "10 Bit Black Point", f64::from(c.black)), + Field::num("tenBitWhite", "10 Bit White Point", f64::from(c.white)), + Field::num("currentGamma", "Current Gamma", f64::from(c.gamma)), + Field::num("highlightRolloff", "Highlight Rolloff", f64::from(c.rolloff)), + Field::choice("cineonUnits", "Units", &units, CineonUnits::ALL.iter().position(|u| *u == c.units).unwrap_or(0)), + ]); + if !base.is_object() { + base = json!({}); + } + if let Some(m) = base.as_object_mut() { + m.insert("pointUnits".into(), json!(c.units.id())); + } + } // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in // After Effects), then Start Timecode. let mut timing = Vec::new(); @@ -1058,4 +1082,45 @@ mod tests { assert_eq!(r["path"], path.as_str()); assert!(std::fs::read_to_string(&path).unwrap().contains(">(); + app.session.state.project_selection = vec![png]; + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + assert!(!keys(&app).iter().any(|k| k == "logConversion"), "{:?}", keys(&app)); + app.session.state.project_selection = vec![dpx]; + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + for k in ["logConversion", "convertedBlack", "convertedWhite", "tenBitBlack", "tenBitWhite", "currentGamma", "highlightRolloff", "cineonUnits"] { + assert!(keys(&app).iter().any(|x| x == k), "{k}: {:?}", keys(&app)); + } + // Units ▸ 8-bit with Converted White Point still showing 1023 (10-bit). + let form = &mut app.dialog_state.form; + for f in form.fields.iter_mut() { + match (f.key.as_str(), &mut f.kind) { + ("cineonUnits", FieldKind::Choice { sel, .. }) => *sel = 1, + ("logConversion", FieldKind::Bool(b)) => *b = true, + _ => {} + } + } + let params = form.params(); + assert_eq!((params["pointUnits"].as_str(), params["cineonUnits"].as_str()), (Some("10bit"), Some("8bit")), "{params}"); + app.session.execute("file.interpretFootage", params).unwrap(); + let Some(ItemKind::Footage(f)) = app.session.project.item(dpx).map(|i| &i.kind) else { panic!("not footage") }; + assert!(f.cineon.log_conversion && f.cineon.units == CineonUnits::EightBit, "{:?}", f.cineon); + assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); + // Opened again, the point shows in 8-bit units. + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + let white = app.dialog_state.form.fields.iter().find(|f| f.key == "convertedWhite").map(|f| f.kind.clone()); + assert!(matches!(white, Some(FieldKind::Number { value, .. }) if (value - 255.0).abs() < 1e-6), "{white:?}"); + } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9e2249e9b..ad3b3c2fe 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -49,7 +49,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | BMP | R | yes | — | good | | WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects | | Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | none | good for import (#745); no Targa output | -| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), no DPX output; common in film VFX | +| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored, or converted by Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion) | none | partial: import (#756) and Cineon Settings; no DPX output; common in film VFX | | Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output | | SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) | | Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared | diff --git a/docs/footage.md b/docs/footage.md index bd99cbeb2..bd2f47e31 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -94,16 +94,21 @@ used, and layers that ran to the end of the footage keep doing so. | Fields, pixel aspect, loop | As in After Effects. | | **Assign Profile**, **Interpret As Linear Light** | The colour profile the file's values are in (the working space converts from it), and whether they are linear light. | | **Preserve RGB** | The file's values enter the composition unconverted: no colour profile, linear light or working-space conversion, and float files (OpenEXR, float TIFF) keep their linear values instead of being sRGB-encoded. Use it to convert chosen layers yourself with an OCIO effect, or to read data passes (depth, mist, normals) as numbers. | +| **Cineon Settings** (DPX and Cineon files) | With *Logarithmic Conversion* on, the file's log code values are converted to the *Current Gamma* (1.7 by default; 1 gives linear light): the *10 Bit Black Point* (95) becomes the *Converted Black Point* (0), and the *10 Bit White Point* (685) becomes the *Converted White Point* (1023). *Highlight Rolloff* (20) rolls values near and above the white point off gradually; at 0, a 32 bpc project keeps over-range values. *Units* shows the converted points as 10-bit, 8-bit or float values. With Logarithmic Conversion off (the default), the code values are shown as stored. | Settings ▸ Import ▸ **Report Missing Frames** (on by default) lists the missing frame numbers when a sequence imports; Interpret Footage lists them too. **File ▸ Interpret Footage ▸ Remember Interpretation / Apply Interpretation** copy the alpha, -fields, pixel aspect, loop and colour settings (Preserve RGB too) to other footage. +fields, pixel aspect, loop and colour settings (Preserve RGB too, and Cineon Settings between +DPX and Cineon files) to other footage. Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premultiplied", "startTimecode": 1001, "preserveRgb": true}` (timecode or a frame number; `"overrideStart": -false` goes back to Use Source File Timecode). +false` goes back to Use Source File Timecode). Cineon Settings: `{"logConversion": true, +"tenBitBlack": 95, "tenBitWhite": 685, "currentGamma": 1.7, "highlightRolloff": 20, +"cineonUnits": "8bit", "convertedBlack": 0, "convertedWhite": 255}` (converted points in +`cineonUnits`, else the footage's Units). ## Multi-layer OpenEXR diff --git a/docs/gaps.md b/docs/gaps.md index f8fcd64c2..1204f9ad8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -73,7 +73,7 @@ are grouped by area; the issue tracker has the rest. | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | | 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 628 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | -| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored unless Interpret Footage ▸ Cineon Settings converts it; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 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) | From 7efbe7d85c2de21f30c86337f3d07a3d677ddffe Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 11:48:41 -0700 Subject: [PATCH 09/50] Interpret Footage: the Footage panel, thumbnails and tracking show Cineon Settings The Footage panel, the Project panel thumbnail and the native-size source frames that tracking, mask tracking, scene detection and auto-trace read took DPX and Cineon frames straight from the file, so they showed the log code values while a composition showed the converted ones. Each now applies the footage's Cineon Settings, as the composition renderer does. --- crates/engine/src/commands/footage_panel.rs | 6 +++++ crates/engine/src/lib.rs | 2 +- crates/engine/src/tests_proxy.rs | 29 +++++++++++++++++++++ crates/engine/src/tracking.rs | 5 ++-- crates/ui-egui/src/panels/footage_panel.rs | 2 +- 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands/footage_panel.rs b/crates/engine/src/commands/footage_panel.rs index 1bd0d2236..26cc8e5c9 100644 --- a/crates/engine/src/commands/footage_panel.rs +++ b/crates/engine/src/commands/footage_panel.rs @@ -56,6 +56,12 @@ pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time:: } } +/// The frame the Footage panel shows: footage `f` at source time `t`, with its Cineon Settings +/// applied. +pub fn frame(s: &Session, item: ItemId, f: &effectcraft_project::Footage, t: Tick) -> Option> { + s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f)) +} + fn source_time(s: &Session, p: &Value, item: ItemId) -> Option { let (_, fr) = source_timing(s, item); if let Some(t) = f_p(p, "time") { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 382534a44..da96af6e0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1131,7 +1131,7 @@ impl Session { effectcraft_project::ItemKind::Footage(f) if f.has_video => { let f = f.clone(); Some(Box::new(move || { - let img = footage.frame(item, &f, Tick(0))?; + let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f); // Point-sample down to at most `max_side` on the long side. let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); diff --git a/crates/engine/src/tests_proxy.rs b/crates/engine/src/tests_proxy.rs index 2480030ca..d7e6e658c 100644 --- a/crates/engine/src/tests_proxy.rs +++ b/crates/engine/src/tests_proxy.rs @@ -385,3 +385,32 @@ fn cineon_settings_convert_dpx_code_values() { s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1)); } + +/// The Footage panel, the Project panel thumbnail and the tracker's source frames show DPX +/// footage with its Cineon Settings, as a composition renders it. +#[test] +fn cineon_settings_apply_to_footage_panel_thumbnail_and_tracking() { + let mut s = session(); + let d = import(&mut s, "/m/scan.dpx"); + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": d.0})).unwrap(); + s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": true, "highlightRolloff": 0})).unwrap(); + let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; + let rendered = px(&s, c, 0.0, RenderOpts::default(), 2, 0)[0]; + assert!(near(rendered, 0.26190), "code 400 at gamma 1.7: {rendered}"); + let f = match &s.project.item(d).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + let panel = crate::commands::footage_panel::frame(&s, d, &f, Tick::ZERO).unwrap().get(2, 0)[0]; + assert!(near(panel, rendered), "Footage panel: {panel}"); + let (w, _, rgba) = (s.thumbnail_job(d, Tick::ZERO, 64).unwrap())().unwrap(); + assert_eq!(w, 4); + let thumb = rgba[8] as f32 / 255.0; + assert!(near(thumb, rendered), "Project panel thumbnail: {thumb}"); + let comp = s.project.comp(c).unwrap(); + let r = crate::render::Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default()); + let (img, _) = crate::tracking::source_frame(&r, &s.project, c, comp, &comp.layers[0], Tick::ZERO, None).unwrap(); + assert!(near(img.get(2, 0)[0], rendered), "tracker source frame: {}", img.get(2, 0)[0]); +} diff --git a/crates/engine/src/tracking.rs b/crates/engine/src/tracking.rs index cfdd12441..dca6eb6cc 100644 --- a/crates/engine/src/tracking.rs +++ b/crates/engine/src/tracking.rs @@ -197,7 +197,8 @@ pub(crate) struct Work { } /// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels). -/// Footage at its native size is used straight from the footage source (no copy). +/// Footage at its native size is used straight from the footage source (no copy unless its +/// Cineon Settings convert it). pub(crate) fn source_frame( r: &Renderer, project: &Project, @@ -215,7 +216,7 @@ pub(crate) fn source_frame( && img.width == f.width && img.height == f.height { - return Some((img, [0.0; 2])); + return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2])); } let buf = r.layer_source(&ctx, layer)?; if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 { diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index a9e7cefb2..92a515995 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -62,7 +62,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid)); let tex = match cached { Some((k, tex, size)) if k == key => Some((tex, size)), - _ => app.session.footage.frame(view.item, f, view.time).map(|img| { + _ => effectcraft_engine::commands::footage_panel::frame(&app.session, view.item, f, view.time).map(|img| { let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8()); let tex = crate::frames::load_fitted(&ctx, "footage-panel", ci, egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height]))); From 6b549b7e4fcdc8792e66741b4e1490ba3f2606f4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:53:37 -0700 Subject: [PATCH 10/50] Docs: Cineon Settings white point converts exactly only without Highlight Rolloff The footage page said the 10 Bit White Point becomes the Converted White Point. That holds with Highlight Rolloff at 0; at the default 20 the top of the converted range is bent into a curve, as in the Cineon Converter effect, and the white point converts to a lower level. --- docs/footage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/footage.md b/docs/footage.md index bd2f47e31..4596a8546 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -94,7 +94,7 @@ used, and layers that ran to the end of the footage keep doing so. | Fields, pixel aspect, loop | As in After Effects. | | **Assign Profile**, **Interpret As Linear Light** | The colour profile the file's values are in (the working space converts from it), and whether they are linear light. | | **Preserve RGB** | The file's values enter the composition unconverted: no colour profile, linear light or working-space conversion, and float files (OpenEXR, float TIFF) keep their linear values instead of being sRGB-encoded. Use it to convert chosen layers yourself with an OCIO effect, or to read data passes (depth, mist, normals) as numbers. | -| **Cineon Settings** (DPX and Cineon files) | With *Logarithmic Conversion* on, the file's log code values are converted to the *Current Gamma* (1.7 by default; 1 gives linear light): the *10 Bit Black Point* (95) becomes the *Converted Black Point* (0), and the *10 Bit White Point* (685) becomes the *Converted White Point* (1023). *Highlight Rolloff* (20) rolls values near and above the white point off gradually; at 0, a 32 bpc project keeps over-range values. *Units* shows the converted points as 10-bit, 8-bit or float values. With Logarithmic Conversion off (the default), the code values are shown as stored. | +| **Cineon Settings** (DPX and Cineon files) | With *Logarithmic Conversion* on, the file's log code values are converted to the *Current Gamma* (1.7 by default; 1 gives linear light): the *10 Bit Black Point* (95) becomes the *Converted Black Point* (0), and with *Highlight Rolloff* at 0 the *10 Bit White Point* (685) becomes the *Converted White Point* (1023). *Highlight Rolloff* (20) bends the top 20% of the converted range into a curve, as the Cineon Converter effect does: the white point then converts to a level below the Converted White Point, and brighter code values stay below it. At 0, a 32 bpc project keeps over-range values. *Units* shows the converted points as 10-bit, 8-bit or float values. With Logarithmic Conversion off (the default), the code values are shown as stored. | Settings ▸ Import ▸ **Report Missing Frames** (on by default) lists the missing frame numbers when a sequence imports; Interpret Footage lists them too. From fa32010f67f00efdf3ea1a18205883096f52a3fe Mon Sep 17 00:00:00 2001 From: Shuvra Basak <95076423+becauseno7@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:22:48 +0000 Subject: [PATCH 11/50] CC Bubbles: bubbles at the layer's edges refract its edge pixels, not a pale fill (#2024) Near the borders a bubble's refraction (liquid) or reflection (metal) sample lands outside the layer. sample_bilinear returns transparent there, so the pale stand-in colour for transparent pixels showed through, a band of washed-out bubbles along every edge. Sample with edge pixels repeated instead, on the CPU and in the GPU kernel; the pale colour stays for genuinely transparent pixels. The CC Bubbles golden hashes are re-pinned (the half-resolution cases pad the buffer with transparent pixels and are unchanged). --- crates/effects/src/sim/tests.rs | 15 +++++++++++++ crates/effects/src/sim/weather.rs | 2 +- crates/effects/tests/it/sim_golden.rs | 32 +++++++++++++-------------- crates/gpu/src/shaders/fx_sim.wgsl | 2 +- 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs index e368c7bbe..d5de4cb74 100644 --- a/crates/effects/src/sim/tests.rs +++ b/crates/effects/src/sim/tests.rs @@ -97,6 +97,21 @@ fn bubbles_rise() { assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}"); } +/// #2024: bubbles near the layer's edges show its edge pixels, not the pale colour that stands +/// in for transparent ones. +#[test] +fn bubbles_near_edges_show_the_layer() { + let blue = Image::filled(40, 40, [0.0, 0.0, 1.0, 1.0]); + let pale = [0, 1].map(|reflection| { + let vals = [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))]; + let a = run("ec.sim.ccbubbles", &vals, blue.clone(), 0.3); + assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn"); + // Blue plus the white highlight keeps blue − red = alpha; the pale colour leaves 0.2 · alpha. + a.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count() + }); + assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)"); +} + #[test] fn drizzle_ripples() { let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6); diff --git a/crates/effects/src/sim/weather.rs b/crates/effects/src/sim/weather.rs index b93c0e48b..b58b39b5e 100644 --- a/crates/effects/src/sim/weather.rs +++ b/crates/effects/src/sim/weather.rs @@ -182,7 +182,7 @@ pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { // Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror). let k = if metal { 1.6 } else { -0.7 }; let nz = (1.0 - d2).max(0.0).sqrt(); - let p = src.sample_bilinear((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64); + let p = src.sample_bilinear_clamped((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64); let (c, sa) = unpremul(p); let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c }; let mut a = cov; diff --git a/crates/effects/tests/it/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs index 2a3a7c724..3932418ad 100644 --- a/crates/effects/tests/it/sim_golden.rs +++ b/crates/effects/tests/it/sim_golden.rs @@ -433,26 +433,26 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.sim.ccstarburst/1/32bpc/t1.5", 0x5c39f8b1bd0e9e43), ("ec.sim.ccstarburst/1/32bpc/t0.8", 0xdf20aa9dcf4b3e22), ("ec.sim.ccstarburst/1/32bpc/t2.2", 0x4fa441737bfcb64d), - ("ec.sim.ccbubbles/0/8bpc/t0.5", 0xdcd187e75eeb2fae), - ("ec.sim.ccbubbles/0/8bpc/t1.5", 0x835ca088747736ed), - ("ec.sim.ccbubbles/0/8bpc/t0.8", 0x5cbb1a6fbb77b41d), - ("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3b168068f5bfa533), - ("ec.sim.ccbubbles/0/32bpc/t0.5", 0x524b84d14edd77e2), - ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xfd7a17f0d40f2a27), - ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x70dd2b2b4ff0f7ce), - ("ec.sim.ccbubbles/0/32bpc/t2.2", 0xae21dd5cc2c2c07c), + ("ec.sim.ccbubbles/0/8bpc/t0.5", 0x6cb63df0c062fdf4), + ("ec.sim.ccbubbles/0/8bpc/t1.5", 0x5bb1350114234ac3), + ("ec.sim.ccbubbles/0/8bpc/t0.8", 0xaa824d49f12c4156), + ("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3528577358c4fc90), + ("ec.sim.ccbubbles/0/32bpc/t0.5", 0xdcd8e71222c13a9a), + ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xd09565e124d8f5db), + ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x91242f8e1fd0ad84), + ("ec.sim.ccbubbles/0/32bpc/t2.2", 0x1f9782131e62149d), ("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x931b4ca707604220), ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x21c25d4555abebe9), ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0x7e05972683359632), ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0xadc4f7219dab4921), - ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x89d27a2fe2011067), - ("ec.sim.ccbubbles/1/8bpc/t1.5", 0x2bbfee897bf5bf6e), - ("ec.sim.ccbubbles/1/8bpc/t0.8", 0x26cc704e7b06fffc), - ("ec.sim.ccbubbles/1/8bpc/t2.2", 0x959d331545d96479), - ("ec.sim.ccbubbles/1/32bpc/t0.5", 0xaf486cebfcbf4641), - ("ec.sim.ccbubbles/1/32bpc/t1.5", 0xe150e9fdb558036f), - ("ec.sim.ccbubbles/1/32bpc/t0.8", 0x6395f3967cb24284), - ("ec.sim.ccbubbles/1/32bpc/t2.2", 0x68a55894d86ffb9e), + ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980), + ("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0), + ("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c), + ("ec.sim.ccbubbles/1/8bpc/t2.2", 0xd365c88c12219802), + ("ec.sim.ccbubbles/1/32bpc/t0.5", 0x50f20497f40bb46d), + ("ec.sim.ccbubbles/1/32bpc/t1.5", 0x74760599ffba220a), + ("ec.sim.ccbubbles/1/32bpc/t0.8", 0x028de52900ed8618), + ("ec.sim.ccbubbles/1/32bpc/t2.2", 0xaf0e21d9a7f625fe), ("ec.sim.ccdrizzle/0/8bpc/t0.5", 0x2bdfbc17d92b65b5), ("ec.sim.ccdrizzle/0/8bpc/t1.5", 0x8c354c46f99e35d3), ("ec.sim.ccdrizzle/0/8bpc/t0.8", 0x891f331ba47ee557), diff --git a/crates/gpu/src/shaders/fx_sim.wgsl b/crates/gpu/src/shaders/fx_sim.wgsl index d936861d3..8ef3035fe 100644 --- a/crates/gpu/src/shaders/fx_sim.wgsl +++ b/crates/gpu/src/shaders/fx_sim.wgsl @@ -180,7 +180,7 @@ fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade { } let k = select(-0.7, 1.6, P.u[1].y != 0u); let nz = sqrt(max(1.0 - d2, 0.0)); - let s = fxm_unpremul(sample_bilinear(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k)); + let s = fxm_unpremul(sample_bilinear_clamped(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k)); var c = s.xyz; if (s.w <= 0.0) { c = vec3(0.6, 0.7, 0.8); From 62cd2364d5457734ea6a54579b91aa0398fcb84e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 17:58:34 -0700 Subject: [PATCH 12/50] Shapes: add the Wiggle Transform path operation, which wiggles paths and each Repeater copy A shape layer's Contents > Add menu had every After Effects path operation except Wiggle Transform. It is now a shape item (layer.addShapeItem kind wiggler, "ADBE Vector Filter - Wiggler" for scripts) with Wiggles/Second, Correlation, Temporal Phase, Spatial Phase, Random Seed and a Transform group whose Anchor Point, Position, Scale and Rotation set how far each transformation wiggles. The values come from the Wiggly selector's noise, with each Repeater copy as one unit: the copies of a Repeater above the operation, in its group or a nested one, wiggle independently as far as Correlation allows, and the copies of a Repeater below it wiggle the same way. A paint above it whose paths all move by one matrix moves its gradient and stroke scale with them. The layer cache keys a layer with a Wiggle Transform by time. --- ROADMAP.md | 5 + crates/engine/src/commands/layer.rs | 2 +- crates/engine/src/commands/paths.rs | 2 +- crates/project/src/build.rs | 16 ++ crates/render/src/cache.rs | 2 +- crates/render/src/shapes.rs | 95 ++++++++- crates/render/src/shapes/tests.rs | 196 ++++++++++++++++++ crates/script/src/matchnames.rs | 8 + crates/script/src/tests.rs | 25 +++ crates/ui-egui/src/panels/timeline.rs | 1 + crates/ui-egui/tests/it/ui_timeline_shapes.rs | 16 ++ 11 files changed, 362 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f4c99d61f..0a7b8946b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -117,6 +117,11 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-24: Shape layers have the Wiggle Transform path operation (Contents ▸ Add, or + `layer.addShapeItem {kind: "wiggler"}`): it wiggles the anchor point, position, scale and + rotation of the paths above it by random amounts that change over time, up to the Transform + values it holds. The copies of a Repeater above it wiggle independently as far as its + Correlation allows; with the Repeater below it, they wiggle together. - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar path into an editable Path 1 with the same geometry, as After Effects does; it used to apply to mask RotoBezier only, so a parametric shape path could not be converted at all diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 13037c88a..9cb3f3290 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1828,7 +1828,7 @@ pub fn specs() -> Vec { "Add (Shape)", [], None, - "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", + "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|wiggler|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", has_layers, add_shape_item ), diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/commands/paths.rs index 96767d967..5272c4240 100644 --- a/crates/engine/src/commands/paths.rs +++ b/crates/engine/src/commands/paths.rs @@ -360,7 +360,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result { const SHAPE_ITEMS: &[&str] = &[ "group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag", - "wiggle", "merge", "front", "bevel", "side", "back", + "wiggle", "wiggler", "merge", "front", "bevel", "side", "back", ]; /// Selected shape items (contents entries) of shape layers: (layer, uid). diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 935c03b0e..f5d9eb934 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -595,6 +595,22 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option { .with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent)) .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))), + "wiggler" | "wiggleTransform" => { + // The Transform group holds how far each transformation wiggles. + let tr = ids + .group("transform", "Transform") + .with(ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point)) + .with(ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point)) + .with(ids.prop("scale", "Scale", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent)) + .with(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)); + indexed(ids.group("wiggler", "Wiggle Transform 1")) + .with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0))) + .with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent)) + .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))) + .with(tr) + } "merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[ "Merge", "Add", diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 89e9fd1c9..d50657b38 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -458,7 +458,7 @@ fn time_dependent(layer: &Layer, effects: bool) -> bool { if matches!(layer.source, LayerSource::Shape) && let Some(c) = layer.props.sub("contents") { - return group_has(c, "wiggle"); + return group_has(c, "wiggle") || group_has(c, "wiggler"); } // Wiggly selectors move with time; Expression selectors evaluate per character (their // property value alone doesn't capture the result). diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index f8de5a290..293dd8bc2 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -2,8 +2,8 @@ //! with the After Effects stacking semantics: //! //! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag, -//! Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including -//! the paths of nested groups; +//! Wiggle Paths, Wiggle Transform, Merge Paths, Repeater) act on all paths above them in the +//! same group, including the paths of nested groups; //! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified //! by every operator in the stack* — a Trim Paths below a Stroke still trims it; //! - items higher in the list draw on top; @@ -17,7 +17,9 @@ use effectcraft_keyframe::{Gradient, Value}; use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke}; use effectcraft_project::{Layer, Node, PropGroup}; use effectcraft_raster::{Image, Mask}; +use effectcraft_text::selectors::{self, Wiggly}; use rayon::prelude::*; +use std::collections::{HashMap, HashSet}; use crate::eval::EvalCtx; @@ -185,9 +187,84 @@ fn used_slots(live: &[usize], draws: &[Draw]) -> Vec { v } +/// Wiggle Transform: moves the live slots by a random offset of the Transform group's Anchor +/// Point, Position, Scale and Rotation (the most each one wiggles), changing over time. Its +/// Wiggles/Second, Correlation, Temporal Phase, Spatial Phase and Random Seed work as the Wiggly +/// Selector's do, with each Repeater copy (`copy_of`) as one unit: the copies of a Repeater above +/// the operation move on their own as far as Correlation allows, and the copies of a Repeater +/// below it move as one. The four transformations of one operation move together. +#[allow(clippy::too_many_arguments)] +fn wiggle_transform( + ctx: &EvalCtx, + layer: &Layer, + g: &PropGroup, + tr: &PropGroup, + arena: &mut [BezPath], + live: &[usize], + copy_of: &HashMap, + draws: &mut [Draw], +) { + let w = Wiggly { + max: 1.0, + min: -1.0, + wiggles_per_second: ctx.f(layer, g, "speed", 2.0), + correlation: ctx.f(layer, g, "correlation", 50.0) / 100.0, + temporal_phase: ctx.f(layer, g, "phase", 0.0), + spatial_phase: ctx.f(layer, g, "spatialPhase", 0.0), + lock_dimensions: false, + seed: ctx.f(layer, g, "seed", 0.0).round() as i64 as u64, + }; + let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]); + let pos = ctx.v2(layer, tr, "position", [0.0; 2]); + let scale = ctx.v2(layer, tr, "scale", [0.0; 2]); + let rot = ctx.f(layer, tr, "rotation", 0.0); + let t = layer.layer_time(ctx.time).seconds(); + let matrix = |k: usize| { + let (x, y) = (selectors::wiggly_value(&w, t, k, 0), selectors::wiggly_value(&w, t, k, 1)); + Mat3::translate(vec2(pos[0] * x, pos[1] * y)) + * Mat3::rotate_deg(rot * x) + * Mat3::scale(vec2(1.0 + scale[0] * x / 100.0, 1.0 + scale[1] * y / 100.0)) + * Mat3::translate(vec2(-anchor[0] * x, -anchor[1] * y)) + }; + let mut by_copy: HashMap = HashMap::new(); + let mut done: HashSet = HashSet::new(); + for &s in live { + if !done.insert(s) { + continue; + } + let k = copy_of.get(&s).copied().unwrap_or(0); + let m = *by_copy.entry(k).or_insert_with(|| matrix(k)); + arena[s] = mat_path(&arena[s], &m); + } + // A paint above the operation whose paths all moved by one matrix moves its gradient and + // stroke scale with them, as a group transform does. A paint over copies that moved by + // different matrices stays one paint (its fill rule and opacity apply to all of them at once) + // and keeps its gradient and stroke scale. + for d in draws { + let mut ms = d.slots.iter().map(|s| done.contains(s).then(|| by_copy.get(©_of.get(s).copied().unwrap_or(0)).copied()).flatten()); + let Some(Some(m)) = ms.next() else { continue }; + if ms.all(|x| x == Some(m)) { + d.xf = m * d.xf; + } + } +} + /// Collect the draws of a contents group and the path slots live at its end (in group space). /// `arena` holds every path slot; operators rewrite slots in place. fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec) -> (Vec, Vec) { + collect_in(ctx, layer, contents, arena, &mut Copies::default()) +} + +/// The Repeater copy each path slot belongs to, for Wiggle Transform: `of` maps a slot a Repeater +/// made to its copy's id (slots no Repeater made are copy 0). Ids are unique across the layer's +/// groups, so copies made in a nested group stay apart in the groups around it. +#[derive(Default)] +struct Copies { + of: HashMap, + next: usize, +} + +fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec, copy_ids: &mut Copies) -> (Vec, Vec) { let mut draws: Vec = Vec::new(); let mut live: Vec = Vec::new(); let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32; @@ -329,6 +406,10 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { + let Some(tr) = g.sub("transform") else { continue }; + wiggle_transform(ctx, layer, g, tr, arena, &live, ©_ids.of, &mut draws); + } "merge" => { let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode")); let input: Vec = live.iter().map(|&i| arena[i].clone()).collect(); @@ -341,7 +422,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let Some(inner) = g.sub("contents") else { continue }; - let (cd, cl) = collect(ctx, layer, inner, arena); + let (cd, cl) = collect_in(ctx, layer, inner, arena, copy_ids); let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0)); for i in used_slots(&cl, &cd) { arena[i] = mat_path(&arena[i], &m); @@ -378,12 +459,20 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec> = Vec::with_capacity(n); + // Each copy of each earlier copy (a second Repeater) gets an id of its own. + let mut ids: HashMap<(usize, usize), usize> = HashMap::new(); for i in 0..n { let m = step(i as f64 + offset); let mut map = std::collections::HashMap::new(); for &s in &base_slots { arena.push(mat_path(&arena[s], &m)); map.insert(s, arena.len() - 1); + let base = copy_ids.of.get(&s).copied().unwrap_or(0); + let id = *ids.entry((i, base)).or_insert_with(|| { + copy_ids.next += 1; + copy_ids.next + }); + copy_ids.of.insert(arena.len() - 1, id); } live.extend(base_live.iter().map(|s| map[s])); copy_maps.push(map); diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index 1dbf67a7a..23141caa3 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -270,6 +270,202 @@ fn wiggle_paths_vary_over_time_deterministically() { assert_ne!(a.data, c.data); } +/// Alpha-weighted centroid of the pixels in columns `x0..x1`. +fn centroid(img: &Image, x0: i64, x1: i64) -> [f32; 2] { + let (mut sx, mut sy, mut sa) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height as i64 { + let a = alpha(img, x, y); + sx += a * x as f32; + sy += a * y as f32; + sa += a; + } + } + [sx / sa, sy / sa] +} + +fn wiggle_transform(ids: &mut Ids, params: &[(&str, Value)]) -> PropGroup { + op(ids, "wiggler", params) +} + +#[test] +fn wiggle_transform_moves_paths_over_time() { + let run = |position: [f64; 2], t: f64| { + let (p, cid) = shape_comp(|ids| { + let w = wiggle_transform(ids, &[("transform/position", Value::Vec2(position))]); + vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)] + }); + render_frame(&p, cid, Tick::from_seconds_f64(t), 1.0) + }; + // The defaults wiggle nothing: the square stays centered on pixel 99.5, 49.5. + let rest = [99.5, 49.5]; + assert_eq!(centroid(&run([0.0, 0.0], 0.37), 0, 200), rest); + let a = run([15.0, 15.0], 0.37); + assert_eq!(a.data, run([15.0, 15.0], 0.37).data, "the same time gives the same frame"); + let b = run([15.0, 15.0], 1.21); + let (ca, cb) = (centroid(&a, 0, 200), centroid(&b, 0, 200)); + assert!(ca != rest && ca != cb, "{ca:?} {cb:?}"); + for c in [ca, cb] { + assert!((c[0] - rest[0]).abs() <= 15.01 && (c[1] - rest[1]).abs() <= 15.01, "{c:?}"); + } + // A translation keeps the square's area. + assert!((coverage(&a) - 400.0).abs() < 1.0, "{}", coverage(&a)); + // The layer cache keys a layer with a Wiggle Transform by time. + let (p, cid) = shape_comp(|ids| vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), wiggle_transform(ids, &[]), build::shape_fill(ids, RED)]); + let comp = p.comp(cid).unwrap(); + let key = |t: f64| crate::cache::layer_key(&crate::eval::EvalCtx::new(&p, cid, comp, Tick::from_seconds_f64(t)), &comp.layers[0], 1.0, false, false); + assert_ne!(key(0.37), key(1.21)); +} + +#[test] +fn wiggle_transform_scales_and_rotates() { + let run = |params: &[(&str, Value)]| { + let (p, cid) = shape_comp(|ids| { + let w = wiggle_transform(ids, params); + vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)] + }); + render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0) + }; + let scaled = coverage(&run(&[("transform/scale", Value::Vec2([50.0, 50.0]))])); + assert!((scaled - 1600.0).abs() > 20.0, "{scaled}"); + let rotated = run(&[("transform/rotation", Value::Scalar(45.0))]); + assert!((coverage(&rotated) - 1600.0).abs() < 2.0, "{}", coverage(&rotated)); + assert!(alpha(&rotated, 81, 31) < 0.5 || alpha(&rotated, 119, 31) < 0.5, "a corner turned away"); +} + +/// A gradient above a Wiggle Transform moves with the paths it paints, as under a group transform. +#[test] +fn wiggle_transform_moves_the_gradient_with_the_path() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), + build::shape_gradient_fill(ids, false, [-20.0, 0.0], [20.0, 0.0], g), + wiggle_transform(ids, &[("transform/position", Value::Vec2([60.0, 0.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + let cols: Vec = (0..200).filter(|&x| alpha(&img, x, 50) > 0.99).collect(); + let (l, r) = (cols[0], cols[cols.len() - 1]); + assert!((l - 80).abs() > 3, "the square moved: {l}"); + let (cl, cr) = (img.get(l, 50), img.get(r, 50)); + assert!(cl[0] < 0.1 && cr[0] > 0.9, "the ramp runs edge to edge: {cl:?} {cr:?}"); +} + +/// A gradient that paints every copy of a Repeater moves with the copies when they wiggle alike +/// (Correlation 100). +#[test] +fn wiggle_transform_moves_a_gradient_shared_by_repeater_copies() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [60.0, 0.0]), + build::shape_gradient_fill(ids, false, [0.0, -10.0], [0.0, 10.0], g), + wiggle_transform(ids, &[("correlation", Value::Scalar(100.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + for cx in [40, 100, 160] { + let rows: Vec = (0..100).filter(|&y| alpha(&img, cx, y) > 0.99).collect(); + let (t, b) = (rows[0], rows[rows.len() - 1]); + assert!((t - 40).abs() > 3, "copy at {cx} moved: {t}"); + let (ct, cb) = (img.get(cx, t), img.get(cx, b)); + assert!(ct[0] < 0.1 && cb[0] > 0.9, "copy at {cx}: {ct:?} {cb:?}"); + } +} + +/// A gradient over copies that wiggle on their own keeps its place: every copy shows the ramp's +/// middle at the group's origin row. +#[test] +fn wiggle_transform_keeps_a_gradient_over_copies_wiggling_apart() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [20.0, 40.0], [-60.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [60.0, 0.0]), + build::shape_gradient_fill(ids, false, [0.0, -20.0], [0.0, 20.0], g), + wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + for cx in [40, 100, 160] { + let c = img.get(cx, 50); + assert!(c[3] > 0.99 && (c[0] - 0.5).abs() < 0.05, "copy at {cx}: {c:?}"); + } +} + +/// Copies wiggling on their own under one 50 % fill stay one paint: where they overlap, the fill +/// covers them once. +#[test] +fn wiggle_transform_keeps_one_fill_over_overlapping_copies() { + let (p, cid) = shape_comp(|ids| { + let mut fill = build::shape_fill(ids, RED); + set(&mut fill, "opacity", Value::Scalar(50.0)); + vec![ + build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [20.0, 0.0]), + fill, + wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + let top = img.data.iter().map(|px| px[3]).fold(0.0f32, f32::max); + assert!((top - 0.5).abs() < 0.01, "{top}"); +} + +/// A long stack of Repeaters, each cleared by a zero-copy Repeater, still renders the last Repeater's copies. +#[test] +fn long_repeater_stack_with_cleared_copies_renders() { + let (p, cid) = shape_comp(|ids| { + let mut items = vec![]; + for _ in 0..70 { + items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0)); + items.push(build::shape_repeater(ids, 3.0, [10.0, 0.0])); + items.push(build::shape_repeater(ids, 0.0, [10.0, 0.0])); + } + items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0)); + items.push(build::shape_repeater(ids, 3.0, [30.0, 0.0])); + items.push(wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])); + items.push(build::shape_fill(ids, RED)); + items + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + assert!((coverage(&img) - 1200.0).abs() < 2.0, "{}", coverage(&img)); +} + +/// Copies a Repeater makes above a Wiggle Transform wiggle on their own (Correlation 0) or as one +/// (Correlation 100); with the Wiggle Transform above the Repeater every copy wiggles the same. +#[test] +fn wiggle_transform_and_repeater_order() { + let offsets = |wiggle_first: bool, correlation: f64| { + let (p, cid) = shape_comp(|ids| { + let rect = build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0); + let rep = build::shape_repeater(ids, 3.0, [60.0, 0.0]); + let w = wiggle_transform(ids, &[("correlation", Value::Scalar(correlation)), ("transform/position", Value::Vec2([0.0, 15.0]))]); + let fill = build::shape_fill(ids, RED); + if wiggle_first { vec![rect, w, rep, fill] } else { vec![rect, rep, w, fill] } + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0) + }; + let own = offsets(false, 0.0); + assert!((own[0] - own[1]).abs() > 0.5 || (own[1] - own[2]).abs() > 0.5, "copies wiggle on their own: {own:?}"); + // A Repeater inside a group above the Wiggle Transform: its copies still wiggle on their own. + let (p, cid) = shape_comp(|ids| { + let inner = vec![build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), build::shape_repeater(ids, 3.0, [60.0, 0.0]), build::shape_fill(ids, RED)]; + let group = build::shape_group(ids, "Repeated", inner); + vec![group, wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + let nested = [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0); + assert!((nested[0] - nested[1]).abs() > 0.5 || (nested[1] - nested[2]).abs() > 0.5, "nested copies: {nested:?}"); + for same in [offsets(false, 100.0), offsets(true, 0.0)] { + assert!(same[0].abs() > 0.5, "{same:?}"); + assert!((same[0] - same[1]).abs() < 0.05 && (same[1] - same[2]).abs() < 0.05, "copies wiggle as one: {same:?}"); + } +} + #[test] fn zigzag_and_twist_render() { let (p, cid) = shape_comp(|ids| { diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index 205697b60..9bbe0ac2f 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -147,6 +147,7 @@ pub const SHAPE_ITEMS: &[(&str, &str)] = &[ ("trim", "ADBE Vector Filter - Trim"), ("twist", "ADBE Vector Filter - Twist"), ("wiggle", "ADBE Vector Filter - Roughen"), + ("wiggler", "ADBE Vector Filter - Wiggler"), ("zigzag", "ADBE Vector Filter - Zigzag"), ]; @@ -208,6 +209,12 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[ ("pucker", "amount", "ADBE Vector PuckerBloat Amount"), ("twist", "angle", "ADBE Vector Twist Angle"), ("twist", "center", "ADBE Vector Twist Center"), + ("wiggler", "speed", "ADBE Vector Xform Temporal Freq"), + ("wiggler", "correlation", "ADBE Vector Correlation"), + ("wiggler", "phase", "ADBE Vector Temporal Phase"), + ("wiggler", "spatialPhase", "ADBE Vector Spatial Phase"), + ("wiggler", "seed", "ADBE Vector Random Seed"), + ("wiggler", "transform", "ADBE Vector Wiggler Transform"), ("merge", "mode", "ADBE Vector Merge Type"), ]; @@ -393,6 +400,7 @@ pub fn shape_kind(name: &str) -> Option<&'static str> { "twist" => "twist", "zigzag" => "zigzag", "wigglepaths" => "wiggle", + "wiggletransform" => "wiggler", "mergepaths" => "merge", _ => return None, }) diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 82f8ad289..7306ffb61 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -321,6 +321,31 @@ fn match_names_and_lookups() { assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0])); } +/// Scripts add a Wiggle Transform by its match name or display name and reach its properties. +#[test] +fn wiggle_transform_by_match_name() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("W", 200, 200, 1, 2, 30); + var sh = c.layers.addShape(); + var root = sh.property("ADBE Root Vectors Group"); + var w = root.addProperty("ADBE Vector Filter - Wiggler"); + var byName = root.addProperty("Wiggle Transform"); + w.property("ADBE Vector Wiggler Transform").property("Position").setValue([20, 10]); + [ + w.matchName, w.name, byName.matchName, w.property("ADBE Vector Xform Temporal Freq").value, + w.property("ADBE Vector Spatial Phase").name, w.property("ADBE Vector Wiggler Transform").name + ] + "#, + ); + assert_eq!(o.result, json!(["ADBE Vector Filter - Wiggler", "Wiggle Transform 1", "ADBE Vector Filter - Wiggler", 2, "Spatial Phase", "Transform"])); + let (_, c) = comp_named(&s, "W"); + let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap(); + assert_eq!(sh.props.prop("contents/wiggler/transform/position").unwrap().value, KV::Vec2([20.0, 10.0])); +} + #[test] fn keyframes_eases_and_interpolation() { let mut s = session(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 2231a080f..037a10f97 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3884,6 +3884,7 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[ ("trim", "Trim Paths"), ("twist", "Twist"), ("wiggle", "Wiggle Paths"), + ("wiggler", "Wiggle Transform"), ("zigzag", "Zig Zag"), ]; diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index bc42b4461..9973ba654 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -75,6 +75,22 @@ fn shape_contents_add_menu_adds_path_operations() { assert_eq!(items, ["group", "trim"]); } +#[test] +fn shape_contents_add_menu_adds_wiggle_transform() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap(); + let contents = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().uid; + let mut h = harness(s); + open_layer(&mut h, l); + click(&mut h, &format!("timeline.group.{contents}.add")); + assert!(h.get_by_label("Zig Zag").rect().max.y <= 1000.0, "the whole menu fits in the window"); + let entry = h.get_by_label("Wiggle Transform").rect(); + click_at(&mut h, entry.center()); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let items: Vec<&str> = layer.props.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect(); + assert_eq!(items, ["group", "wiggler"]); +} + /// A shape group's inner Contents is elided in After Effects: opening the group lists its items /// (here the path) directly, with no "Contents" row between them. #[test] From 3f88c72a43a53275aaa87104fc9b1356cffbafe5 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 22:18:28 -0400 Subject: [PATCH 13/50] Expressions: a layer is a callable property group In After Effects a layer is a property group: thisLayer("transform"), thisComp.layer("Other")("effects")("Blur"), thisLayer(1). In EffectCraft's expression runtime a Layer was a plain object, so those failed with "TypeError: not a callable function", including the references the pick whip writes with Compact English off (thisComp.layer("Other")("effects")("ec.noise.fractal")(...)). A Layer is now callable like a property group (built as __mkGroup builds groups), looking up its own properties by match name, name or index, and keeps its methods, name and index. The places that took a layer for a plain object (the result reading as the layer's index, thisComp.layer(layer, offset), a Layer Control's value handed through the property proxy) accept it. Test: layers_are_callable_property_groups (fails before). --- crates/expr/src/prelude.js | 21 ++++++++++++++++----- crates/expr/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 6611d36aa..7fd746bf1 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -44,7 +44,7 @@ function __finish(r) { function __result(r) { r = __v(r); - if (r !== null && typeof r === 'object' && r.__isLayer) return r.index; + if (r !== null && r !== undefined && r.__isLayer) return r.index; if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out(); // `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while // host requests can still be answered and the script re-run. @@ -464,7 +464,7 @@ Comp.prototype = { return __err('Composition "' + this.name + '" has no active camera'); }, layer: function (a, rel) { - if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0)); + if (a !== null && a !== undefined && a.__isLayer) return this.layer(a.index + (rel || 0)); var id = __h(__LAYER, this.__c, a); if (id === null) __err(typeof a === 'string' ? 'Layer named "' + a + '" does not exist' : 'Layer index ' + a + ' out of range'); return new Layer(this.__c, id); @@ -542,7 +542,17 @@ var thisProject = { // Layer ----------------------------------------------------------------------------------------- -function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; } +// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another layer's +// `("effects")("Slider Control")`, `thisLayer(1)` (what the pick whip writes with Compact English +// off). Callable like a group (`__mkGroup`), with the Layer methods. +function Layer(c, l) { + var f = function (k) { return f.__group(k); }; + delete f.name; + delete f.length; + Object.setPrototypeOf(f, Layer.prototype); + f.__c = c; f.__l = l; f.__i = null; + return f; +} function __m3(m, p) { var x = p[0] || 0, y = p[1] || 0; @@ -846,8 +856,9 @@ var __propHandler = { var v = t.value; if (v === null || v === undefined) return undefined; var x = v[k]; - // Property groups are callable proxies: hand them over as they are, not bound like methods (#809). - if (x?.__isGroup) return x; + // Property groups and layers are callable: hand them over as they are, not bound like methods + // (#809). + if (x?.__isGroup || x?.__isLayer) return x; return typeof x === 'function' ? x.bind(v) : x; }, }; diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 7d2790255..2634c22ac 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1370,4 +1370,21 @@ fn time_to_frames_preserves_coercion_and_small_durations() { } } +/// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another +/// layer's `("effects")("Slider Control")` or `(1)`, as the pick whip writes them with Compact +/// English off (they failed: "not a callable function"). It is still a layer. +#[test] +fn layers_are_callable_property_groups() { + let f = fx(); + assert_close!(f.num("thisLayer(\"transform\")(\"opacity\")", 0.0), 100.0); + assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"ec.control.slider\")(\"slider\")", 0.0), 42.0); + assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"Slider Control\")(1)", 0.0), 42.0); + assert_close!(f.num("thisComp.layer(1)(\"effects\")(1)(1)", 0.0), 42.0); + // Its own name, index and methods, and a layer as the result still reads as its index. + assert_close!(f.num("thisLayer.name == \"A\" ? thisComp.layer(\"B\").index : 0", 0.0), 2.0); + assert_close!(f.num("thisComp.layer(\"B\")", 0.0), 2.0); + assert_close!(f.num("thisComp.layer(thisComp.layer(\"B\"), -1).index", 0.0), 1.0); + assert!(f.err("thisLayer(\"nope\")").contains("no property"), "{}", f.err("thisLayer(\"nope\")")); +} + mod property_group; From ca590aaeadc6d985689a4cafbc0997e93cbb2ee2 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Fri, 16 Oct 2026 14:21:42 -0400 Subject: [PATCH 14/50] Expressions: a layer is made once per evaluation, and only functions are checked for being one A Layer is now a callable function with its own prototype, which costs more to make than the plain object it was. __layer(c, l) makes each layer once per evaluation (kept in __S, which __begin resets), so an expression naming the same layer many times builds it once. That covers thisComp.layer(...) in a loop, .parent, a Layer Control's value and thisLayer. The checks for a layer (the result, thisComp.layer(layer, offset), the property proxy) read __isLayer only on functions. Numbers and other primitives are no longer boxed to look the property up. Timing (release, Windows, medians of 2000 evaluations, PR vs main): - one evaluation building one layer: about 22 vs 16 us; - thisLayer.transform.opacity: about 200 vs 170 us; - 20000 thisComp.layer(1) lookups in one evaluation: 95 vs 138 ms. --- crates/expr/src/prelude.js | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 7fd746bf1..4517fef1d 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -26,7 +26,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { textIndex = ti; textTotal = tt; selectorValue = sv; - __S = { c: c, l: l, path: path, uid: uid, idx: idx, fd: fd, comp: null, layer: null, prop: null, vars: {} }; + __S = { c: c, l: l, path: path, uid: uid, idx: idx, fd: fd, comp: null, layer: null, prop: null, vars: {}, layers: {} }; __rc = 0; __seed = 0; __timeless = false; @@ -44,7 +44,7 @@ function __finish(r) { function __result(r) { r = __v(r); - if (r !== null && r !== undefined && r.__isLayer) return r.index; + if (typeof r === 'function' && r.__isLayer) return r.index; if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out(); // `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while // host requests can still be answered and the script re-run. @@ -464,10 +464,10 @@ Comp.prototype = { return __err('Composition "' + this.name + '" has no active camera'); }, layer: function (a, rel) { - if (a !== null && a !== undefined && a.__isLayer) return this.layer(a.index + (rel || 0)); + if (typeof a === 'function' && a.__isLayer) return this.layer(a.index + (rel || 0)); var id = __h(__LAYER, this.__c, a); if (id === null) __err(typeof a === 'string' ? 'Layer named "' + a + '" does not exist' : 'Layer index ' + a + ' out of range'); - return new Layer(this.__c, id); + return __layer(this.__c, id); }, toString: function () { return '[object Comp]'; }, }; @@ -554,6 +554,15 @@ function Layer(c, l) { return f; } +// Layer `l` of comp `c`, made once per evaluation: a Layer is a callable function with its own +// prototype, dearer to make than a plain object, and expressions often name the same layer many +// times (`thisComp.layer("A")` in a loop, `.parent`). +function __layer(c, l) { + if (__S === null) return new Layer(c, l); + var k = c + ':' + l; + return __S.layers[k] || (__S.layers[k] = new Layer(c, l)); +} + function __m3(m, p) { var x = p[0] || 0, y = p[1] || 0; var X = m[0] * x + m[1] * y + m[2], Y = m[3] * x + m[4] * y + m[5], W = m[6] * x + m[7] * y + m[8]; @@ -588,7 +597,7 @@ Layer.prototype = { get parent() { var p = this.__info[7]; if (p === null) __err('Layer "' + this.name + '" has no parent'); - return new Layer(this.__c, p); + return __layer(this.__c, p); }, get hasVideo() { return this.__info[8]; }, get enabled() { return this.__info[11]; }, @@ -757,7 +766,7 @@ Prop.prototype = { propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); }, __wrap: function (v) { var kind = this.__info[2]; - if (kind === 'layer') return v === null ? null : new Layer(this.__c, v); + if (kind === 'layer') return v === null ? null : __layer(this.__c, v); if (kind === 'path' && v !== null) return new __Path(v[0], v[1], v[2], v[3]); return v; }, @@ -787,7 +796,7 @@ Prop.prototype = { for (var i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i; return this.key(best + 1); }, - __seedIndex: function () { return this.__self ? __S.idx : new Layer(this.__c, this.__l).index; }, + __seedIndex: function () { return this.__self ? __S.idx : __layer(this.__c, this.__l).index; }, wiggle: function (freq, amp, octaves, ampMult, t) { if (octaves === undefined) octaves = 1; if (ampMult === undefined) ampMult = 0.5; @@ -858,7 +867,7 @@ var __propHandler = { var x = v[k]; // Property groups and layers are callable: hand them over as they are, not bound like methods // (#809). - if (x?.__isGroup || x?.__isLayer) return x; + if (typeof x === 'function' && (x.__isGroup || x.__isLayer)) return x; return typeof x === 'function' ? x.bind(v) : x; }, }; @@ -1045,7 +1054,7 @@ function __global(name, get) { } __global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); }); -__global('thisLayer', function () { return __S.layer || (__S.layer = new Layer(__S.c, __S.l)); }); +__global('thisLayer', function () { return __S.layer || (__S.layer = __layer(__S.c, __S.l)); }); __global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); }); ['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active', 'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) { From 7c0ba4ef1d05739fea7b28c68a72fe7d45388a8c Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 18 Oct 2026 11:54:03 -0400 Subject: [PATCH 15/50] Expressions: a layer is made once per runtime and reads its info again in each evaluation --- crates/expr/src/prelude.js | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 4517fef1d..1ca3f17e3 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -26,7 +26,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { textIndex = ti; textTotal = tt; selectorValue = sv; - __S = { c: c, l: l, path: path, uid: uid, idx: idx, fd: fd, comp: null, layer: null, prop: null, vars: {}, layers: {} }; + __S = { c: c, l: l, path: path, uid: uid, idx: idx, fd: fd, comp: null, layer: null, prop: null, vars: {} }; __rc = 0; __seed = 0; __timeless = false; @@ -550,17 +550,19 @@ function Layer(c, l) { delete f.name; delete f.length; Object.setPrototypeOf(f, Layer.prototype); - f.__c = c; f.__l = l; f.__i = null; + f.__c = c; f.__l = l; f.__i = null; f.__s = null; + // Kept across evaluations (`__layer`), so an expression can't leave its own fields on one. + Object.preventExtensions(f); return f; } -// Layer `l` of comp `c`, made once per evaluation: a Layer is a callable function with its own -// prototype, dearer to make than a plain object, and expressions often name the same layer many -// times (`thisComp.layer("A")` in a loop, `.parent`). +// Layer `l` of comp `c`, made once per runtime: a callable Layer is dearer to make than a plain +// object, so it is kept, and its info (`__info`) is read again in each evaluation. +var __layers = {}; function __layer(c, l) { - if (__S === null) return new Layer(c, l); - var k = c + ':' + l; - return __S.layers[k] || (__S.layers[k] = new Layer(c, l)); + // By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves. + var m = __layers[c] || (__layers[c] = {}); + return m[l] || (m[l] = new Layer(c, l)); } function __m3(m, p) { @@ -585,7 +587,10 @@ function __m4(m, p, vec) { Layer.prototype = { __isLayer: true, - get __info() { return this.__i || (this.__i = __h(__LINFO, this.__c, this.__l)); }, + get __info() { + if (this.__s !== __S) { this.__i = __h(__LINFO, this.__c, this.__l); this.__s = __S; } + return this.__i; + }, get name() { return this.__info[0]; }, get index() { return this.__info[1]; }, get inPoint() { return this.__info[2]; }, From 754ed2fe842f173c8cf2d5ad3b0905c13243f324 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 18 Oct 2026 11:54:03 -0400 Subject: [PATCH 16/50] Expressions: test that a kept layer reads the project again in each evaluation --- crates/expr/src/tests.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 2634c22ac..586560606 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1387,4 +1387,19 @@ fn layers_are_callable_property_groups() { assert!(f.err("thisLayer(\"nope\")").contains("no property"), "{}", f.err("thisLayer(\"nope\")")); } +/// A layer is kept from one evaluation to the next (it is dear to make) but reads the project +/// afresh in each, and an expression's own fields on it don't reach the next one. +#[test] +fn a_kept_layer_reads_the_project_again() { + let mut f = fx(); + let first = "thisComp.layer(\"B\").index * 10 + thisComp.layer(2).name.length"; + assert_close!(f.num(first, 0.0), 21.0); + f.layer_mut(f.b).name = "Bee".into(); + f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1); + assert_close!(f.num("thisComp.layer(\"Bee\").index * 10 + thisComp.layer(2).name.length", 0.0), 11.0); + assert_close!(f.num("thisLayer.index", 0.0), 2.0); + let _ = f.ev(f.a, ROT, "thisLayer.mine = 5; 0", 0.0); + assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0); +} + mod property_group; From 5c23bda95d38481a2cd45c9d840b3ca50cef0a63 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:00:43 -0400 Subject: [PATCH 17/50] Expressions: an expression can't change which layer a kept layer is --- crates/expr/src/prelude.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 1ca3f17e3..e525d70e3 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -550,8 +550,12 @@ function Layer(c, l) { delete f.name; delete f.length; Object.setPrototypeOf(f, Layer.prototype); - f.__c = c; f.__l = l; f.__i = null; f.__s = null; - // Kept across evaluations (`__layer`), so an expression can't leave its own fields on one. + // Kept across evaluations (`__layer`), so an expression can't change which layer it is or leave + // its own fields on it. `__i` and `__s` stay writable: `__info` reads them again in an evaluation + // whose `__S` isn't `__s`, and every evaluation has a new `__S`. + Object.defineProperty(f, '__c', { value: c }); + Object.defineProperty(f, '__l', { value: l }); + f.__i = null; f.__s = null; Object.preventExtensions(f); return f; } From f4a4d76f2a2eff23b938da67b453e3a0e5ef0b00 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:00:43 -0400 Subject: [PATCH 18/50] Expressions: test that a kept layer's own fields don't carry over to the next evaluation --- crates/expr/src/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 586560606..629884e91 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1400,6 +1400,8 @@ fn a_kept_layer_reads_the_project_again() { assert_close!(f.num("thisLayer.index", 0.0), 2.0); let _ = f.ev(f.a, ROT, "thisLayer.mine = 5; 0", 0.0); assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0); + let _ = f.ev(f.a, ROT, "var L = thisLayer; L.__c = 'x'; L.__l = 99; L.__i = ['Z', 7]; L.__s = __S; 0", 0.0); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); } mod property_group; From 0860c7e03d451d1476b58aeac47b6a1100b32f16 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:30:59 -0400 Subject: [PATCH 19/50] Expressions: an expression can't delete a kept layer's fields --- crates/expr/src/prelude.js | 9 +++++---- crates/expr/src/tests.rs | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index e525d70e3..4e532a3e9 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -550,12 +550,13 @@ function Layer(c, l) { delete f.name; delete f.length; Object.setPrototypeOf(f, Layer.prototype); - // Kept across evaluations (`__layer`), so an expression can't change which layer it is or leave - // its own fields on it. `__i` and `__s` stay writable: `__info` reads them again in an evaluation - // whose `__S` isn't `__s`, and every evaluation has a new `__S`. + // Kept across evaluations (`__layer`), so an expression can't change which layer it is, delete + // a field or leave its own on it. `__i` and `__s` stay writable: `__info` reads them again in an + // evaluation whose `__S` isn't `__s`, and every evaluation has a new `__S`. Object.defineProperty(f, '__c', { value: c }); Object.defineProperty(f, '__l', { value: l }); - f.__i = null; f.__s = null; + Object.defineProperty(f, '__i', { value: null, writable: true }); + Object.defineProperty(f, '__s', { value: null, writable: true }); Object.preventExtensions(f); return f; } diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 629884e91..f54480f08 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1402,6 +1402,8 @@ fn a_kept_layer_reads_the_project_again() { assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0); let _ = f.ev(f.a, ROT, "var L = thisLayer; L.__c = 'x'; L.__l = 99; L.__i = ['Z', 7]; L.__s = __S; 0", 0.0); assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); + let _ = f.ev(f.a, ROT, "var L = thisLayer; delete L.__c; delete L.__l; delete L.__i; delete L.__s; 0", 0.0); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); } mod property_group; From b2fb5f4e5f740327cc6fd00cdecde15ece755f12 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:32:44 -0400 Subject: [PATCH 20/50] Expressions: an expression can't change the prelude for the evaluations after it --- crates/expr/src/prelude.js | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 4e532a3e9..06757ec79 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -563,12 +563,15 @@ function Layer(c, l) { // Layer `l` of comp `c`, made once per runtime: a callable Layer is dearer to make than a plain // object, so it is kept, and its info (`__info`) is read again in each evaluation. -var __layers = {}; -function __layer(c, l) { - // By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves. - var m = __layers[c] || (__layers[c] = {}); - return m[l] || (m[l] = new Layer(c, l)); -} +// The kept layers are out of an expression's reach. +var __layer = (function () { + var layers = {}; + return function (c, l) { + // By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves. + var m = layers[c] || (layers[c] = {}); + return m[l] || (m[l] = new Layer(c, l)); + }; +})(); function __m3(m, p) { var x = p[0] || 0, y = p[1] || 0; @@ -1099,3 +1102,23 @@ function velocityAtTime(t) { return thisProperty.velocityAtTime(t); } function speedAtTime(t) { return thisProperty.speedAtTime(t); } function key(n) { return thisProperty.key(n); } function nearestKey(t) { return thisProperty.nearestKey(t); } + +// --------------------------------------------------------------------------------------------- +// The runtime runs this file once and keeps it for the thread's later evaluations, so an +// expression can't change the object model or the internals: the prototypes are frozen, and the +// `__` globals, the constructors and `thisProject` can't be reassigned. The `__` state below is +// set again by `__begin` in each evaluation. + +[Comp, Footage, Layer, Prop, Markers, __Path, __TextStyle].forEach(function (f) { + Object.freeze(f.prototype); + Object.freeze(f); +}); +[__GroupProto, __groupHandler, __propHandler, thisProject].forEach(Object.freeze); +(function () { + var perEvaluation = { __S: true, __rc: true, __seed: true, __timeless: true, __projectInfo: true }; + var model = { Comp: true, Footage: true, Layer: true, Prop: true, Markers: true, thisProject: true }; + Object.getOwnPropertyNames(globalThis).forEach(function (k) { + if (!(k.slice(0, 2) === '__' && !perEvaluation[k]) && !model[k]) return; + if ('value' in Object.getOwnPropertyDescriptor(globalThis, k)) Object.defineProperty(globalThis, k, { writable: false, configurable: false }); + }); +})(); From d81716bfb6c386902a18a9cfa7383f3bd43e9615 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:32:44 -0400 Subject: [PATCH 21/50] Expressions: test that an expression can't change the prelude for the next one --- crates/expr/src/tests.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index f54480f08..0e8addc69 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1406,4 +1406,30 @@ fn a_kept_layer_reads_the_project_again() { assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); } +/// The prelude is kept from one evaluation to the next, so an expression can't change its object +/// model or internals for the ones after it. +#[test] +fn an_expression_cant_change_the_prelude_for_the_next() { + let f = fx(); + let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers]"; + let before = f.ev(f.a, ROT, read, 0.5).unwrap(); + let attacks = [ + "Comp.prototype.layer = function () { return 0; }", + "Object.getPrototypeOf(thisLayer).index = 0", + "Object.getPrototypeOf(thisLayer('transform')).property = function () { return 0; }", + "Object.getPrototypeOf(thisLayer.transform.opacity).value = 0", + "Comp = function () {}", + "Prop = function () {}", + "thisProject = null", + "__h = function () { return null; }", + "__layer = function () { return null; }", + "__child = function () {}", + "delete __mkProp", + ]; + for a in attacks { + let _ = f.ev(f.a, ROT, &format!("try {{ {a} }} catch (e) {{}} 0"), 0.0); + assert_eq!(f.ev(f.a, ROT, read, 0.5).unwrap(), before, "after `{a}`"); + } +} + mod property_group; From ecb8dadde5af8b442f60204585b9ac680edfb822 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 25 Oct 2026 14:17:41 -0400 Subject: [PATCH 22/50] Expressions: an expression can't delete or redefine thisLayer, thisComp or the other unqualified names --- crates/expr/src/prelude.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 06757ec79..5671d3ec2 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -1056,11 +1056,11 @@ Markers.prototype = { // --------------------------------------------------------------------------------------------- // Unqualified names: thisLayer / thisProperty attributes and the `this*` objects. They're // accessors on the global object; assigning one (`var width = 10`) shadows it for the current -// evaluation only. +// evaluation only. They can't be deleted or redefined, so an expression can't take one away from +// the evaluations after it. function __global(name, get) { Object.defineProperty(globalThis, name, { - configurable: true, get: function () { return name in __S.vars ? __S.vars[name] : get(); }, set: function (v) { __S.vars[name] = v; }, }); From 2f2ebbdedb7b87f91560acac6aa398e3d382bde2 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 25 Oct 2026 14:17:41 -0400 Subject: [PATCH 23/50] Expressions: test that deleting or redefining thisLayer and thisComp doesn't reach the next evaluation --- crates/expr/src/tests.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 0e8addc69..9d87d3239 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -985,6 +985,8 @@ fn shadowing_layer_attribute_names_is_local() { let f = fx(); assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0); assert_close!(f.num("width", 0.0), 20.0, 1e-9); + assert_close!(f.num("function width() { return 4; }\nwidth() * 2", 0.0), 8.0); + assert_close!(f.num("width", 0.0), 20.0, 1e-9); } #[test] @@ -1411,7 +1413,7 @@ fn a_kept_layer_reads_the_project_again() { #[test] fn an_expression_cant_change_the_prelude_for_the_next() { let f = fx(); - let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers]"; + let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers, width]"; let before = f.ev(f.a, ROT, read, 0.5).unwrap(); let attacks = [ "Comp.prototype.layer = function () { return 0; }", @@ -1425,6 +1427,10 @@ fn an_expression_cant_change_the_prelude_for_the_next() { "__layer = function () { return null; }", "__child = function () {}", "delete __mkProp", + "delete thisLayer", + "delete thisComp", + "delete width", + "Object.defineProperty(globalThis, 'thisComp', { get: function () { return 0; } })", ]; for a in attacks { let _ = f.ev(f.a, ROT, &format!("try {{ {a} }} catch (e) {{}} 0"), 0.0); From ad6b87c60da5b7b8422ad0cb4e828897d5eb8090 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 25 Oct 2026 14:21:26 -0400 Subject: [PATCH 24/50] Expressions: the kept layers are bounded like the runtime's scripts --- crates/expr/src/prelude.js | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 5671d3ec2..2fdcba110 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -31,6 +31,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { __seed = 0; __timeless = false; __projectInfo = null; + __layersTrim(); } function __finish(r) { @@ -563,13 +564,25 @@ function Layer(c, l) { // Layer `l` of comp `c`, made once per runtime: a callable Layer is dearer to make than a plain // object, so it is kept, and its info (`__info`) is read again in each evaluation. -// The kept layers are out of an expression's reach. -var __layer = (function () { - var layers = {}; - return function (c, l) { +// The kept layers are out of an expression's reach, and bounded like the runtime's scripts: once +// `max` are kept, the layers after them aren't, and the next evaluation starts over +// (`__layersTrim`, so a layer stays the same object for a whole evaluation). +var __layer, __layersTrim; +(function () { + var layers = {}, kept = 0, max = 4096; + __layer = function (c, l) { // By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves. - var m = layers[c] || (layers[c] = {}); - return m[l] || (m[l] = new Layer(c, l)); + var m = layers[c], f = m && m[l]; + if (f) return f; + f = new Layer(c, l); + if (kept < max) { + (m || (layers[c] = {}))[l] = f; + kept++; + } + return f; + }; + __layersTrim = function () { + if (kept >= max) { layers = {}; kept = 0; } }; })(); From e7cf022a7de76b7697634d47c4e730c24a879798 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 25 Oct 2026 14:21:26 -0400 Subject: [PATCH 25/50] Expressions: test that the kept layers are bounded --- crates/expr/src/tests.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 9d87d3239..41eb50173 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1408,6 +1408,20 @@ fn a_kept_layer_reads_the_project_again() { assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); } +/// The kept layers are bounded: past the bound a layer isn't kept, an evaluation that makes more +/// layers than are kept still sees one object for each layer it had kept, and the evaluations +/// after it, which start over, read the right layers. +#[test] +fn the_kept_layers_are_bounded() { + let f = fx(); + let same = "(thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0)"; + let many = + format!("var c = thisLayer.__c;\nfor (var i = 0; i < 5000; i++) __layer(c, 100000 + i);\n{same} * 10 + (__layer(c, 1e6) === __layer(c, 1e6) ? 0 : 1)"); + assert_close!(f.num(&many, 0.0), 11.0); + assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); + assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); +} + /// The prelude is kept from one evaluation to the next, so an expression can't change its object /// model or internals for the ones after it. #[test] From 2eefced49b1e5d5ae33f0e0417f73efda4962bf0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 25 Oct 2026 17:56:41 -0400 Subject: [PATCH 26/50] Expressions: the kept layers' maps have no prototype --- crates/expr/src/prelude.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 2fdcba110..f2ef40ea4 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -564,25 +564,26 @@ function Layer(c, l) { // Layer `l` of comp `c`, made once per runtime: a callable Layer is dearer to make than a plain // object, so it is kept, and its info (`__info`) is read again in each evaluation. -// The kept layers are out of an expression's reach, and bounded like the runtime's scripts: once +// The kept layers are out of an expression's reach (their maps have no prototype, so a change to +// `Object.prototype` doesn't reach them either), and bounded like the runtime's scripts: once // `max` are kept, the layers after them aren't, and the next evaluation starts over // (`__layersTrim`, so a layer stays the same object for a whole evaluation). var __layer, __layersTrim; (function () { - var layers = {}, kept = 0, max = 4096; + var layers = Object.create(null), kept = 0, max = 4096; __layer = function (c, l) { // By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves. var m = layers[c], f = m && m[l]; if (f) return f; f = new Layer(c, l); if (kept < max) { - (m || (layers[c] = {}))[l] = f; + (m || (layers[c] = Object.create(null)))[l] = f; kept++; } return f; }; __layersTrim = function () { - if (kept >= max) { layers = {}; kept = 0; } + if (kept >= max) { layers = Object.create(null); kept = 0; } }; })(); From 95ee400ab2308a3ed60d004af620c2e7eb35d049 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 25 Oct 2026 17:56:42 -0400 Subject: [PATCH 27/50] Expressions: test that a change to Object.prototype doesn't reach the kept layers --- crates/expr/src/tests.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 41eb50173..5af2984fa 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1422,6 +1422,15 @@ fn the_kept_layers_are_bounded() { assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); } +/// A change to `Object.prototype` doesn't reach the kept layers: a frozen object put there under +/// the comp's id, before any of its layers is kept, doesn't stop them being kept. +#[test] +fn the_kept_layers_dont_go_through_object_prototype() { + let f = fx(); + let _ = f.ev(f.a, ROT, "try { Object.prototype[thisComp.__c] = Object.freeze({}); } catch (e) {} 0", 0.0); + assert_close!(f.num("thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0", 0.0), 1.0); +} + /// The prelude is kept from one evaluation to the next, so an expression can't change its object /// model or internals for the ones after it. #[test] From 5e583c1549c023247b7b84fa69e968e72284d5d6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 12:05:51 -0700 Subject: [PATCH 28/50] Prompt to save before running Shutdown Scripts --- crates/ui-egui/src/panels/unsaved.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/unsaved.rs b/crates/ui-egui/src/panels/unsaved.rs index 05f719fa9..0492a0f33 100644 --- a/crates/ui-egui/src/panels/unsaved.rs +++ b/crates/ui-egui/src/panels/unsaved.rs @@ -16,7 +16,16 @@ use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; /// Commands that replace or close the open project (Quit is asked when the window closes). -const CLOSING: &[&str] = &["file.newProject", "file.open", "file.openRecent", "file.closeProject", "file.revert", "file.openDemoProject", "templates.create"]; +const CLOSING: &[&str] = &[ + "file.newProject", + "file.open", + "file.openRecent", + "file.closeProject", + "file.revert", + "file.openDemoProject", + "templates.create", + "file.runShutdownScripts", +]; /// The command waiting on the prompt, and whether the user already answered it. #[derive(Clone, Debug, Default)] @@ -212,6 +221,23 @@ mod tests { assert!(app.session.is_dirty()); } + #[test] + fn shutdown_scripts_prompt_before_discarding_unsaved_work() { + let (mut app, ctx) = app(); + modified(&mut app); + let result = crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(result, json!({"dialog": "unsavedChanges"})); + assert_eq!(app.session.project.comps().count(), 1); + assert!(app.session.is_dirty()); + answer_with(&mut app, &ctx, Answer::Cancel); + assert_eq!(app.session.project.comps().count(), 1); + assert!(app.session.is_dirty()); + crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap(); + answer_with(&mut app, &ctx, Answer::Discard); + assert_eq!(app.session.project.comps().count(), 0); + assert!(!app.session.is_dirty() && app.dialog.is_none()); + } + #[test] fn closing_a_modified_project_asks_and_cancel_keeps_it() { let (mut app, ctx) = app(); From e2e54c347d2c7426f60f1766ecd767c139599419 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 12:05:52 -0700 Subject: [PATCH 29/50] Apply explicit Cineon settings through menu dispatch --- crates/ui-egui/src/panels/forms.rs | 63 +++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 09f4475f9..10409146d 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -422,7 +422,30 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], ), "file.interpretFootage" | "file.interpretProxy" - if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight", "startFrame"]) => + if !has( + p, + &[ + "frameRate", + "alpha", + "loop", + "pixelAspect", + "colorProfile", + "fields", + "invertAlpha", + "matteColor", + "linearLight", + "startFrame", + "logConversion", + "cineonUnits", + "pointUnits", + "convertedBlack", + "convertedWhite", + "tenBitBlack", + "tenBitWhite", + "currentGamma", + "highlightRolloff", + ], + ) => { let proxy = id == "file.interpretProxy"; let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) { @@ -1083,6 +1106,44 @@ mod tests { assert!(std::fs::read_to_string(&path).unwrap().contains(" Date: Wed, 28 Oct 2026 23:28:59 -0700 Subject: [PATCH 30/50] Shapes: read the Wiggle Transform group inside wiggle_transform wiggle_transform looks up its Transform group itself and returns when the group is missing, so it takes seven parameters and no longer needs the clippy::too_many_arguments allow. --- crates/render/src/shapes.rs | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 293dd8bc2..9150243eb 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -193,17 +193,8 @@ fn used_slots(live: &[usize], draws: &[Draw]) -> Vec { /// Selector's do, with each Repeater copy (`copy_of`) as one unit: the copies of a Repeater above /// the operation move on their own as far as Correlation allows, and the copies of a Repeater /// below it move as one. The four transformations of one operation move together. -#[allow(clippy::too_many_arguments)] -fn wiggle_transform( - ctx: &EvalCtx, - layer: &Layer, - g: &PropGroup, - tr: &PropGroup, - arena: &mut [BezPath], - live: &[usize], - copy_of: &HashMap, - draws: &mut [Draw], -) { +fn wiggle_transform(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, arena: &mut [BezPath], live: &[usize], copy_of: &HashMap, draws: &mut [Draw]) { + let Some(tr) = g.sub("transform") else { return }; let w = Wiggly { max: 1.0, min: -1.0, @@ -406,10 +397,7 @@ fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Ve let t = layer.layer_time(ctx.time).seconds(); apply(arena, &live, |p| ops::wiggle(p, &w, t)); } - "wiggler" => { - let Some(tr) = g.sub("transform") else { continue }; - wiggle_transform(ctx, layer, g, tr, arena, &live, ©_ids.of, &mut draws); - } + "wiggler" => wiggle_transform(ctx, layer, g, arena, &live, ©_ids.of, &mut draws), "merge" => { let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode")); let input: Vec = live.iter().map(|&i| arena[i].clone()).collect(); From 3f85aa111e7579d0f06a8b0174dbdfe4ea4d62fb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 01:36:02 -0700 Subject: [PATCH 31/50] Scripts: move the Startup and Shutdown script tests into tests/startup.rs The tests now sit with the crate's other test modules under crates/script/src/tests/ and are declared in tests.rs. docs/scripting-parity.md points at the new path. --- crates/script/src/lib.rs | 2 -- crates/script/src/tests.rs | 1 + crates/script/src/{tests_startup.rs => tests/startup.rs} | 0 docs/scripting-parity.md | 2 +- 4 files changed, 2 insertions(+), 3 deletions(-) rename crates/script/src/{tests_startup.rs => tests/startup.rs} (100%) diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index f4694acfa..11d837677 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -88,8 +88,6 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome { #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_startup; // #760: `$.evalFile` and `BridgeTalk`. // `/browser/…` is the web editor's file table, an absolute path on wasm; on Windows it has no diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 8b7973c5d..7031af007 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -17,6 +17,7 @@ mod include; mod panels; #[cfg(not(windows))] mod services; +mod startup; mod tasks; mod ui; mod ui_more; diff --git a/crates/script/src/tests_startup.rs b/crates/script/src/tests/startup.rs similarity index 100% rename from crates/script/src/tests_startup.rs rename to crates/script/src/tests/startup.rs diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index ddaeec724..515713669 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -23,7 +23,7 @@ our own scripts; no real-world script library has been run against both apps. | ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | | `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows (a Startup script's tasks go on until EffectCraft quits); frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | | Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) | -| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `tests_startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) | +| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `crates/script/src/tests/startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) | | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification | | Keyboard shortcuts for scripts | yes | no | missing | plugins.md | From 0acc2a4677e0e3744906ec91d0b02fe8e87a7b1d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 02:00:35 -0700 Subject: [PATCH 32/50] Cineon: Interpret Footage and the Cineon Converter share one log-to-linear function effectcraft_project::cineon_log_to_lin does the log-to-linear conversion for Interpret Footage > Cineon Settings and the Cineon Converter's Log to Linear. Both raise the result to 1 / Gamma, so Gamma 1 gives linear light and the same Gamma gives the same values in both. The converter's Linear to Log, its Log to Log reference and the GPU shader use the same Gamma scale, with a smallest Gamma of 0.01 / 1.7 (CINEON_MIN_GAMMA). A test runs the converter at Gamma 1.7 (the default), 1 and 2.4 and checks it against Cineon Settings at each code value. --- crates/effects/src/utility.rs | 43 +++++++++++++++++++-------- crates/gpu/src/fx_pixel2.rs | 2 +- crates/gpu/src/shaders/fx_pixel2.wgsl | 6 ++-- crates/project/src/footage.rs | 34 ++++++++++++++------- crates/project/src/lib.rs | 2 +- 5 files changed, 61 insertions(+), 26 deletions(-) diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 1d3b3ee4a..1f2ddeb7b 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -100,17 +100,11 @@ impl Cineon { fn knee(&self) -> f32 { 1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99) } - /// Normalised code value (code / 1023) → linear. + /// Normalized code value (code / 1023) → linear, raised to `1 / gamma` + /// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings + /// also uses). pub fn log_to_lin(&self, v: f32) -> f32 { - let off = self.offset(); - let code = v * 1023.0; - let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); - lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01)); - let k = self.knee(); - if k < 1.0 && lin > k { - let w = 1.0 - k; - lin = k + w * (1.0 - (-(lin - k) / w).exp()); - } + let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, self.gamma, self.rolloff); self.ib + (self.iw - self.ib) * lin } /// Linear → normalised code value. @@ -123,7 +117,7 @@ impl Cineon { let w = 1.0 - k; lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln(); } - lin = lin.max(0.0).powf(self.gamma.max(0.01) / 1.7); + lin = lin.max(0.0).powf(self.gamma.max(effectcraft_project::CINEON_MIN_GAMMA)); let code = self.white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002; code / 1023.0 } @@ -138,7 +132,7 @@ fn cineon(ctx: &EffectCtx, mut b: Buf) -> Buf { gamma: ctx.params.f("gamma") as f32, rolloff: ctx.params.f("highlightRolloff") as f32, }; - let std = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: c.rolloff }; + let std = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.0, rolloff: c.rolloff }; let kind = ctx.params.e("conversionType"); b.img.map_straight(|v| { v.map(|x| match kind { @@ -495,6 +489,31 @@ mod tests { assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4); } + /// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same + /// values for the same points, Gamma and Highlight Rolloff: at the default Gamma (1.7) and at + /// Gamma 1, which gives linear light (code 400 is 0.10251). + #[test] + fn cineon_converter_matches_interpret_footage() { + let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; + for (gamma, rolloff) in [(1.7, 20.0), (1.0, 0.0), (2.4, 50.0)] { + let mut img = Image::new(codes.len() as u32, 1); + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma)), ("highlightRolloff", num(rolloff))]; + let out = run("ec.utility.cineon", &vals, img); + let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() }; + for (x, code) in codes.iter().enumerate() { + let footage = settings.convert(code / 1023.0); + let effect = out.img.data[x][0]; + assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}"); + } + } + let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() }; + assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4); + } + #[test] fn hdr_compression_behaviour() { let mut img = Image::new(2, 1); diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index acba936d0..6e3bc4ab0 100644 --- a/crates/gpu/src/fx_pixel2.rs +++ b/crates/gpu/src/fx_pixel2.rs @@ -228,7 +228,7 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { p.u[0] = [0, pr.e("conversionType"), 0, 0]; p.f[0] = [offset(black, white), knee, white, pr.f("gamma") as f32]; p.f[1] = [pr.f("internalBlackPoint") as f32, pr.f("internalWhitePoint") as f32, 0.0, 0.0]; - p.f[2] = [offset(95.0, 685.0), knee, 685.0, 1.7]; + p.f[2] = [offset(95.0, 685.0), knee, 685.0, 1.0]; } "ec.utility.hdrcompander" => { p.u[0] = [1, (pr.e("mode") == 1) as u32, 0, 0]; diff --git a/crates/gpu/src/shaders/fx_pixel2.wgsl b/crates/gpu/src/shaders/fx_pixel2.wgsl index a63a91fed..2b4dc0577 100644 --- a/crates/gpu/src/shaders/fx_pixel2.wgsl +++ b/crates/gpu/src/shaders/fx_pixel2.wgsl @@ -257,11 +257,13 @@ fn fp2_vector(@builtin(global_invocation_id) gid: vec3) { // u[0].x = effect: 0 Cineon Converter, 1 HDR Compander, 2 HDR Highlight Compression, // 3 CC Overbrights, 4 Color Link (alpha), 5 Color Link (blend), 6 CC Composite. +const FP2_CINEON_MIN_GAMMA: f32 = 0.01 / 1.7; + // utility::Cineon::log_to_lin; c = (offset, knee, white, gamma), r = (internal black, white). fn fp2_log_to_lin(v: f32, c: vec4, r: vec2) -> f32 { let code = v * 1023.0; var lin = (pow(10.0, (code - c.z) * 0.002 / 0.6) - c.x) / max(1.0 - c.x, 1e-6); - lin = powz(max(lin, 0.0), 1.7 / max(c.w, 0.01)); + lin = powz(max(lin, 0.0), 1.0 / max(c.w, FP2_CINEON_MIN_GAMMA)); let k = c.y; if (k < 1.0 && lin > k) { let w = 1.0 - k; @@ -279,7 +281,7 @@ fn fp2_lin_to_log(v: f32, c: vec4, r: vec2) -> f32 { let w = 1.0 - k; lin = k - w * log(max(1.0 - (lin - k) / w, 1e-6)); } - lin = powz(max(lin, 0.0), max(c.w, 0.01) / 1.7); + lin = powz(max(lin, 0.0), max(c.w, FP2_CINEON_MIN_GAMMA)); let code = c.z + log(max(lin * (1.0 - c.x) + c.x, 1e-9)) * 0.4342944819032518 * 0.6 / 0.002; return code / 1023.0; } diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index f1e894809..e26d77417 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -161,21 +161,35 @@ impl CineonSettings { /// A code value (0–1 of the 10-bit range) converted from log to the Current Gamma and the /// converted black and white levels (0–1; above 1 for over-range highlights). pub fn convert(&self, v: f32) -> f32 { - // Kodak printing density: 0.002 density per code value, negative gamma 0.6. - let exposure = |code: f32| 10f32.powf((code - self.white) * 0.002 / 0.6); - let offset = exposure(self.black); - let mut lin = ((exposure(v * Self::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0); - lin = lin.powf(1.0 / self.gamma.max(0.01)); - let knee = 1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99); - if knee < 1.0 && lin > knee { - let w = 1.0 - knee; - lin = knee + w * (1.0 - (-(lin - knee) / w).exp()); - } + let lin = cineon_log_to_lin(v, self.black, self.white, self.gamma, self.rolloff); let out = (self.converted_black + (self.converted_white - self.converted_black) * lin) / Self::MAX; if out.is_finite() { out } else { 0.0 } } } +/// The smallest Gamma [`cineon_log_to_lin`] and the Cineon Converter's Linear to Log use. +pub const CINEON_MIN_GAMMA: f32 = 0.01 / 1.7; + +/// Cineon log to linear, shared by Interpret Footage ▸ Cineon Settings and the Cineon Converter +/// effect. `v` is a code value as 0–1 of the 10-bit range; `black` and `white` are the 10 Bit +/// Black and White Points. The result is 0 at the black point and 1 at the white point, raised to +/// `1 / gamma` (Gamma 1 gives linear light), with values above `1 - rolloff / 100` ramped off +/// toward 1 (Highlight Rolloff 0–100; 0 keeps over-range values). A Gamma below +/// [`CINEON_MIN_GAMMA`] is raised to it. +pub fn cineon_log_to_lin(v: f32, black: f32, white: f32, gamma: f32, rolloff: f32) -> f32 { + // Kodak printing density: 0.002 density per code value, negative gamma 0.6. + let exposure = |code: f32| 10f32.powf((code - white) * 0.002 / 0.6); + let offset = exposure(black); + let mut lin = ((exposure(v * CineonSettings::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0); + lin = lin.powf(1.0 / gamma.max(CINEON_MIN_GAMMA)); + let knee = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if knee < 1.0 && lin > knee { + let w = 1.0 - knee; + lin = knee + w * (1.0 - (-(lin - knee) / w).exp()); + } + lin +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Footage { pub path: String, diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index a59a2f40a..52f55fcac 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -36,7 +36,7 @@ mod layer; mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; -pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer}; +pub use footage::{AlphaMode, CINEON_MIN_GAMMA, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, cineon_log_to_lin}; pub use item::{Item, ItemId, ItemKind, LayerId}; pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte}; pub use settings::{ From 2bef8aa6696da6a48a812b96e99789eb72af1951 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 06:46:51 -0700 Subject: [PATCH 33/50] Cineon: the Cineon Converter keeps its Gamma scale cineon_log_to_lin takes the exponent scale from its caller. Interpret Footage > Cineon Settings passes 1 (Gamma 1 gives linear light) and the Cineon Converter passes 1.7, as before, so the converter's output and its default Gamma 1.7 are unchanged. The converter's Linear to Log, Log to Log reference and GPU shader return to their earlier code. A test checks the converter at its default Gamma and at 2.4, for all three conversion types, against the earlier formula. --- crates/effects/src/utility.rs | 74 +++++++++++++++++++++++---- crates/gpu/src/fx_pixel2.rs | 2 +- crates/gpu/src/shaders/fx_pixel2.wgsl | 6 +-- crates/project/src/footage.rs | 16 +++--- crates/project/src/lib.rs | 2 +- 5 files changed, 76 insertions(+), 24 deletions(-) diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 1f2ddeb7b..e41b42e99 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -100,11 +100,11 @@ impl Cineon { fn knee(&self) -> f32 { 1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99) } - /// Normalized code value (code / 1023) → linear, raised to `1 / gamma` + /// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma` /// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings - /// also uses). + /// also uses with `1 / gamma`). pub fn log_to_lin(&self, v: f32) -> f32 { - let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, self.gamma, self.rolloff); + let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff); self.ib + (self.iw - self.ib) * lin } /// Linear → normalised code value. @@ -117,7 +117,7 @@ impl Cineon { let w = 1.0 - k; lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln(); } - lin = lin.max(0.0).powf(self.gamma.max(effectcraft_project::CINEON_MIN_GAMMA)); + lin = lin.max(0.0).powf(self.gamma.max(0.01) / 1.7); let code = self.white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002; code / 1023.0 } @@ -132,7 +132,7 @@ fn cineon(ctx: &EffectCtx, mut b: Buf) -> Buf { gamma: ctx.params.f("gamma") as f32, rolloff: ctx.params.f("highlightRolloff") as f32, }; - let std = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.0, rolloff: c.rolloff }; + let std = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: c.rolloff }; let kind = ctx.params.e("conversionType"); b.img.map_straight(|v| { v.map(|x| match kind { @@ -490,18 +490,19 @@ mod tests { } /// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same - /// values for the same points, Gamma and Highlight Rolloff: at the default Gamma (1.7) and at - /// Gamma 1, which gives linear light (code 400 is 0.10251). + /// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times + /// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give + /// linear light (code 400 is 0.10251). #[test] fn cineon_converter_matches_interpret_footage() { let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; - for (gamma, rolloff) in [(1.7, 20.0), (1.0, 0.0), (2.4, 50.0)] { + for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] { let mut img = Image::new(codes.len() as u32, 1); for (x, code) in codes.iter().enumerate() { let v = code / 1023.0; img.set(x as u32, 0, [v, v, v, 1.0]); } - let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma)), ("highlightRolloff", num(rolloff))]; + let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))]; let out = run("ec.utility.cineon", &vals, img); let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() }; for (x, code) in codes.iter().enumerate() { @@ -514,6 +515,61 @@ mod tests { assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4); } + /// The Cineon Converter gives the same values as before Cineon Settings shared its + /// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three + /// conversion types. + #[test] + fn cineon_converter_output_is_unchanged() { + fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 { + let off = 10f32.powf((black - white) * 0.002 / 0.6); + let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); + lin = lin.max(0.0).powf(1.7 / gamma.max(0.01)); + let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k + w * (1.0 - (-(lin - k) / w).exp()); + } + lin + } + fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 { + let off = 10f32.powf((black - white) * 0.002 / 0.6); + let mut lin = v; + let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln(); + } + lin = lin.max(0.0).powf(gamma.max(0.01) / 1.7); + (white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0 + } + let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; + for gamma in [None, Some(2.4)] { + for kind in 0..3 { + let mut img = Image::new(codes.len() as u32, 1); + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let mut vals = vec![("conversionType", Value::Enum(kind))]; + if let Some(g) = gamma { + vals.push(("gamma", num(g))); + } + let out = run("ec.utility.cineon", &vals, img); + let g = gamma.unwrap_or(1.7) as f32; + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + let want = match kind { + 0 => lin_to_log(v, g, 20.0, 95.0, 685.0), + 1 => log_to_lin(v, g, 20.0, 95.0, 685.0), + _ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0), + }; + let got = out.img.data[x][0]; + assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}"); + } + } + } + } + #[test] fn hdr_compression_behaviour() { let mut img = Image::new(2, 1); diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index 6e3bc4ab0..acba936d0 100644 --- a/crates/gpu/src/fx_pixel2.rs +++ b/crates/gpu/src/fx_pixel2.rs @@ -228,7 +228,7 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { p.u[0] = [0, pr.e("conversionType"), 0, 0]; p.f[0] = [offset(black, white), knee, white, pr.f("gamma") as f32]; p.f[1] = [pr.f("internalBlackPoint") as f32, pr.f("internalWhitePoint") as f32, 0.0, 0.0]; - p.f[2] = [offset(95.0, 685.0), knee, 685.0, 1.0]; + p.f[2] = [offset(95.0, 685.0), knee, 685.0, 1.7]; } "ec.utility.hdrcompander" => { p.u[0] = [1, (pr.e("mode") == 1) as u32, 0, 0]; diff --git a/crates/gpu/src/shaders/fx_pixel2.wgsl b/crates/gpu/src/shaders/fx_pixel2.wgsl index 2b4dc0577..a63a91fed 100644 --- a/crates/gpu/src/shaders/fx_pixel2.wgsl +++ b/crates/gpu/src/shaders/fx_pixel2.wgsl @@ -257,13 +257,11 @@ fn fp2_vector(@builtin(global_invocation_id) gid: vec3) { // u[0].x = effect: 0 Cineon Converter, 1 HDR Compander, 2 HDR Highlight Compression, // 3 CC Overbrights, 4 Color Link (alpha), 5 Color Link (blend), 6 CC Composite. -const FP2_CINEON_MIN_GAMMA: f32 = 0.01 / 1.7; - // utility::Cineon::log_to_lin; c = (offset, knee, white, gamma), r = (internal black, white). fn fp2_log_to_lin(v: f32, c: vec4, r: vec2) -> f32 { let code = v * 1023.0; var lin = (pow(10.0, (code - c.z) * 0.002 / 0.6) - c.x) / max(1.0 - c.x, 1e-6); - lin = powz(max(lin, 0.0), 1.0 / max(c.w, FP2_CINEON_MIN_GAMMA)); + lin = powz(max(lin, 0.0), 1.7 / max(c.w, 0.01)); let k = c.y; if (k < 1.0 && lin > k) { let w = 1.0 - k; @@ -281,7 +279,7 @@ fn fp2_lin_to_log(v: f32, c: vec4, r: vec2) -> f32 { let w = 1.0 - k; lin = k - w * log(max(1.0 - (lin - k) / w, 1e-6)); } - lin = powz(max(lin, 0.0), max(c.w, FP2_CINEON_MIN_GAMMA)); + lin = powz(max(lin, 0.0), max(c.w, 0.01) / 1.7); let code = c.z + log(max(lin * (1.0 - c.x) + c.x, 1e-9)) * 0.4342944819032518 * 0.6 / 0.002; return code / 1023.0; } diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index e26d77417..ae5f113d5 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -161,27 +161,25 @@ impl CineonSettings { /// A code value (0–1 of the 10-bit range) converted from log to the Current Gamma and the /// converted black and white levels (0–1; above 1 for over-range highlights). pub fn convert(&self, v: f32) -> f32 { - let lin = cineon_log_to_lin(v, self.black, self.white, self.gamma, self.rolloff); + let lin = cineon_log_to_lin(v, self.black, self.white, 1.0, self.gamma, self.rolloff); let out = (self.converted_black + (self.converted_white - self.converted_black) * lin) / Self::MAX; if out.is_finite() { out } else { 0.0 } } } -/// The smallest Gamma [`cineon_log_to_lin`] and the Cineon Converter's Linear to Log use. -pub const CINEON_MIN_GAMMA: f32 = 0.01 / 1.7; - /// Cineon log to linear, shared by Interpret Footage ▸ Cineon Settings and the Cineon Converter /// effect. `v` is a code value as 0–1 of the 10-bit range; `black` and `white` are the 10 Bit /// Black and White Points. The result is 0 at the black point and 1 at the white point, raised to -/// `1 / gamma` (Gamma 1 gives linear light), with values above `1 - rolloff / 100` ramped off -/// toward 1 (Highlight Rolloff 0–100; 0 keeps over-range values). A Gamma below -/// [`CINEON_MIN_GAMMA`] is raised to it. -pub fn cineon_log_to_lin(v: f32, black: f32, white: f32, gamma: f32, rolloff: f32) -> f32 { +/// `scale / gamma`, with values above `1 - rolloff / 100` ramped off toward 1 (Highlight Rolloff +/// 0–100; 0 keeps over-range values). A Gamma below 0.01 is raised to 0.01. Cineon Settings passes +/// a `scale` of 1 (Gamma 1 gives linear light) and the Cineon Converter passes 1.7 (its default +/// Gamma 1.7 gives linear light). +pub fn cineon_log_to_lin(v: f32, black: f32, white: f32, scale: f32, gamma: f32, rolloff: f32) -> f32 { // Kodak printing density: 0.002 density per code value, negative gamma 0.6. let exposure = |code: f32| 10f32.powf((code - white) * 0.002 / 0.6); let offset = exposure(black); let mut lin = ((exposure(v * CineonSettings::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0); - lin = lin.powf(1.0 / gamma.max(CINEON_MIN_GAMMA)); + lin = lin.powf(scale / gamma.max(0.01)); let knee = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); if knee < 1.0 && lin > knee { let w = 1.0 - knee; diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 52f55fcac..37584eec4 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -36,7 +36,7 @@ mod layer; mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; -pub use footage::{AlphaMode, CINEON_MIN_GAMMA, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, cineon_log_to_lin}; +pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, cineon_log_to_lin}; pub use item::{Item, ItemId, ItemKind, LayerId}; pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte}; pub use settings::{ From 8606f54a64e143f269f261298831fdf80a8ad0f0 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 29 Oct 2026 22:31:31 +0530 Subject: [PATCH 34/50] Fix #2573: New Composition From Footage creates a composition The empty Composition viewer's button only ran File > Import, so the picked file landed in the Project panel and no composition was made. file.import now takes newComp: when the import lands (at once or when its background job applies), what it made becomes one composition via New Comp from Selection. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 73 +++++++++++++++++------ crates/engine/src/tests/session.rs | 40 +++++++++++++ crates/engine/src/tests/vector_import.rs | 31 ++++++++++ crates/ui-egui/src/panels/viewer/frame.rs | 2 +- crates/ui-egui/tests/it/ui_drag_drop.rs | 21 +++++++ 5 files changed, 149 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b3922c7d6..69e2e50cd 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -124,7 +124,8 @@ fn revert(s: &mut Session, _: &Value) -> Result { } /// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they -/// also become layers of the active comp ([`add_to_comp`]). +/// also become layers of the active comp ([`add_to_comp`]); with `newComp` (the empty viewer's +/// New Composition From Footage) they become one new composition instead ([`new_comp`]). /// /// With `background` (files dropped on the window or picked in the Import dialog) the files are /// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows @@ -174,10 +175,10 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result { Ok(r) } -/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`; -/// what couldn't be added joins the result's errors. +/// After an import (its result `r`): with `newComp`, the new items become one new composition; +/// otherwise with `addToComp`, layers of `target`. What couldn't be added joins the result's errors. fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Value) { - let errors = add_to_comp(s, r, target, p); + let errors = if b_p(p, "newComp").unwrap_or(false) { new_comp(s, r) } else { add_to_comp(s, r, target, p) }; if !errors.is_empty() && let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut) { @@ -191,18 +192,7 @@ fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Val /// Project panel) and the other footage. `target` stays open. Returns what couldn't be added. pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: &Value) -> Vec { let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] }; - let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); - let comps = ids("comps"); - let in_comps: std::collections::HashSet = comps - .iter() - .filter_map(|c| s.project.comp(ItemId(*c))) - .flat_map(|c| c.layers.iter()) - .filter_map(|l| match l.source { - LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0), - _ => None, - }) - .collect(); - let add: Vec = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect(); + let add = imported(s, r); let mut errors = vec![]; for (k, item) in add.into_iter().enumerate() { let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")}); @@ -219,6 +209,55 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: errors } +/// What an import (its result `r`) made that belongs in a comp: the comps of layered files +/// (their layers' footage, in their precomps too, stays in the Project panel), then the other items, +/// without those comps again or the files' folders. +fn imported(s: &Session, r: &Value) -> Vec { + let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); + let comps = ids("comps"); + let mut in_comps = std::collections::HashSet::new(); + let mut todo = comps.clone(); + while let Some(c) = todo.pop() { + for l in s.project.comp(ItemId(c)).into_iter().flat_map(|c| c.layers.iter()) { + match l.source { + LayerSource::Comp { item } if in_comps.insert(item.0) => todo.push(item.0), + LayerSource::Footage { item } | LayerSource::Solid { item } => { + in_comps.insert(item.0); + } + _ => {} + } + } + } + let rest = ids("items").into_iter().filter(|i| !in_comps.contains(i) && !comps.contains(i) && !s.project.item(ItemId(*i)).is_some_and(|it| it.is_folder())); + comps.iter().copied().chain(rest).collect() +} + +/// With `newComp`, make one composition of the footage and solids an import (its result `r`) +/// made, as New Comp from Selection does with `single` (first item on top, its settings). A comp +/// the import made (a layered file) already is the composition, so it stays out, as does data +/// footage; none left, no new comp. Returns what went wrong. +fn new_comp(s: &mut Session, r: &Value) -> Vec { + let items: Vec = imported(s, r) + .into_iter() + .map(ItemId) + .filter(|i| { + s.project.item(*i).is_some_and(|it| match &it.kind { + ItemKind::Footage(f) => f.kind != FootageKind::Data, + ItemKind::Solid(_) => true, + ItemKind::Comp(_) | ItemKind::Folder => false, + }) + }) + .collect(); + if items.is_empty() { + return vec![]; + } + s.state.project_selection = items; + match s.execute("file.newCompFromSelection", json!({"single": true})) { + Ok(_) => vec![], + Err(e) => vec![e.to_string()], + } +} + /// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`]. pub(crate) fn import(s: &mut Session, p: &Value) -> Result { let pending = prepare_import(s, p)?; @@ -745,7 +784,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), newComp?: bool (make one new composition of the footage and solids imported, as file.newCompFromSelection with single; a layered file imported as a composition is that composition and gets no other; takes precedence over addToComp), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", always, import_cmd ), diff --git a/crates/engine/src/tests/session.rs b/crates/engine/src/tests/session.rs index b5d1cbb5f..dfe326455 100644 --- a/crates/engine/src/tests/session.rs +++ b/crates/engine/src/tests/session.rs @@ -159,6 +159,46 @@ fn background_imports_report_progress_and_select_what_arrived() { assert_eq!(r["items"].as_array().map(Vec::len), Some(1)); } +/// #2573: New Composition From Footage (`file.import {newComp}`) makes one composition of what +/// the import made, first file on top, at once or when the background job lands; an import that +/// made nothing makes no comp. +#[test] +fn new_comp_from_footage_makes_a_comp_of_what_was_imported() { + use effectcraft_project::{Footage, FootageKind, ItemKind}; + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + if path.ends_with(".xyz") { + return Err("unsupported file".into()); + } + Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() }) + } + } + let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + let comps = |s: &Session| s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count(); + let layers = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::>(); + // At once. + s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.xyz"], "newComp": true})).unwrap(); + assert_eq!(comps(&s), 1); + assert_eq!(layers(&s), ["a.png"]); + assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, 32)); + // In the background: the job's own items, whatever is selected or active meanwhile; it takes + // precedence over `addToComp`. + s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.png"], "background": true, "newComp": true, "addToComp": true})).unwrap(); + s.state.project_selection.clear(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(comps(&s), 2); + assert_eq!(layers(&s), ["c.png", "d.png"]); + // Nothing imported: no comp, and the failure is reported. + s.execute("file.import", json!({"paths": ["/drop/e.xyz"], "background": true, "newComp": true})).unwrap(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(comps(&s), 2); + assert!(s.job_log.last().unwrap().message.contains("unsupported file")); + let r = s.execute("file.import", json!({"paths": ["/drop/f.xyz"], "newComp": true})).unwrap(); + assert_eq!(comps(&s), 2); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(1)); +} + /// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103). #[test] fn audio_lock_and_shy_keep_cached_layers() { diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 9cfb94ecc..10c07c2ad 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -160,6 +160,37 @@ fn psd_imports_as_composition() { assert!(s.project.comp(cid).is_none()); } +/// #2573: New Composition From Footage on a Photoshop file imported as a composition opens that +/// composition; it isn't wrapped in another. +#[test] +fn psd_imported_as_composition_with_new_comp_is_the_new_comp() { + let path = tmp("newcomp.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "newComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + assert_eq!(s.state.active_comp, Some(cid)); + let roots = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_)) && i.name.starts_with("newcomp")).count(); + assert_eq!(roots, 1, "no wrapper comp"); +} + +/// A Photoshop file imported as a composition and dropped on the Timeline (`addToComp`) adds +/// its composition once, not its folder too. +#[test] +fn psd_imported_as_composition_into_a_comp_adds_one_layer() { + let path = tmp("dropped.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let psd = r["comps"][0].as_u64().unwrap(); + let layers = &s.project.comp(effectcraft_project::ItemId(main)).unwrap().layers; + assert_eq!(layers.len(), 1, "{layers:?}"); + assert!(matches!(layers[0].source, LayerSource::Comp { item } if item.0 == psd)); +} + #[test] fn psd_retain_layer_sizes() { let path = tmp("retain.psd"); diff --git a/crates/ui-egui/src/panels/viewer/frame.rs b/crates/ui-egui/src/panels/viewer/frame.rs index ac467ecd5..6d705f414 100644 --- a/crates/ui-egui/src/panels/viewer/frame.rs +++ b/crates/ui-egui/src/panels/viewer/frame.rs @@ -15,7 +15,7 @@ pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec app.auto.add("viewer.empty.newComp", b1, "New Composition"); let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0)); if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::unique("empty-fromfootage")).clicked() { - let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({})); + let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({"newComp": true})); } app.auto.add("viewer.empty.import", b2, "New Composition From Footage"); // A Project item dropped on the empty viewer creates the first composition, diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index e405bf777..26af9fbad 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -594,6 +594,27 @@ fn import_dialog_offers_the_image_sequence_option() { let _ = std::fs::remove_dir_all(&dir); } +/// #2573: the empty Composition viewer's New Composition From Footage imports the picked file +/// (through the Import Image Sequence question a numbered still asks) and makes a composition +/// of it, as in After Effects. +#[test] +fn new_composition_from_footage_makes_a_comp_of_the_picked_file() { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default())); + h.run_steps(3); + let dir = frames("fromfootage", 2); + let pick = dir.join("frame_0002.png").to_string_lossy().to_string(); + h.state_mut().session.importer = Some(Arc::new(Stills)); + h.state_mut().hooks.pick_files = Some(Box::new(move |_: &[&str]| vec![pick.clone()])); + click_id(&mut h, "viewer.empty.import"); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + click_id(&mut h, "form.ok"); + step_until(&mut h, |h| h.state().session.active_comp().is_some()); + let comp = h.state().session.active_comp().unwrap(); + assert_eq!((comp.width, comp.height), (64, 32)); + assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["frame_0002.png"]); + let _ = std::fs::remove_dir_all(&dir); +} + /// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret /// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in /// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one From dc09ba58aeda1134e672ad2457c6a90d077eef10 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 04:25:27 +0700 Subject: [PATCH 35/50] =?UTF-8?q?Text:=20stroke=20Line=20Join=20=E2=80=94?= =?UTF-8?q?=20Miter,=20Round=20or=20Bevel=20(#2077)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text strokes were always drawn with round joins. The document gets a Line Join (`lineJoin: miter|round|bevel` in layer.setText / newText), Miter for new text as in After Effects, Round for documents saved before it so their look is kept. The 2D and Advanced 3D (extruded) text strokes use it, expressions' style copies carry it, and the Character panel has Miter / Round / Bevel buttons under the stroke width. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layer.rs | 4 +-- crates/engine/src/tests/session.rs | 3 ++ crates/expr/src/runtime.rs | 1 + crates/keyframe/src/lib.rs | 2 +- crates/keyframe/src/text_doc.rs | 35 ++++++++++++++++++++++++ crates/render/src/text.rs | 13 ++++++++- crates/render/src/text/tests.rs | 2 ++ crates/render/src/three_d/adv/scene.rs | 2 +- crates/ui-egui/src/panels/text_panels.rs | 19 +++++++++++-- crates/ui-egui/tests/it/ui_text_edit.rs | 16 +++++++++++ 10 files changed, 90 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 885518fc3..26c4b4c8c 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), - "{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)", + "{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)", has_comp, new_text ), @@ -192,7 +192,7 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", + "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}", has_layers, set_text ), diff --git a/crates/engine/src/tests/session.rs b/crates/engine/src/tests/session.rs index b5d1cbb5f..d4a47c1cc 100644 --- a/crates/engine/src/tests/session.rs +++ b/crates/engine/src/tests/session.rs @@ -476,6 +476,9 @@ fn set_text_paragraph_fill_and_leading() { s.execute("layer.setText", json!({"layer": t, "hScale": 120, "vScale": 80, "baselineShift": 4, "smallCaps": true, "strokeOverFill": true})).unwrap(); let d = doc(&s); assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true)); + s.execute("layer.setText", json!({"layer": t, "lineJoin": "bevel"})).unwrap(); + assert_eq!(doc(&s).line_join, effectcraft_keyframe::LineJoin::Bevel); + assert!(s.execute("layer.setText", json!({"layer": t, "lineJoin": "square"})).is_err()); } #[test] diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 29e5e4eda..84bfcdf3c 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -677,6 +677,7 @@ pub fn styled_doc(v: &serde_json::Value, own: &effectcraft_keyframe::TextDoc) -> let p0 = src.para(0); d.set_paras(vec![p0; d.para_count()]); d.stroke_over_fill = src.stroke_over_fill; + d.line_join = src.line_join; } } for op in v.get("ops").and_then(|o| o.as_array()).into_iter().flatten() { diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index f2d94f177..f0dd4849c 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -15,7 +15,7 @@ pub mod value; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; -pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun}; +pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, LineJoin, OpenType, ParaStyle, StyleRun}; pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value}; /// Temporal interpolation on one side of a keyframe. diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index 6c1e5b843..96a8b6be3 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -179,6 +179,31 @@ pub enum Composer { fn single_line() -> Composer { Composer::SingleLine } + +/// How the corners of a text stroke are drawn (Character panel Line Join). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LineJoin { + /// Sharp corners (AE's default for new text). + #[default] + Miter, + Round, + Bevel, +} + +impl LineJoin { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "miter" => LineJoin::Miter, + "round" => LineJoin::Round, + "bevel" => LineJoin::Bevel, + _ => return None, + }) + } +} + +fn round_join() -> LineJoin { + LineJoin::Round +} fn yes() -> bool { true } @@ -402,6 +427,9 @@ pub struct TextDoc { pub apply_fill: bool, pub apply_stroke: bool, pub stroke_over_fill: bool, + /// Documents saved before line joins existed drew round joins. + #[serde(default = "round_join")] + pub line_join: LineJoin, pub tracking: f64, /// None = auto (120% of size). pub leading: Option, @@ -463,6 +491,7 @@ impl Default for TextDoc { apply_fill: true, apply_stroke: false, stroke_over_fill: true, + line_join: LineJoin::Miter, tracking: 0.0, leading: None, justify: Justify::Left, @@ -909,6 +938,10 @@ impl TextDoc { self.stroke_over_fill = v.as_bool().ok_or("strokeOverFill: expected a boolean")?; return Ok(true); } + "lineJoin" => { + self.line_join = v.as_str().and_then(LineJoin::parse).ok_or("lineJoin: expected miter|round|bevel")?; + return Ok(true); + } "vertical" => { self.vertical = v.as_bool().ok_or("vertical: expected a boolean")?; return Ok(true); @@ -1423,6 +1456,8 @@ mod tests { assert!(d.is_uniform()); assert_eq!(d.style_at(2).size, 50.0); assert_eq!(d.composer, Composer::SingleLine, "old box text keeps greedy breaking"); + assert_eq!(d.line_join, LineJoin::Round, "old strokes keep their round corners"); + assert_eq!(TextDoc::default().line_join, LineJoin::Miter); assert!(d.ligatures); assert_eq!(d.kerning, Kerning::Metrics); // Round trip with runs. diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index c25b1da8d..19c17d53f 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -510,6 +510,7 @@ pub struct PlacedGlyph { pub fill: [f32; 4], pub stroke: [f32; 4], pub stroke_width: f64, + pub join: effectcraft_path::Join, /// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows /// on every side, in character space (0 when not bold). pub bold: f64, @@ -838,6 +839,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), + join: path_join(doc.line_join), bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 }, apply_fill: st.apply_fill, char_index: g.char_index, @@ -853,6 +855,15 @@ pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> { text_geom(ctx, layer).map(|t| t.glyphs.iter().map(|g| (g.path(), g.xf)).collect()).unwrap_or_default() } +fn path_join(j: effectcraft_keyframe::LineJoin) -> effectcraft_path::Join { + use effectcraft_keyframe::LineJoin; + match j { + LineJoin::Miter => effectcraft_path::Join::Miter, + LineJoin::Round => effectcraft_path::Join::Round, + LineJoin::Bevel => effectcraft_path::Join::Bevel, + } +} + /// One paint pass of a glyph: fill (false) or stroke (true). type Pass = (usize, bool); @@ -919,7 +930,7 @@ fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: } outer } else if stroke { - let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; + let st = StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() }; effectcraft_path::stroke_coverage(paths, &st, &mm, w, h) } else { grown_coverage(paths, &mm, w, h, bold) diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 4bf459142..503ea097c 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -598,6 +598,7 @@ fn touching_glyphs_join_without_a_seam() { fill, stroke: [0.0; 4], stroke_width: 0.0, + join: effectcraft_path::Join::Miter, bold: 0.0, apply_fill: true, char_index: 0, @@ -668,6 +669,7 @@ fn faux_bold_under_a_non_uniform_scale() { fill: [1.0; 4], stroke: [0.0; 4], stroke_width: 0.0, + join: effectcraft_path::Join::Miter, bold: 2.0, apply_fill: true, char_index: 0, diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index a97272434..79990bbd6 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -572,7 +572,7 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top), g.xf.surface_colors(c))); } if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 { - let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; + let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() }; if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) { let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op]; groups.push((vec![region], c, g.m, usize::from(stroke_on_top), g.xf.surface_colors(c))); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 6a36fbbc9..bc0bb9e01 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -3,7 +3,7 @@ //! otherwise they apply to the whole selected text layer. use crate::i18n::tr; -use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, TextDoc}; +use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, LineJoin, TextDoc}; use effectcraft_engine::render::EvalCtx; use egui::{Align2, Rect, Sense, pos2, vec2}; use serde_json::json; @@ -182,6 +182,20 @@ fn stroke_order_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Grid, doc: } } +/// Line Join of the stroke: Miter / Round / Bevel (grid row 5, under the stroke width). +fn line_join_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec) { + let Grid { x0, y, w, enabled } = g; + let bw = (w - 2.0 * 4.0) / 3.0; + for (i, (key, label, join)) in + [("miter", "Miter", LineJoin::Miter), ("round", "Round", LineJoin::Round), ("bevel", "Bevel", LineJoin::Bevel)].into_iter().enumerate() + { + let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 4.0), y + 5.0 * 28.0), vec2(bw, 22.0)); + if toggle(app, ui, p, r, &format!("character.lineJoin.{key}"), (label, doc.line_join == join, false)) && enabled { + actions.push(json!({"lineJoin": key})); + } + } +} + /// The Character panel for the text being edited (or the selected text layer): font family and /// style, size, leading, tracking, stroke, scale, baseline shift and other numeric fields, kerning, /// fill/stroke colours with eyedroppers, style toggles, OpenType features and variable-font axes. @@ -224,7 +238,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { numeric_fields(app, ui, &p, grid, &doc, &mut actions); kerning_field(app, ui, &p, grid, &doc, &mut actions); stroke_order_field(app, ui, grid, &doc, &mut actions); - y += 5.0 * 28.0 + 8.0; + line_join_field(app, ui, &p, grid, &doc, &mut actions); + y += 6.0 * 28.0 + 8.0; // Fill / stroke swatches with their enable boxes. for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() { let fx = x0 + i as f32 * (w / 2.0); diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index 9f298ec19..ea4b56d26 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -1734,3 +1734,19 @@ fn same_caret_application_text_replacement_rejects_the_previous_context_fence() h.run_steps(1); assert_eq!(edited(&h).1, "Aがbcd"); } + +/// #2077: the Character panel's Line Join buttons set the stroke's corner style (Miter for new text). +#[test] +fn character_panel_line_join_buttons_set_the_join() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Join", "size": 80, "applyStroke": true, "strokeWidth": 8})).unwrap()["layer"].as_u64().unwrap(); + let mut h = harness(EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + let join = |h: &Harness<'_, EffectcraftApp>| layer_doc(&h.state().session, LayerId(lid)).unwrap().line_join; + assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Miter); + let bevel = rect(&h, "character.lineJoin.bevel").center(); + click(&mut h, bevel, Modifiers::NONE); + assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Bevel); +} From fb2fb169dc1f3de411091b91bcaf3b45f231cfea Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 04:32:17 +0700 Subject: [PATCH 36/50] Effects: padded buffers stay within a pixel budget (#2261) Effects that grow their buffer (CC Radial Fast Blur, CC Lens, Magnify, the distorts and blurs) could pad a 20 x 20 layer by 4096 px a side, a ~8200 x 8200 f32 buffer plus its clone. Buf::pad (CPU and GPU) now cuts the pad so the grown buffer stays within 16 Mpx or four times its own area, whichever is larger: small layers are bounded at 256 MiB, big layers still grow as before in practice. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 35 ++++++++++++++++++++++++++++++++++- crates/gpu/src/effects.rs | 1 + 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 32e662fec..56e56bb61 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -312,8 +312,9 @@ impl Buf { self.offset[0] += l as f64; self.offset[1] += t as f64; } - /// Grow by `pad` transparent pixels on every side. + /// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`]. pub fn pad(&mut self, pad: u32) { + let pad = pad_within_budget(self.img.width, self.img.height, pad); if pad == 0 { return; } @@ -323,6 +324,21 @@ impl Buf { } } +/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within +/// 16 Mpx (256 MiB of f32 RGBA) or four times its own area, whichever is larger: a small layer +/// far from an effect's centre can't ask for gigabytes (#2261). Shared with the GPU path. +pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 { + const MIN_BUDGET: f64 = 16_777_216.0; + let (w, h) = (f64::from(w), f64::from(h)); + let budget = MIN_BUDGET.max(4.0 * w * h); + if (w + 2.0 * f64::from(pad)) * (h + 2.0 * f64::from(pad)) <= budget { + return pad; + } + // (w + 2p)(h + 2p) = budget, solved for p. + let p = (((w - h).powi(2) + 4.0 * budget).sqrt() - (w + h)) / 4.0; + (p.floor().max(0.0) as u32).min(pad) +} + /// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`: /// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its /// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp @@ -1344,6 +1360,23 @@ pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, en mod tests { use super::*; + /// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx; + /// big layers may still grow to four times their area, and small pads are untouched. + #[test] + fn padded_buffers_stay_within_the_budget() { + let area = |w: u32, h: u32, p: u32| (u64::from(w) + 2 * u64::from(p)) * (u64::from(h) + 2 * u64::from(p)); + let far = radial_fast_pad((6500.0, 6500.0), 20, 20, 0.8); + assert_eq!(far, 4096, "the effect asks for its full pad"); + let mut b = Buf { img: Image::new(20, 20), offset: [0.0; 2], scale: 1.0 }; + b.pad(far); + assert!(area(20, 20, 0) < area(b.img.width, b.img.height, 0) && area(b.img.width, b.img.height, 0) <= 1 << 24, "{}x{}", b.img.width, b.img.height); + assert_eq!(b.offset, [f64::from((b.img.width - 20) / 2); 2], "the content keeps its place"); + let p = pad_within_budget(3840, 2160, 4096); + assert!(p > 1000 && area(3840, 2160, p) <= 4 * 3840 * 2160 && area(3840, 2160, p + 1) > 4 * 3840 * 2160); + assert_eq!(pad_within_budget(640, 360, 200), 200); + assert_eq!(pad_within_budget(20, 20, 0), 0); + } + #[test] fn run_fx_passthrough() { let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]); diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 1e97b7eab..645f1cc39 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -46,6 +46,7 @@ impl GBuf { /// Buf::pad: grow by `pad` transparent pixels on every side. pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> { + let pad = effectcraft_effects::pad_within_budget(self.img.width, self.img.height, pad); if pad == 0 { return Some(()); } From 5d764e647319bb493fe860fb7e720fdb3a3ab004 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 04:36:58 +0700 Subject: [PATCH 37/50] Timeline: drag effects, masks and shape items to reorder them (#2210) Dragging an effect's (or mask's, shape item's, text animator's) row in the Timeline drew a marquee. The row now drags: a line shows where it lands among its siblings and the drop runs prop.moveGroup (one undo step); Escape cancels, as the layer reorder drag does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 69 ++++++++++++++++++- crates/ui-egui/tests/it/ui_timeline_shapes.rs | 52 ++++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7ae50ac0d..dad0af927 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1324,6 +1324,20 @@ fn layer_drag_id() -> egui::Id { egui::Id::unique("tl-layer-drag") } +/// An effect, mask, shape item or text animator being dragged to a new place among its +/// siblings (#2210). +#[derive(Clone)] +struct GroupDrag { + comp: ItemId, + owner: egui::Id, + layer: LayerId, + uid: u64, +} + +fn group_drag_id() -> egui::Id { + egui::Id::unique("tl-group-drag") +} + /// A keyframe drag in progress: where the pointer started, the grabbed key's comp time /// (seconds), how many frames the selected keys have moved so far and, for a Quick Offset /// (Ctrl/Cmd+Alt), how many layers it staggers. @@ -1522,7 +1536,7 @@ fn shift_range(ctx: &egui::Context, cid: ItemId, comp: &Comp, rows: &[Row], sele /// Where layers dropped at height `py` land among the visible layer rows: above the first row /// whose middle is below it (its index in `layer_rows`), else below the last row. Returns that /// and the y of the line that shows it (`top` without rows). -fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option, f32) { +fn insertion(layer_rows: &[(Rect, T)], py: f32, top: f32) -> (Option, f32) { let at = layer_rows.iter().position(|(r, _)| py < r.center().y); let y = match at { Some(i) => layer_rows.get(i).map_or(top, |(r, _)| r.min.y), @@ -1876,6 +1890,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { alt_scale_and_graph(app, ui, g, &ectx); graph_empty_area(ui, g); layer_reorder_drag(ui, g); + group_reorder_drag(ui, g); quick_offset_follow(g); key_drag_follow(app, g); key_box_select(app, ui, g); @@ -3550,6 +3565,7 @@ fn group_mask_mode(ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) { /// Group row click and double-click. fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) { + let cid = g.tl.cid; let cw = &g.tl.cw; let actions = &mut g.tl.actions; let ui_actions = &mut g.ui_actions; @@ -3558,7 +3574,12 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: let indent = rc.indent; let RowKind::Group { uid, name, fx, .. } = &rc.row.kind else { return }; let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y)); - let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click()); + // Effects, masks, shape items and animators drag to a new place among their siblings. + let movable = !layer.switches.locked && layer.props.find_group(*uid).is_some_and(PropGroup::is_instance); + let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), if movable { Sense::click_and_drag() } else { Sense::click() }); + if movable && gr.drag_started_by(egui::PointerButton::Primary) { + ui.ctx().data_mut(|d| d.insert_temp(group_drag_id(), GroupDrag { comp: cid, owner: gr.id, layer: layer.id, uid: *uid })); + } app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name); if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 { actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); @@ -4472,6 +4493,50 @@ fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { // Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer } +/// Group reorder drag (#2210): a line where the dragged effect, mask or shape item lands among +/// its siblings; the drop moves it there (`prop.moveGroup`). Escape cancels. +fn group_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { + let ctx = g.tl.ctx.clone(); + let comp = g.tl.comp.clone(); + let Some(drag) = ctx.data(|d| d.get_temp::(group_drag_id())) else { return }; + let (down, released, escape) = + ctx.input(|i| (i.pointer.primary_down(), i.pointer.button_released(egui::PointerButton::Primary), i.key_pressed(egui::Key::Escape))); + let dropped = ctx.drag_stopped_id() == Some(drag.owner) && released; + let parent = comp.layer(drag.layer).and_then(|l| l.props.parent_of(drag.uid)); + let valid = ui.is_enabled() && drag.comp == g.tl.cid && parent.is_some() && !escape && (dropped || (down && ctx.dragged_id() == Some(drag.owner))); + if dropped || !valid { + ctx.data_mut(|d| d.remove::(group_drag_id())); + } + let Some(parent) = parent.filter(|_| valid) else { return }; + let siblings: Vec = parent.children.iter().map(Node::uid).collect(); + let rows: Vec<(Rect, u64)> = g + .hit_rows + .iter() + .filter_map(|(r, row)| match &row.kind { + RowKind::Group { uid, .. } if row.layer == drag.layer && siblings.contains(uid) => Some((*r, *uid)), + _ => None, + }) + .collect(); + let Some(py) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).map(|p| p.y) else { return }; + if rows.is_empty() { + return; + } + let (at, line_y) = insertion(&rows, py, g.tl.rows_rect.min.y); + if !dropped { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + g.tl.p.with_clip_rect(g.tl.rows_rect).line_segment([pos2(g.tl.rect.min.x, line_y), pos2(g.tl.graph_x0, line_y)], Stroke::new(2.0, g.tl.t.accent)); + return; + } + // The sibling the drop lands before (or after, below the last visible one). + let Some(&(_, anchor)) = at.map_or(rows.last(), |i| rows.get(i)) else { return }; + let from = siblings.iter().position(|u| *u == drag.uid); + let rest: Vec = siblings.iter().copied().filter(|u| *u != drag.uid).collect(); + let to = if anchor == drag.uid { from } else { rest.iter().position(|u| *u == anchor).map(|i| i + usize::from(at.is_none())) }; + if let Some(to) = to.filter(|to| Some(*to) != from) { + g.tl.actions.push(("prop.moveGroup".into(), json!({"layer": drag.layer.0, "prop": drag.uid, "index": to + 1}))); + } +} + /// Quick Offset of layer bars follows the pointer. fn quick_offset_follow(g: &mut Rw) { let t = g.tl.t; diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index c8b45e8da..aadb52ffe 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -380,3 +380,55 @@ fn ctrl_v_pastes_svg_markup_as_a_shape_layer() { assert_eq!(c.layers.len(), 4); assert_eq!(c.layers[0].name, "Oval 2"); } + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, cancel: bool) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.step(); + } + if cancel { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// #2210: an effect's row drags to a new place in the layer's effect stack, as in After Effects +/// (not a marquee); Escape cancels the drag. +#[test] +fn effects_drag_to_a_new_place_in_the_timeline() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Gaussian Blur", "Invert", "Tint"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let effects = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().clone(); + let uids: Vec = effects.groups().map(|g| g.uid).collect(); + let mut h = harness(s); + h.state_mut().ui.timeline.open_groups.insert(effects.uid); + open_layer(&mut h, l); + let names = |h: &Harness<'_, EffectcraftApp>| -> Vec { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().groups().map(|g| g.name.clone()).collect() + }; + let row = |h: &Harness<'_, EffectcraftApp>, uid: u64| { + let e = h.state().auto.find(&format!("timeline.group.{uid}.name")).unwrap_or_else(|| panic!("no row for {uid}")); + egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) + }; + // Tint above Gaussian Blur. + let (tint, blur) = (row(&h, uids[2]), row(&h, uids[0])); + drag(&mut h, tint.center(), pos2(blur.center().x, blur.min.y + 2.0), true); + assert_eq!(names(&h), ["Gaussian Blur", "Invert", "Tint"], "Escape cancels"); + drag(&mut h, tint.center(), pos2(blur.center().x, blur.min.y + 2.0), false); + assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"]); + // Gaussian Blur (now second) below Invert, the last. + let (blur, invert) = (row(&h, uids[0]), row(&h, uids[1])); + drag(&mut h, blur.center(), pos2(invert.center().x, invert.max.y - 2.0), false); + assert_eq!(names(&h), ["Tint", "Invert", "Gaussian Blur"]); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"], "one undo step per drop"); +} From cca5447e09e4bb8f689b19dda093901f0d3e1c4e Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 17:43:30 -0400 Subject: [PATCH 38/50] Expressions: propertyGroup reaches the kept layer, not a new one --- crates/expr/src/prelude.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 29df86f83..9c950d4c3 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -435,7 +435,7 @@ function __ancestor(c, l, path, countUp) { if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more'); n = Math.floor(n); var info = __h(__ANCESTOR, c, l, path, n); - if (info === 'layer') return new Layer(c, l); + if (info === 'layer') return __layer(c, l); if (info === null) __err('propertyGroup(' + n + ') goes above the layer'); return __mkGroup(c, l, info); } From 48c9993bd35b0f1213085dcf47731649839fe30d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 17:43:30 -0400 Subject: [PATCH 39/50] Expressions: test that propertyGroup reaches the same layer as thisLayer --- crates/expr/src/tests/property_group.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/expr/src/tests/property_group.rs b/crates/expr/src/tests/property_group.rs index debe5e5ac..c96045ec2 100644 --- a/crates/expr/src/tests/property_group.rs +++ b/crates/expr/src/tests/property_group.rs @@ -72,6 +72,9 @@ fn property_group_reaches_the_layer() { assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0); assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0); assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0); + // The layer it reaches is the same object as `thisLayer`. + assert_close!(f.num("thisLayer('transform').propertyGroup(1) === thisLayer ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("thisProperty.propertyGroup(2) === thisLayer ? 1 : 0", 0.0), 1.0); let s = staggered(); assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0); } From ffad10c7f33ba48bf93bbf460677f5fa30e4dfc4 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 17:53:50 -0400 Subject: [PATCH 40/50] Expressions: a kept layer's info is private, so freezing the layer doesn't keep it --- crates/expr/src/prelude.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 9c950d4c3..0e20cbd99 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -552,12 +552,18 @@ function Layer(c, l) { delete f.length; Object.setPrototypeOf(f, Layer.prototype); // Kept across evaluations (`__layer`), so an expression can't change which layer it is, delete - // a field or leave its own on it. `__i` and `__s` stay writable: `__info` reads them again in an - // evaluation whose `__S` isn't `__s`, and every evaluation has a new `__S`. + // a field or leave its own on it. Its info is private to `__info`, which reads it again in an + // evaluation whose `__S` isn't the one it was read in (every evaluation has a new `__S`), so + // freezing the layer can't keep its info either. + var info = null, read = null; Object.defineProperty(f, '__c', { value: c }); Object.defineProperty(f, '__l', { value: l }); - Object.defineProperty(f, '__i', { value: null, writable: true }); - Object.defineProperty(f, '__s', { value: null, writable: true }); + Object.defineProperty(f, '__info', { + get: function () { + if (read !== __S) { info = __h(__LINFO, c, l); read = __S; } + return info; + }, + }); Object.preventExtensions(f); return f; } @@ -609,10 +615,6 @@ function __m4(m, p, vec) { Layer.prototype = { __isLayer: true, - get __info() { - if (this.__s !== __S) { this.__i = __h(__LINFO, this.__c, this.__l); this.__s = __S; } - return this.__i; - }, get name() { return this.__info[0]; }, get index() { return this.__info[1]; }, get inPoint() { return this.__info[2]; }, From 4b7ed9c77f9404f11be374bbe3f999de28cdb7f0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 17:53:50 -0400 Subject: [PATCH 41/50] Expressions: test that a frozen kept layer reads the project again --- crates/expr/src/tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index bd942f7b0..dbf686c8e 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1407,6 +1407,10 @@ fn a_kept_layer_reads_the_project_again() { assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); let _ = f.ev(f.a, ROT, "var L = thisLayer; delete L.__c; delete L.__l; delete L.__i; delete L.__s; 0", 0.0); assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); + let _ = f.ev(f.a, ROT, "Object.freeze(thisLayer); 0", 0.0); + f.layer_mut(f.a).name = "Ay".into(); + f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 12.0); } /// The kept layers are bounded: past the bound a layer isn't kept, an evaluation that makes more From e2cf4477e71482c4e24b70f9b0539018d1fee5b1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 05:15:35 +0700 Subject: [PATCH 42/50] Viewer: a mask vertex held at the panel edge pans the view (#999) Dragging a mask or shape vertex, a tangent or a Pen point against the edge of the Composition panel now pans the view toward that edge, faster the further out the pointer is, and the point keeps moving with it, so zoomed-in rotoscoping no longer stops at the panel edge (as in After Effects). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewer.rs | 34 ++++++++++++++++++ crates/ui-egui/src/panels/viewer/tests.rs | 12 +++++++ crates/ui-egui/tests/it/ui_viewer.rs | 42 +++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 2e3dc9be0..f150cfc08 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1255,6 +1255,39 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v.puppet_layers = puppet_layers; } +/// Auto-pan (#999): a mask vertex, tangent or Pen drag held at (or past) the edge of the view +/// pans it, as in After Effects, so the point moves on past what was visible. Returns the map +/// of the panned view. +fn edge_pan(app: &mut EffectcraftApp, ctx: &egui::Context, g: &Gesture, map: ViewerMap, pos: Pos2) -> ViewerMap { + if !matches!(g, Gesture::Vertices { .. } | Gesture::Tangent { .. } | Gesture::Pen { .. }) { + return map; + } + let d = edge_pan_speed(map.area, pos) * ctx.input(|i| i.stable_dt).min(0.1); + if d == egui::Vec2::ZERO { + return map; + } + app.ui.viewer.pan = [app.ui.viewer.pan[0] + d.x, app.ui.viewer.pan[1] + d.y]; + ctx.request_repaint(); + ViewerMap { origin: map.origin + d, ..map } +} + +/// How fast (points per second, per axis) the view pans under a drag at `pos`: within 24 points +/// of an edge of `area`, or past it, toward that edge and faster the further out. +fn edge_pan_speed(area: Rect, pos: Pos2) -> egui::Vec2 { + const ZONE: f32 = 24.0; + let axis = |p: f32, lo: f32, hi: f32| { + let depth = if p < lo + ZONE { + lo + ZONE - p + } else if p > hi - ZONE { + hi - ZONE - p + } else { + 0.0 + }; + (depth / ZONE).clamp(-3.0, 3.0) * 400.0 + }; + vec2(axis(pos.x, area.min.x, area.max.x), axis(pos.y, area.min.y, area.max.y)) +} + fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) { let t = v.t; let zoom = v.zoom; @@ -1265,6 +1298,7 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: let painter = v.painter.clone(); let gesture: Option = ui.data(|d| d.get_temp(gid)); if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) { + let map = edge_pan(app, &ctx, &g, map, pos); let cpt = map.to_comp(pos); let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::(egui::Id::unique("viewer-drag-n")).unwrap_or(0))); match g { diff --git a/crates/ui-egui/src/panels/viewer/tests.rs b/crates/ui-egui/src/panels/viewer/tests.rs index cb5bffe65..f9d3ef2f4 100644 --- a/crates/ui-egui/src/panels/viewer/tests.rs +++ b/crates/ui-egui/src/panels/viewer/tests.rs @@ -139,3 +139,15 @@ fn gizmo_segments_clip_at_the_near_plane() { assert!(s[1][0] > 10.0 && s[1][0].is_finite(), "toward the camera it grows, never flips: {s:?}"); assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none()); } + +/// #999: a vertex drag pans the view only near or past the edge, toward it, faster further out. +#[test] +fn edge_pan_speed_moves_toward_the_edge() { + let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(900.0, 650.0)); + assert_eq!(edge_pan_speed(area, pos2(500.0, 350.0)), egui::Vec2::ZERO); + let left = edge_pan_speed(area, pos2(105.0, 350.0)); + assert!(left.x > 0.0 && left.y == 0.0, "near the left edge the view moves right to show more: {left:?}"); + assert!(edge_pan_speed(area, pos2(20.0, 350.0)).x > left.x, "faster past the edge"); + let corner = edge_pan_speed(area, pos2(950.0, 700.0)); + assert!(corner.x < 0.0 && corner.y < 0.0, "{corner:?}"); +} diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index fb7557c83..41cf5fe43 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -831,6 +831,48 @@ fn shift_drag_keeps_a_mask_vertex_to_one_axis() { assert!(free[0] > shifted[0] && free[1] > shifted[1], "without Shift both axes move: {shifted:?} -> {free:?}"); } +/// #999: a mask vertex dragged against the edge of the Composition panel pans the view, and the +/// vertex keeps moving while the pointer is held there, as in After Effects. +#[test] +fn a_vertex_held_at_the_viewer_edge_pans_the_view() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]]; + let mask = { + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap(); + s.execute("mask.selectVertices", json!({"vertices": [{"layer": plate, "mask": m, "index": 0}]})).unwrap(); + m + }; + h.run_steps(2); + let pan0 = h.state().ui.viewer.pan; + let from = rect(&h, &format!("viewer.mask.{mask}.vertex.0")).center(); + let edge = pos2(2.0, from.y); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (edge - from) * (i as f32 / 6.0))); + h.step(); + } + let reached = mask_path(&h, "Plate").vertices[0]; + for _ in 0..20 { + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: edge, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + let pan = h.state().ui.viewer.pan; + assert!(pan[0] > pan0[0] && pan[1] == pan0[1], "the view panned right to show more on the left: {pan0:?} -> {pan:?}"); + let after = mask_path(&h, "Plate").vertices[0]; + assert!(after[0] < reached[0], "the vertex kept moving left with the view: {reached:?} -> {after:?}"); + // Back inside the view, nothing pans. + let still = h.state().ui.viewer.pan; + let mid = rect(&h, "viewer.comp").center(); + drag(&mut h, mid, mid + vec2(5.0, 0.0)); + assert_eq!(h.state().ui.viewer.pan, still); +} + /// #1769: a hidden masked layer keeps its mask path and vertices, and they remain editable. #[test] fn hidden_masked_layer_keeps_its_mask_path_editable() { From f5363e9d399af19cbf666c94bfb3b79c56216ee5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 05:16:23 +0700 Subject: [PATCH 43/50] README: 308 effects, and a test keeps the count current (#2565) The README quoted 306 effects in three places while the registry (and the generated docs/effects.md) had 307, now 308. A catalog test checks every effect count the README gives against the registry. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- crates/effects/src/catalog.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 601f268b0..12970b8ba 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@

A free, open-source compositor: compositions, layers, - keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS, + keyframes, 308 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS, Windows and Linux, and in the browser. Young, moving fast, and already usable.

@@ -127,7 +127,7 @@ or into the Timeline, between layers and at the time you point to. ## Effects -306 built-in effects across its categories, each with its parameter names, order and +308 built-in effects across its categories, each with its parameter names, order and defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise, Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective, @@ -231,7 +231,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING ## Where it stands EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set -exists, including 306 effects, but breadth is not the same as full maturity across every edge +exists, including 308 effects, but breadth is not the same as full maturity across every edge case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended for production client work. In particular: diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 80a937b22..85be739ed 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -182,6 +182,20 @@ mod tests { assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current"); } + /// The README quotes the number of effects (#2565): every count it gives is the registry's. + #[test] + fn readme_effect_counts_are_current() { + let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md")).unwrap_or_default(); + let words: Vec<&str> = readme.split_whitespace().collect(); + let counts: Vec<&str> = words + .windows(3) + .filter(|w| w[0].parse::().is_ok() && (w[1].starts_with("effects") || (w[1] == "built-in" && w[2].starts_with("effects")))) + .map(|w| w[0]) + .collect(); + let n = super::registry().len().to_string(); + assert!(!counts.is_empty() && counts.iter().all(|c| *c == n), "README.md says {counts:?} effects; the registry has {n}"); + } + #[test] fn cpu_only_controls_keep_effects_off_the_gpu() { use effectcraft_keyframe::Value; From 047ffbaba516c6ec536ade1f63ea208ddf8ca19e Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 19:28:40 +0200 Subject: [PATCH 44/50] Keyframes: mute keyframes without deleting them (#1894) Add a muted flag to Keyframe. Muted keys stay in the Timeline and Graph Editor (dimmed, selectable and movable) but evaluation skips them, so the active neighbours interpolate as if they were not there; auto-Bezier and eases use the active neighbours. When every key is muted the property falls back to its static value.\n\nkeys.setMuted {muted} mutes or unmutes the selected keys in one undo step. The Timeline context menu offers Mute/Unmute Keyframes and the Animation menu has Mute Keyframes. Muted keys have no Bezier handles and motion-path points are dimmed.\n\nLottie export omits muted keys (with a warning) because Lottie has no mute state; scripting keeps seeing them in numKeys/key().\n\nTests: keyframe evaluation (linear, auto-Bezier spatial, all muted), serialization and value replacement, engine command + undo, Lottie export. --- crates/engine/src/cmd/prop.rs | 10 +++ crates/engine/src/menus.rs | 1 + crates/engine/src/tests/menu_cmds.rs | 26 +++++++ crates/interchange/src/export.rs | 26 ++++--- crates/interchange/src/tests.rs | 53 +++++++++++++++ crates/keyframe/src/lib.rs | 71 ++++++++++++++------ crates/keyframe/src/tests.rs | 57 ++++++++++++++++ crates/lottie/src/anim.rs | 25 +++++-- crates/lottie/src/tests.rs | 18 +++++ crates/ui-egui/src/i18n.rs | 10 +++ crates/ui-egui/src/panels/graph.rs | 20 ++++-- crates/ui-egui/src/panels/timeline.rs | 3 + crates/ui-egui/src/panels/viewer_overlays.rs | 45 +++++++------ 13 files changed, 303 insertions(+), 62 deletions(-) diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index ab2f0a1e2..663429fb2 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -1189,6 +1189,15 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result { }) } +fn set_muted(s: &mut Session, p: &Value) -> Result { + let muted = b_p(p, "muted").unwrap_or(true); + let label = if muted { "Mute Keyframes" } else { "Unmute Keyframes" }; + edit_keys(s, label, None, move |keys, i, _| { + keys[i].muted = muted; + false + }) +} + /// A number or a per-dimension array of numbers. fn nums_p(p: &Value, k: &str, scale: f64) -> Option> { match p.get(k)? { @@ -1381,6 +1390,7 @@ pub fn specs() -> Vec { &json!({"which": "out"}) )), cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold), + cmd!("keys.setMuted", "Mute Keyframes", ["Animation"], None, "{muted?: bool (default true)}", has_keys, set_muted), cmd!( "keys.interpolation", "Keyframe Interpolation...", diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 9296e1322..3a240fc60 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -1036,6 +1036,7 @@ Animation --- Add Keyframe | anim.addKeyframe Toggle Hold Keyframe | keys.toggleHold + Mute Keyframes | keys.setMuted Keyframe Interpolation... | keys.interpolation Keyframe Velocity... | keys.velocity Keyframe Assistant diff --git a/crates/engine/src/tests/menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs index 93f81736f..f5db9500c 100644 --- a/crates/engine/src/tests/menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -1483,6 +1483,32 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() { assert!(s.state.selected_keys.is_empty()); } +#[test] +fn muting_keys_skips_them_in_the_timeline_value_and_undoes() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + for (t, v) in [(0.0, 0.0), (1.0, 100.0), (2.0, 300.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let value_at = |s: &Session, t: f64| layer(s, a).props.prop("transform/opacity").unwrap().value_at(Tick::from_seconds_f64(t)).as_f64(); + let select = |s: &mut Session| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + select(&mut s); + let before = value_at(&s, 0.5); + s.execute("keys.setMuted", json!({"muted": true})).unwrap(); + assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); + // The muted key no longer bends the 0 → 300 segment. + assert!((value_at(&s, 0.5) - 75.0).abs() < 1e-6); + assert!((value_at(&s, 1.5) - 225.0).abs() < 1e-6); + s.undo(); + assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); + assert!((value_at(&s, 0.5) - before).abs() < 1e-6); + // The same command unmutes. + select(&mut s); + s.execute("keys.setMuted", json!({"muted": true})).unwrap(); + s.execute("keys.setMuted", json!({"muted": false})).unwrap(); + assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); +} + #[test] fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() { let mut s = comp(); diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs index 7da18913a..cc44475f3 100644 --- a/crates/interchange/src/export.rs +++ b/crates/interchange/src/export.rs @@ -155,12 +155,7 @@ fn unsupported(project: &Project, comp: &Comp, l: &Layer, format: TimelineFormat return Some(format!("blend mode {}", l.blend_mode.label())); } // Premiere's Basic Motion in the interchange formats has one (uniform) scale. - if let Some(s) = l.transform().and_then(|t| t.get("scale")) - && std::iter::once(&s.value).chain(s.keys.iter().map(|k| &k.value)).any(|v| { - let a = v.as_vec3(); - (a[0] - a[1]).abs() > 1e-9 - }) - { + if l.transform().and_then(|t| t.get("scale")).is_some_and(non_uniform_scale) { return Some("non-uniform scale".into()); } if l.preserve_transparency { @@ -581,10 +576,7 @@ impl Exporter<'_> { } } if let Some(p) = tr.get("scale") { - let uniform = std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)).all(|v| { - let a = v.as_vec3(); - (a[0] - a[1]).abs() < 1e-9 - }); + let uniform = !non_uniform_scale(p); m.params.insert("scale".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[1]))); if !uniform { m.params.insert("scale_width".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[0]))); @@ -636,6 +628,16 @@ fn differs(v: &Value, def: [f64; 2]) -> bool { (a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6 } +/// Whether a scale property is non-uniform at some point: active key values, or the static +/// value when every key is muted (muted keys are not exported, #1894). +fn non_uniform_scale(p: &Property) -> bool { + let non_uniform = |v: &Value| { + let a = v.as_vec3(); + (a[0] - a[1]).abs() > 1e-9 + }; + if p.keys.iter().any(|k| !k.muted) { p.keys.iter().filter(|k| !k.muted).any(|k| non_uniform(&k.value)) } else { non_uniform(&p.value) } +} + /// The clip's `id` effect, or a new instance of it (None if the registry has no such effect). fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option { ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance())) @@ -651,7 +653,9 @@ fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) { /// A property → a FilmCraft parameter (layer-time keys → clip-time keys). fn param_out(p: &Property, to_clip: &impl Fn(Tick) -> filmcraft_time::Tick, conv: impl Fn(&Value) -> fp::ParamValue) -> fp::Param { let mut out = fp::Param::new(conv(&p.value)); - for k in &p.keys { + // XML timelines have no mute state: leave muted keys out, as the Lottie exporter does, so + // the exported animation matches what the renderer shows (#1894). + for k in p.keys.iter().filter(|k| !k.muted) { let mut fk = fp::Keyframe::new(to_clip(k.time), conv(&k.value)); fk.interp = match k.out_interp { Interp::Linear => fp::Interpolation::Linear, diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 1e0c7d417..2fe36d47b 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -480,6 +480,59 @@ fn tr_orig(l: &Layer) -> &effectcraft_project::Property { l.transform().unwrap().get("position").unwrap() } +/// #1894: XML timelines have no mute state, so a muted key must not be written as a keyframe +/// (the export would otherwise animate through it). +#[test] +fn export_skips_muted_keyframes() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(4.0)); + let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None); + { + let pos = l.transform_mut().unwrap().get_mut("position").unwrap(); + pos.keys = vec![ + Keyframe::new(t(0.0), Value::Vec3([100.0, 120.0, 0.0])), + Keyframe::new(t(1.0), Value::Vec3([500.0, 240.0, 0.0])), + Keyframe::new(t(2.0), Value::Vec3([300.0, 60.0, 0.0])), + ]; + pos.keys[1].muted = true; + } + c.layers.push(l); + let cid = comp_item(&mut p, "Main", c); + let out = export(&p, cid, &ExportOptions { format: TimelineFormat::Fcp7Xml, ..Default::default() }, &HashMap::new()).unwrap(); + let (q, r) = import_doc(&out.bytes, "Main.xml", &mut probe); + let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main"); + let pos = layer(q.comp(main).unwrap(), "clip").transform().unwrap().get("position").unwrap(); + assert_eq!(pos.keys.len(), 2, "the muted key was exported"); + assert_eq!(pos.keys[0].value.as_vec3()[0], 100.0); + assert_eq!(pos.keys[1].value.as_vec3()[0], 300.0); +} + +/// #1894: a muted non-uniform scale key must not force a uniform layer to pre-render; only the +/// active keys matter, and the static value when every key is muted. +#[test] +fn muted_non_uniform_scale_does_not_force_prerendering() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(2.0)); + let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None); + { + let scale = l.transform_mut().unwrap().get_mut("scale").unwrap(); + scale.keys = vec![Keyframe::new(t(0.0), Value::Vec3([100.0, 100.0, 100.0])), Keyframe::new(t(1.0), Value::Vec3([50.0, 200.0, 100.0]))]; + scale.keys[1].muted = true; + } + let lid = l.id; + c.layers.push(l); + let cid = comp_item(&mut p, "Main", c); + assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()), None); + // With every key muted the static value drives evaluation, so a non-uniform static scale + // still takes the flattened path. + let scale = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().transform_mut().unwrap().get_mut("scale").unwrap(); + scale.keys[0].muted = true; + scale.value = Value::Vec3([50.0, 200.0, 100.0]); + assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()).as_deref(), Some("non-uniform scale")); +} + #[test] fn export_without_prerender_leaves_text_out() { let (p, cid) = ae_project(); diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index f0dd4849c..abf929fe9 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -15,6 +15,7 @@ pub mod value; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; +use std::borrow::Borrow; pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, LineJoin, OpenType, ParaStyle, StyleRun}; pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value}; @@ -89,12 +90,19 @@ pub struct Keyframe { /// Keyframe color label: index into the label list (0 = None). #[serde(default, skip_serializing_if = "is_zero")] pub label: u8, + /// Muted keys stay in the list (and the UI) but evaluation skips them. + #[serde(default, skip_serializing_if = "is_false")] + pub muted: bool, } fn is_zero(v: &u8) -> bool { *v == 0 } +fn is_false(v: &bool) -> bool { + !*v +} + fn yes() -> bool { true } @@ -116,6 +124,7 @@ impl Keyframe { spatial_continuous: false, roving: false, label: 0, + muted: false, } } pub fn hold(mut self) -> Keyframe { @@ -320,16 +329,16 @@ fn v3(v: &Value) -> [f64; 3] { /// tangent is a sixth of the way from the previous key to the next, the in tangent its mirror, and /// key times play no part. An end key uses its one neighbour, so the path leaves the first key /// and enters the last one toward the neighbouring key instead of with no tangent. -fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { +fn auto_spatial>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) { if i >= keys.len() || keys.len() < 2 { return ([0.0; 3], [0.0; 3]); } - let a = v3(&keys[i.saturating_sub(1)].value); - let c = v3(&keys[(i + 1).min(keys.len() - 1)].value); + let a = v3(&keys[i.saturating_sub(1)].borrow().value); + let c = v3(&keys[(i + 1).min(keys.len() - 1)].borrow().value); let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0]; // A side facing a key at the same position gets no tangent, so the segment between two // identical keys has zero length and the layer holds still (#1675). - let p = v3(&keys[i].value); + let p = v3(&keys[i].borrow().value); let tin = if i > 0 && a == p { [0.0; 3] } else { [-tout[0], -tout[1], -tout[2]] }; let tout = if i + 1 < keys.len() && c == p { [0.0; 3] } else { tout }; (tin, tout) @@ -337,7 +346,12 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { /// Effective spatial tangents of key `i` (auto or stored). pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { - let Some(key) = keys.get(i) else { return ([0.0; 3], [0.0; 3]) }; + tangents_of(keys, i) +} + +/// [`spatial_tangents`] over stored keys or a filtered list of them (see [`evaluate`]). +fn tangents_of>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) { + let Some(key) = keys.get(i).map(Borrow::borrow) else { return ([0.0; 3], [0.0; 3]) }; if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) } } @@ -373,18 +387,18 @@ pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { } /// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope). -fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease { - let k = &keys[i]; +fn effective_ease>(keys: &[K], i: usize, d: usize, spatial: bool, out: bool) -> Ease { + let k = keys[i].borrow(); let list = if out { &k.out_ease } else { &k.in_ease }; if k.auto_bezier && i > 0 && i + 1 < keys.len() { - let dt = secs(keys[i + 1].time) - secs(keys[i - 1].time); + let dt = secs(keys[i + 1].borrow().time) - secs(keys[i - 1].borrow().time); let slope = if spatial { - let a = v3(&keys[i - 1].value); - let c = v3(&keys[i + 1].value); + let a = v3(&keys[i - 1].borrow().value); + let c = v3(&keys[i + 1].borrow().value); ((c[0] - a[0]).powi(2) + (c[1] - a[1]).powi(2) + (c[2] - a[2]).powi(2)).sqrt() / dt.max(1e-12) } else { - let a = keys[i - 1].value.components(); - let c = keys[i + 1].value.components(); + let a = keys[i - 1].borrow().value.components(); + let c = keys[i + 1].borrow().value.components(); (c.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0)) / dt.max(1e-12) }; return Ease { speed: slope, influence: 1.0 / 6.0 }; @@ -508,23 +522,39 @@ pub fn time_reverse(keys: &mut [Keyframe]) { } /// Evaluate a keyframed property at `t`. `spatial` selects motion-path semantics for 2D/3D values. +/// Muted keys are skipped, so the active neighbours interpolate as if they were not there; `None` +/// means every key is muted (the property's static value applies). pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option { - let first = keys.first()?; + if !keys.iter().any(|k| k.muted) { + return evaluate_active(keys, t, spatial); + } + // Muting is rare: skip the muted keys by reference rather than copying every key and value. + let active: Vec<&Keyframe> = keys.iter().filter(|k| !k.muted).collect(); + evaluate_active(&active, t, spatial) +} + +fn evaluate_active>(keys: &[K], t: Tick, spatial: bool) -> Option { + let first = keys.first()?.borrow(); if keys.len() == 1 || t <= first.time { return Some(first.value.clone()); } - let last = keys.last()?; + let last = keys.last()?.borrow(); if t >= last.time { return Some(last.value.clone()); } - let i = keys.partition_point(|k| k.time <= t) - 1; - Some(segment_value(keys, i, t, spatial)) + let i = keys.partition_point(|k| k.borrow().time <= t) - 1; + Some(segment_value_of(keys, i, t, spatial)) } /// Value inside segment `i` → `i+1` at time `t`. pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value { - let a = &keys[i]; - let b = &keys[i + 1]; + segment_value_of(keys, i, t, spatial) +} + +/// [`segment_value`] over stored keys or a filtered list of them (see [`evaluate`]). +fn segment_value_of>(keys: &[K], i: usize, t: Tick, spatial: bool) -> Value { + let a = keys[i].borrow(); + let b = keys[i + 1].borrow(); if a.out_interp == Interp::Hold || !a.value.interpolates() { return a.value.clone(); } @@ -539,8 +569,8 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val if is_spatial { let p0 = v3(&a.value); let p3 = v3(&b.value); - let (_, out_t) = spatial_tangents(keys, i); - let (in_t, _) = spatial_tangents(keys, i + 1); + let (_, out_t) = tangents_of(keys, i); + let (in_t, _) = tangents_of(keys, i + 1); let seg = SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]); let len = seg.length(); let f = if lin_out && lin_in || len <= 1e-12 { @@ -617,6 +647,7 @@ pub fn set_key(keys: &mut Vec, key: Keyframe) -> usize { k.spatial_continuous = old.spatial_continuous; k.roving = old.roving; k.label = old.label; + k.muted = old.muted; keys[i] = k; i } diff --git a/crates/keyframe/src/tests.rs b/crates/keyframe/src/tests.rs index 0a31cfc19..18e9b37be 100644 --- a/crates/keyframe/src/tests.rs +++ b/crates/keyframe/src/tests.rs @@ -395,3 +395,60 @@ proptest::proptest! { proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v)); } } + +#[test] +fn muted_keys_are_skipped_by_evaluation() { + let mut keys = + vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(100.0)), Keyframe::new(s(2.0), Value::Scalar(300.0))]; + let at = |keys: &[Keyframe], t: f64| evaluate(keys, s(t), false).unwrap().as_f64(); + assert!((at(&keys, 0.5) - 50.0).abs() < 1e-9); + assert!((at(&keys, 1.5) - 200.0).abs() < 1e-9); + keys[1].muted = true; + // The 0 → 300 segment now spans 0 → 2 s. + assert!((at(&keys, 0.5) - 75.0).abs() < 1e-9); + assert!((at(&keys, 1.0) - 150.0).abs() < 1e-9); + assert!((at(&keys, 1.5) - 225.0).abs() < 1e-9); + for k in &mut keys { + k.muted = true; + } + assert!(evaluate(&keys, s(0.5), false).is_none()); +} + +#[test] +fn muted_key_automation_uses_the_active_neighbours() { + let mut keys = vec![ + Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), + Keyframe::new(s(1.0), Value::Vec2([50.0, 100.0])), + Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])), + ]; + for k in &mut keys { + k.auto_bezier = true; + k.in_interp = Interp::Bezier; + k.out_interp = Interp::Bezier; + } + let all = keys.clone(); + let mut active = keys.clone(); + active.remove(1); + keys[1].muted = true; + for t in [0.0, 0.25, 0.5, 1.0, 1.5, 1.75, 2.0] { + assert_eq!(evaluate(&keys, s(t), true), evaluate(&active, s(t), true), "{t}"); + } + // The muted auto-Bezier key no longer bulges the motion path. + assert_ne!(evaluate(&keys, s(0.5), true), evaluate(&all, s(0.5), true)); +} + +#[test] +fn mute_round_trips_and_survives_value_replacement() { + let mut k = Keyframe::new(s(0.0), Value::Scalar(1.0)); + let json = serde_json::to_value(&k).unwrap(); + assert!(json.get("muted").is_none(), "false is the default and is skipped"); + k.muted = true; + let json = serde_json::to_value(&k).unwrap(); + assert_eq!(json["muted"], serde_json::json!(true)); + let back: Keyframe = serde_json::from_value(json).unwrap(); + assert!(back.muted); + let mut keys = vec![k]; + set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(2.0))); + assert!(keys[0].muted); + assert_eq!(keys[0].value, Value::Scalar(2.0)); +} diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs index e3264e3b7..4925460a9 100644 --- a/crates/lottie/src/anim.rs +++ b/crates/lottie/src/anim.rs @@ -73,9 +73,22 @@ fn is_spatial(p: &Property) -> bool { } /// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions). -/// `label` names the property in warnings. +/// `label` names the property in warnings. Muted keys are omitted: Lottie has no mute state, and +/// the remaining keys carry the animation the renderer shows. pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json { - let mut out = if p.keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, p, conv)}) }; + let muted = p.keys.iter().any(|k| k.muted); + if muted { + ex.warn(format!("{label}: muted keyframes are not exported")); + } + let active: Vec; + let keys: &[Keyframe] = if muted { + active = p.keys.iter().filter(|k| !k.muted).cloned().collect(); + &active + } else { + &p.keys + }; + let mut out = + if keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, keys, is_spatial(p), p.hold_only, conv)}) }; if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) { if ex.include_expressions { out["x"] = json!(e.text); @@ -86,20 +99,18 @@ pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Js out } -fn export_keys(ex: &Ex, p: &Property, conv: &dyn Fn(&Value) -> Json) -> Vec { - let keys = &p.keys; - let spatial = is_spatial(p); +fn export_keys(ex: &Ex, keys: &[Keyframe], spatial: bool, hold_only: bool, conv: &dyn Fn(&Value) -> Json) -> Vec { let mut out = Vec::with_capacity(keys.len()); for (i, k) in keys.iter().enumerate() { let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))}); if i + 1 == keys.len() { - if k.out_interp == Interp::Hold || p.hold_only { + if k.out_interp == Interp::Hold || hold_only { o["h"] = json!(1); } out.push(o); continue; } - if k.out_interp == Interp::Hold || p.hold_only || !k.value.interpolates() { + if k.out_interp == Interp::Hold || hold_only || !k.value.interpolates() { o["h"] = json!(1); out.push(o); continue; diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 31f3a70bb..203f04c04 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -878,3 +878,21 @@ fn external_images_are_local_or_skipped() { assert_eq!(std::path::Path::new(&paths[0]), base.join("images").join("a.png")); assert_eq!(r.warnings.iter().filter(|w| w.contains("is not imported")).count(), assets.len() - 1, "{:?}", r.warnings); } + +#[test] +fn muted_keys_are_omitted_from_the_export() { + let (mut p, cid) = setup(); + let mut l = solid_layer(&mut p, cid, [0.0, 1.0, 0.0], 20, 20); + let mut b = Keyframe::new(s(1.0), Value::Scalar(100.0)); + b.muted = true; + l.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), b, Keyframe::new(s(2.0), Value::Scalar(300.0))]; + push(&mut p, cid, l); + let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert!(warnings.iter().any(|w| w.contains("muted keyframes")), "{warnings:?}"); + // Lottie has no mute state: the two active keys carry the 0 → 300 animation. + assert_eq!(json["layers"][0]["ks"]["o"]["k"].as_array().unwrap().len(), 2); + assert_eq!(json["layers"][0]["ks"]["o"]["k"][1]["s"], json!([300.0])); + let opacity_at = |p: &Project, cid: ItemId, t: f64| p.comp(cid).unwrap().layers[0].props.prop("transform/opacity").unwrap().value_at(s(t)).as_f64(); + assert!((opacity_at(&p, cid, 1.0) - 150.0).abs() < 1e-6); + assert!((opacity_at(&q, nc, 1.0) - 150.0).abs() < 1e-6); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 254eec49c..8712ab74f 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -836,6 +836,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行落下"), ("anim.addKeyframe", "Add Keyframe", "添加关键帧"), ("keys.toggleHold", "Toggle Hold Keyframe", "切换定格关键帧"), + ("keys.setMuted", "Mute Keyframes", "静音关键帧"), ("keys.interpolation", "Keyframe Interpolation...", "关键帧插值..."), ("keys.velocity", "Keyframe Velocity...", "关键帧速度..."), ("", "Keyframe Assistant", "关键帧辅助"), @@ -1472,6 +1473,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行落下"), ("anim.addKeyframe", "Add Keyframe", "新增關鍵影格"), ("keys.toggleHold", "Toggle Hold Keyframe", "切換定格關鍵影格"), + ("keys.setMuted", "Mute Keyframes", "靜音關鍵影格"), ("keys.interpolation", "Keyframe Interpolation...", "關鍵影格插補..."), ("keys.velocity", "Keyframe Velocity...", "關鍵影格速度..."), ("", "Keyframe Assistant", "關鍵影格輔助"), @@ -2105,6 +2107,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行をドロップイン"), ("anim.addKeyframe", "Add Keyframe", "キーフレームを追加"), ("keys.toggleHold", "Toggle Hold Keyframe", "停止したキーフレームの切り替え"), + ("keys.setMuted", "Mute Keyframes", "キーフレームをミュート"), ("keys.interpolation", "Keyframe Interpolation...", "キーフレーム補間法..."), ("keys.velocity", "Keyframe Velocity...", "キーフレーム速度..."), ("", "Keyframe Assistant", "キーフレーム補助"), @@ -2741,6 +2744,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Поява рядків згори"), ("anim.addKeyframe", "Add Keyframe", "Додати ключовий кадр"), ("keys.toggleHold", "Toggle Hold Keyframe", "Перемкнути утримання ключового кадру"), + ("keys.setMuted", "Mute Keyframes", "Заглушити ключові кадри"), ("keys.interpolation", "Keyframe Interpolation...", "Інтерполяція ключових кадрів..."), ("keys.velocity", "Keyframe Velocity...", "Швидкість ключових кадрів..."), ("", "Keyframe Assistant", "Помічник ключових кадрів"), @@ -3378,6 +3382,7 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Entrada de líneas cayendo"), ("anim.addKeyframe", "Add Keyframe", "Añadir fotograma clave"), ("keys.toggleHold", "Toggle Hold Keyframe", "Conmutar mantener fotograma clave"), + ("keys.setMuted", "Mute Keyframes", "Silenciar fotogramas clave"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolación de fotogramas clave..."), ("keys.velocity", "Keyframe Velocity...", "Velocidad de fotogramas clave..."), ("", "Keyframe Assistant", "Asistente de fotogramas clave"), @@ -4013,6 +4018,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Entrar com queda de linhas"), ("anim.addKeyframe", "Add Keyframe", "Adicionar quadro-chave"), ("keys.toggleHold", "Toggle Hold Keyframe", "Alternar quadro-chave de retenção"), + ("keys.setMuted", "Mute Keyframes", "Silenciar quadros-chave"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolação de quadro-chave..."), ("keys.velocity", "Keyframe Velocity...", "Velocidade do quadro-chave..."), ("", "Keyframe Assistant", "Assistente de quadro-chave"), @@ -4655,6 +4661,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Появление строк сверху"), ("anim.addKeyframe", "Add Keyframe", "Добавить ключевой кадр"), ("keys.toggleHold", "Toggle Hold Keyframe", "Переключить фиксацию ключевого кадра"), + ("keys.setMuted", "Mute Keyframes", "Отключить ключевые кадры"), ("keys.interpolation", "Keyframe Interpolation...", "Интерполяция ключевых кадров..."), ("keys.velocity", "Keyframe Velocity...", "Скорость ключевых кадров..."), ("", "Keyframe Assistant", "Помощник по ключевым кадрам"), @@ -5290,6 +5297,7 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Chute des lignes depuis le haut"), ("anim.addKeyframe", "Add Keyframe", "Ajouter une image clé"), ("keys.toggleHold", "Toggle Hold Keyframe", "Activer/Désactiver l'image clé figée"), + ("keys.setMuted", "Mute Keyframes", "Désactiver les images clés"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolation des images clés..."), ("keys.velocity", "Keyframe Velocity...", "Vitesse des images clés..."), ("", "Keyframe Assistant", "Assistant d'images clés"), @@ -5925,6 +5933,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Zeilen fallen ein"), ("anim.addKeyframe", "Add Keyframe", "Keyframe hinzufügen"), ("keys.toggleHold", "Toggle Hold Keyframe", "Halte-Keyframe umschalten"), + ("keys.setMuted", "Mute Keyframes", "Keyframes stummschalten"), ("keys.interpolation", "Keyframe Interpolation...", "Keyframe-Interpolation..."), ("keys.velocity", "Keyframe Velocity...", "Keyframe-Geschwindigkeit..."), ("", "Keyframe Assistant", "Keyframe-Assistent"), @@ -6561,6 +6570,7 @@ const CZECH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Spustit řádky shora"), ("anim.addKeyframe", "Add Keyframe", "Přidat klíčový snímek"), ("keys.toggleHold", "Toggle Hold Keyframe", "Přepnout podržení klíčového snímku"), + ("keys.setMuted", "Mute Keyframes", "Ztlumit klíčové snímky"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolace klíčových snímků..."), ("keys.velocity", "Keyframe Velocity...", "Rychlost klíčového snímku..."), ("", "Keyframe Assistant", "Pomocník klíčových snímků"), diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 24df22616..51b288cc6 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -48,9 +48,21 @@ struct KeyPt { t: f64, v: f64, sel: bool, + muted: bool, handles: Vec, } +/// A graph key's fill and edge colours: muted keys stay visible and selectable, dimmed. +fn key_colors(t: &Tokens, edge: Color32, k: &KeyPt) -> (Color32, Color32) { + let fill = match (k.muted, k.sel) { + (true, true) => t.keyframe_selected.gamma_multiply(0.5), + (true, false) => Color32::from_gray(0x70), + (false, true) => t.keyframe_selected, + (false, false) => Color32::from_gray(0xe0), + }; + (fill, if k.muted { edge.gamma_multiply(0.4) } else { edge }) +} + struct Curve<'a> { layer: &'a Layer, prop: &'a Property, @@ -153,7 +165,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl let dt = e.influence * dur * if out_side { 1.0 } else { -1.0 }; let (hv, show) = if speed { (e.speed.abs(), true) } else { (k.value.components().get(d).copied().unwrap_or(0.0) + e.speed * dt, !spatial) }; let bezier = if out_side { k.out_interp == Interp::Bezier } else { k.in_interp == Interp::Bezier }; - if ks && show && (bezier || speed) { + if ks && !k.muted && show && (bezier || speed) { handles.push(Handle { out: out_side, t: kt + dt, v: hv, speed: e.speed, dur }); } } @@ -162,7 +174,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl } else { k.value.components().get(d).copied().unwrap_or(0.0) }; - keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, handles }); + keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, muted: k.muted, handles }); } out.push(Curve { layer: l, prop: pr, dim: d, color, pts, keys, editable: !spatial }); } @@ -522,9 +534,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte } } // The key itself. - let col = if k.sel { t.keyframe_selected } else { Color32::from_gray(0xe0) }; + let (col, edge) = key_colors(&t, c.color, k); pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col); - pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside); + pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, edge), StrokeKind::Outside); let kr = Rect::from_center_size(kp, vec2(11.0, 11.0)); let kresp = ui.interact(kr, egui::Id::unique(("gkey", uid, d, k.idx)), Sense::click_and_drag()); app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 98bc4d8d2..81e39ccaf 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4111,6 +4111,7 @@ fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: ("Keyframe Interpolation…", "keys.interpolation", json!({})), ("Keyframe Velocity…", "keys.velocity", json!({})), ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), + (if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})), ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})), ("-", "", json!({})), ("Easy Ease", "keys.easyEase", json!({})), @@ -4203,6 +4204,8 @@ fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe None if ks => t.keyframe_selected, None => t.keyframe, }; + // Muted keys stay visible and editable but no longer drive the value. + let kcol = if k.muted { kcol.gamma_multiply(0.35) } else { kcol }; if k.roving { // Roving keys: a small dot (their time follows their neighbours). gp.circle_filled(c, 2.5, kcol); diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index ef4549db6..05884abce 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -360,31 +360,36 @@ pub(crate) fn motion_path( } let v = k.value.as_vec3(); let s = to_scr(v); - // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's - // ends), to drag it into a curve; Linear keys (no tangents) show none. - let (tin, tout) = if k.spatial_auto { - effectcraft_engine::keyframe::spatial_handles(&pos.keys, i) - } else { - effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i) - }; - for (out, tg) in [(false, tin), (true, tout)] { - let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); - if !has || (tg[0] == 0.0 && tg[1] == 0.0) { - continue; + let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }); + // A muted position key is off the motion path: no tangents, and a dimmed point. + let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col }; + if !k.muted { + // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's + // ends), to drag it into a curve; Linear keys (no tangents) show none. + let (tin, tout) = if k.spatial_auto { + effectcraft_engine::keyframe::spatial_handles(&pos.keys, i) + } else { + effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i) + }; + for (out, tg) in [(false, tin), (true, tout)] { + let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); + if !has || (tg[0] == 0.0 && tg[1] == 0.0) { + continue; + } + let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]); + painter.line_segment([s, h], Stroke::new(1.0, kc)); + painter.circle_filled(h, 3.0, kc); + let side = if out { "out" } else { "in" }; + app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); + hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); } - let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]); - painter.line_segment([s, h], Stroke::new(1.0, col)); - painter.circle_filled(h, 3.0, col); - let side = if out { "out" } else { "in" }; - app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); - hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); } let r = Rect::from_center_size(s, vec2(7.0, 7.0)); - if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) { - painter.rect_filled(r, 0.0, col); + if selected { + painter.rect_filled(r, 0.0, kc); } else { painter.rect_filled(r, 0.0, Color32::from_black_alpha(140)); - painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside); } app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe"); hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c }); From 8ac2dec42abeadcd58c0172e4737dd74c460bdb5 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 21:07:19 +0200 Subject: [PATCH 45/50] Keyframes: offer Unmute Keyframes in the Animation menu The menu only carried Mute, so unmuting meant the timeline's context menu. Add the second row bound to `keys.setMuted {"muted":false}`, its translation in the ten catalogs, and a test that runs both rows through Session::execute with undo and redo. --- crates/engine/src/menus.rs | 1 + crates/engine/src/tests/menu_cmds.rs | 36 ++++++++++++++++++++++++++++ crates/keyframe/src/tests.rs | 3 +-- crates/ui-egui/src/i18n.rs | 12 ++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 3a240fc60..a7418ae58 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -1037,6 +1037,7 @@ Animation Add Keyframe | anim.addKeyframe Toggle Hold Keyframe | keys.toggleHold Mute Keyframes | keys.setMuted + Unmute Keyframes | keys.setMuted {"muted":false} Keyframe Interpolation... | keys.interpolation Keyframe Velocity... | keys.velocity Keyframe Assistant diff --git a/crates/engine/src/tests/menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs index f5db9500c..cfbbb4966 100644 --- a/crates/engine/src/tests/menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -1509,6 +1509,42 @@ fn muting_keys_skips_them_in_the_timeline_value_and_undoes() { assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); } +/// Both Animation-menu rows mute and unmute through `Session::execute`, and undo/redo work. +#[test] +fn mute_and_unmute_keyframes_menu_rows_work_and_undo() { + fn rows(nodes: &[crate::menus::MenuNode], out: &mut Vec<(String, Value)>) { + for n in nodes { + match n { + crate::menus::MenuNode::Item(e) if e.command == "keys.setMuted" => out.push((e.label.clone(), e.params.clone())), + crate::menus::MenuNode::Submenu { children, .. } => rows(children, out), + _ => {} + } + } + } + let mut menu = vec![]; + rows(&crate::menus::parse(crate::menus::TREE, false).unwrap(), &mut menu); + assert_eq!(menu.iter().map(|(l, _)| l.as_str()).collect::>(), ["Mute Keyframes", "Unmute Keyframes"], "{menu:?}"); + + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + for (t, v) in [(0.0, 0.0), (1.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let muted = |s: &Session| layer(s, a).props.prop("transform/opacity").unwrap().keys[1].muted; + s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + let run = |s: &mut Session, params: &Value| { + s.execute("keys.setMuted", if params.is_null() { json!({}) } else { params.clone() }).unwrap(); + }; + run(&mut s, &menu[0].1); + assert!(muted(&s)); + s.undo(); + assert!(!muted(&s)); + s.redo(); + assert!(muted(&s)); + run(&mut s, &menu[1].1); + assert!(!muted(&s)); +} + #[test] fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() { let mut s = comp(); diff --git a/crates/keyframe/src/tests.rs b/crates/keyframe/src/tests.rs index 18e9b37be..312703239 100644 --- a/crates/keyframe/src/tests.rs +++ b/crates/keyframe/src/tests.rs @@ -398,8 +398,7 @@ proptest::proptest! { #[test] fn muted_keys_are_skipped_by_evaluation() { - let mut keys = - vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(100.0)), Keyframe::new(s(2.0), Value::Scalar(300.0))]; + let mut keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(100.0)), Keyframe::new(s(2.0), Value::Scalar(300.0))]; let at = |keys: &[Keyframe], t: f64| evaluate(keys, s(t), false).unwrap().as_f64(); assert!((at(&keys, 0.5) - 50.0).abs() < 1e-9); assert!((at(&keys, 1.5) - 200.0).abs() < 1e-9); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8712ab74f..6da705ef1 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -837,6 +837,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("anim.addKeyframe", "Add Keyframe", "添加关键帧"), ("keys.toggleHold", "Toggle Hold Keyframe", "切换定格关键帧"), ("keys.setMuted", "Mute Keyframes", "静音关键帧"), + ("keys.setMuted", "Unmute Keyframes", "取消静音关键帧"), ("keys.interpolation", "Keyframe Interpolation...", "关键帧插值..."), ("keys.velocity", "Keyframe Velocity...", "关键帧速度..."), ("", "Keyframe Assistant", "关键帧辅助"), @@ -1474,6 +1475,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("anim.addKeyframe", "Add Keyframe", "新增關鍵影格"), ("keys.toggleHold", "Toggle Hold Keyframe", "切換定格關鍵影格"), ("keys.setMuted", "Mute Keyframes", "靜音關鍵影格"), + ("keys.setMuted", "Unmute Keyframes", "取消靜音關鍵影格"), ("keys.interpolation", "Keyframe Interpolation...", "關鍵影格插補..."), ("keys.velocity", "Keyframe Velocity...", "關鍵影格速度..."), ("", "Keyframe Assistant", "關鍵影格輔助"), @@ -2108,6 +2110,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("anim.addKeyframe", "Add Keyframe", "キーフレームを追加"), ("keys.toggleHold", "Toggle Hold Keyframe", "停止したキーフレームの切り替え"), ("keys.setMuted", "Mute Keyframes", "キーフレームをミュート"), + ("keys.setMuted", "Unmute Keyframes", "キーフレームのミュートを解除"), ("keys.interpolation", "Keyframe Interpolation...", "キーフレーム補間法..."), ("keys.velocity", "Keyframe Velocity...", "キーフレーム速度..."), ("", "Keyframe Assistant", "キーフレーム補助"), @@ -2745,6 +2748,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("anim.addKeyframe", "Add Keyframe", "Додати ключовий кадр"), ("keys.toggleHold", "Toggle Hold Keyframe", "Перемкнути утримання ключового кадру"), ("keys.setMuted", "Mute Keyframes", "Заглушити ключові кадри"), + ("keys.setMuted", "Unmute Keyframes", "Зняти заглушення ключових кадрів"), ("keys.interpolation", "Keyframe Interpolation...", "Інтерполяція ключових кадрів..."), ("keys.velocity", "Keyframe Velocity...", "Швидкість ключових кадрів..."), ("", "Keyframe Assistant", "Помічник ключових кадрів"), @@ -3383,6 +3387,7 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("anim.addKeyframe", "Add Keyframe", "Añadir fotograma clave"), ("keys.toggleHold", "Toggle Hold Keyframe", "Conmutar mantener fotograma clave"), ("keys.setMuted", "Mute Keyframes", "Silenciar fotogramas clave"), + ("keys.setMuted", "Unmute Keyframes", "Reactivar fotogramas clave"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolación de fotogramas clave..."), ("keys.velocity", "Keyframe Velocity...", "Velocidad de fotogramas clave..."), ("", "Keyframe Assistant", "Asistente de fotogramas clave"), @@ -4019,6 +4024,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("anim.addKeyframe", "Add Keyframe", "Adicionar quadro-chave"), ("keys.toggleHold", "Toggle Hold Keyframe", "Alternar quadro-chave de retenção"), ("keys.setMuted", "Mute Keyframes", "Silenciar quadros-chave"), + ("keys.setMuted", "Unmute Keyframes", "Reativar quadros-chave"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolação de quadro-chave..."), ("keys.velocity", "Keyframe Velocity...", "Velocidade do quadro-chave..."), ("", "Keyframe Assistant", "Assistente de quadro-chave"), @@ -4662,6 +4668,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("anim.addKeyframe", "Add Keyframe", "Добавить ключевой кадр"), ("keys.toggleHold", "Toggle Hold Keyframe", "Переключить фиксацию ключевого кадра"), ("keys.setMuted", "Mute Keyframes", "Отключить ключевые кадры"), + ("keys.setMuted", "Unmute Keyframes", "Включить ключевые кадры"), ("keys.interpolation", "Keyframe Interpolation...", "Интерполяция ключевых кадров..."), ("keys.velocity", "Keyframe Velocity...", "Скорость ключевых кадров..."), ("", "Keyframe Assistant", "Помощник по ключевым кадрам"), @@ -5298,6 +5305,7 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("anim.addKeyframe", "Add Keyframe", "Ajouter une image clé"), ("keys.toggleHold", "Toggle Hold Keyframe", "Activer/Désactiver l'image clé figée"), ("keys.setMuted", "Mute Keyframes", "Désactiver les images clés"), + ("keys.setMuted", "Unmute Keyframes", "Réactiver les images clés"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolation des images clés..."), ("keys.velocity", "Keyframe Velocity...", "Vitesse des images clés..."), ("", "Keyframe Assistant", "Assistant d'images clés"), @@ -5934,6 +5942,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("anim.addKeyframe", "Add Keyframe", "Keyframe hinzufügen"), ("keys.toggleHold", "Toggle Hold Keyframe", "Halte-Keyframe umschalten"), ("keys.setMuted", "Mute Keyframes", "Keyframes stummschalten"), + ("keys.setMuted", "Unmute Keyframes", "Keyframes wieder aktivieren"), ("keys.interpolation", "Keyframe Interpolation...", "Keyframe-Interpolation..."), ("keys.velocity", "Keyframe Velocity...", "Keyframe-Geschwindigkeit..."), ("", "Keyframe Assistant", "Keyframe-Assistent"), @@ -6571,6 +6580,7 @@ const CZECH: &[(&str, &str, &str)] = &[ ("anim.addKeyframe", "Add Keyframe", "Přidat klíčový snímek"), ("keys.toggleHold", "Toggle Hold Keyframe", "Přepnout podržení klíčového snímku"), ("keys.setMuted", "Mute Keyframes", "Ztlumit klíčové snímky"), + ("keys.setMuted", "Unmute Keyframes", "Obnovit klíčové snímky"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolace klíčových snímků..."), ("keys.velocity", "Keyframe Velocity...", "Rychlost klíčového snímku..."), ("", "Keyframe Assistant", "Pomocník klíčových snímků"), @@ -7202,6 +7212,8 @@ const GEORGIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "სტრიქონების ჩამოვარდნა"), ("anim.addKeyframe", "Add Keyframe", "საკვანძო კადრის დამატება"), ("keys.toggleHold", "Toggle Hold Keyframe", "ფიქსირებული საკვანძო კადრის გადართვა"), + ("keys.setMuted", "Mute Keyframes", "საკვანძო კადრების დადუმება"), + ("keys.setMuted", "Unmute Keyframes", "საკვანძო კადრების დადუმების მოხსნა"), ("keys.interpolation", "Keyframe Interpolation...", "საკვანძო კადრის ინტერპოლაცია..."), ("keys.velocity", "Keyframe Velocity...", "საკვანძო კადრის სიჩქარე..."), ("", "Keyframe Assistant", "საკვანძო კადრების ასისტენტი"), From 4bdb68a8127422e6ac3704c10e443dc346b0d19f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 05:54:16 +0700 Subject: [PATCH 46/50] Import: an MP4 lasts as long as its video edit list shows (#2592) The reader reports a track's media duration (mdhd). For H.264 with B-frames that runs past the last shown frame by the encoder delay the edit list trims, so a 75-frame 25 fps clip imported as 76 frames / 3.04 s. The footage's duration now follows the video track's edit list when it is shorter (never longer; a delayed start keeps the reader's duration), as the frame rate is already corrected from the header. Co-Authored-By: Claude Opus 5.5 --- crates/media/src/probe.rs | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index f695ff0d0..41b50b0f4 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -23,6 +23,7 @@ pub fn probe(path: impl AsRef) -> Result { let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn); turn_footage(&mut f, turn); correct_rate(&mut f, header.as_deref().and_then(video_rate)); + trim_to_edits(&mut f, header.as_deref().and_then(video_shown)); return Ok(f); } let bytes = crate::read_regular_file(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; @@ -107,6 +108,7 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let turn = video_turn(&bytes); let rate = video_rate(&bytes); + let shown = video_shown(&bytes); // A WAV's cue points and XMP markers become the footage's markers. let markers = crate::wav_markers::read(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; @@ -114,9 +116,37 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { f.markers = markers; turn_footage(&mut f, turn); correct_rate(&mut f, rate); + trim_to_edits(&mut f, shown); Ok(f) } +/// A movie lasts as long as its video edit list shows (#2592): the reader reports the media +/// duration (`mdhd`), which for H.264 with B-frames runs past the last shown frame by the +/// encoder delay the edit list trims (75 frames at 25 fps read as 3.04 s). Never lengthens. +fn trim_to_edits(f: &mut Footage, shown: Option) { + if f.kind == FootageKind::Video + && let Some(shown) = shown.filter(|s| *s > Tick::ZERO && *s < f.duration) + { + f.duration = shown; + } +} + +/// How long the video track's edit list shows its media (`elst` segments, movie timescale): +/// `None` without edits, or with an empty edit (a delayed start keeps the reader's duration). +fn video_shown(bytes: &[u8]) -> Option { + let file = filmcraft_isobmff::open(bytes).ok()?; + let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?; + edits_shown(&t.edits, file.timescale) +} + +fn edits_shown(edits: &[filmcraft_isobmff::Edit], timescale: u32) -> Option { + if edits.is_empty() || timescale == 0 || edits.iter().any(|e| e.media_time < 0) { + return None; + } + let units = edits.iter().try_fold(0u64, |acc, e| acc.checked_add(e.segment_duration))?; + Some(Tick::from_units(i64::try_from(units).ok()?, i64::from(timescale))) +} + /// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's /// own estimate misreads it. fn correct_rate(f: &mut Footage, rate: Option) { @@ -322,6 +352,24 @@ fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option mod tests { use super::*; + /// #2592: an encoder-delayed H.264 track's edit list shows 75 frames at 25 fps (3 s at a + /// 1000 timescale), though its media runs a frame longer; empty edits and no edits leave it. + #[test] + fn the_edit_list_trims_a_movie_to_the_frames_it_shows() { + let edit = |segment_duration, media_time| filmcraft_isobmff::Edit { segment_duration, media_time, media_rate: 0x0001_0000 }; + let shown = edits_shown(&[edit(3000, 1024)], 1000); + assert_eq!(shown, Some(Tick::from_units(3, 1))); + let mut f = Footage { kind: FootageKind::Video, duration: Tick::from_units(76, 25), ..Footage::default() }; + trim_to_edits(&mut f, shown); + assert_eq!(f.duration, Tick::from_units(75, 25)); + // Never longer than the reader's duration, and only for video. + trim_to_edits(&mut f, Some(Tick::from_units(4, 1))); + assert_eq!(f.duration, Tick::from_units(75, 25)); + assert_eq!(edits_shown(&[edit(1000, -1), edit(3000, 0)], 1000), None); + assert_eq!(edits_shown(&[], 1000), None); + assert_eq!(edits_shown(&[edit(3000, 0)], 0), None); + } + /// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale. fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec { let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num); From 883171eecebe1f9a435550b15e125501688c6612 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 05:59:40 +0700 Subject: [PATCH 47/50] Scripts: FootageItem.replace() swaps in one file, as in After Effects Since #2107, Replace Footage keeps an item's kind by default, so replace() on a sequence item brought the new file's whole numbered run. In After Effects, replace() swaps in the one file and replaceWithSequence() brings a run; replace() now asks for exactly that. Co-Authored-By: Claude Opus 5.5 --- crates/script/src/prelude.js | 3 ++- crates/script/src/tests.rs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index a33714314..b9108b2a1 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1706,7 +1706,8 @@ Object.defineProperty(FootageItem.prototype, "mainSource", { return this.__mainSource; }, }); -FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f) }); }; +// replace() swaps in one file, even a numbered still (replaceWithSequence brings a run), as in After Effects. +FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: false }); }; FootageItem.prototype.replaceWithSequence = function (f, forceAlphabetical) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: true, alphabetical: !!forceAlphabetical }); }; diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 875885c11..3f8190a73 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1062,6 +1062,11 @@ fn footage_proxies_sequences_and_conform_rate() { let paths: Vec = s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.replace('\\', "/")) } else { None }).collect(); assert!(paths.iter().any(|f| f.contains("take_")), "replaced with the take sequence: {paths:?}"); + // replace() swaps in the one file, even a numbered still, as in After Effects. + ok(&mut s, &format!("app.project.item(1).replace(new File('{}'))", p("shot_0002.png"))); + let kinds: Vec<_> = + s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((f.kind, f.path.replace('\\', "/"))) } else { None }).collect(); + assert!(kinds.iter().any(|(k, f)| *k == effectcraft_engine::project::FootageKind::Still && f.ends_with("shot_0002.png")), "{kinds:?}"); let _ = std::fs::remove_dir_all(&dir); } From 4a4aa28184ae4671fb209151415ab12f944d5a82 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 06:27:58 +0700 Subject: [PATCH 48/50] Render Queue: elapsed and remaining time tick between frames (#2593) The job reports its times when a frame finishes, so with slow frames the Current Render strip held the same Est. Remain long enough to look stalled, then jumped. Between reports both times now count on from the last one, as a clock does (remaining never below zero, a finished job stands still), and the panel repaints twice a second while rendering. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/render_queue.rs | 46 ++++++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 5e39a30c2..c7cce6e01 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -451,6 +451,22 @@ fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter } } +/// Elapsed and estimated remaining seconds at UI time `now`. The job reports them as frames +/// finish; between reports they count on from the last one, as a clock does, so a slow frame +/// doesn't read as a stalled render (#2593). +fn live_times(ctx: &egui::Context, pr: &effectcraft_engine::render_queue::JobState, now: f64) -> (f64, Option) { + let report = (pr.current, pr.done, pr.item_elapsed.to_bits()); + let since = ctx.data_mut(|d| { + let seen = d.get_temp_mut_or_insert_with(egui::Id::unique("rq-progress-seen"), || (report, now)); + if seen.0 != report { + *seen = (report, now); + } + seen.1 + }); + let dt = if pr.finished { 0.0 } else { (now - since).max(0.0) }; + (pr.item_elapsed + dt, pr.remaining.map(|r| (r - dt).max(0.0))) +} + /// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify /// and the progress bar. fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec) { @@ -461,11 +477,15 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id)); let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone()); if let Some(pr) = &progress { - let rem = pr.remaining.map(hms).unwrap_or_else(|| "–".into()); + let (elapsed, remaining) = live_times(ui.ctx(), pr, ui.input(|i| i.time)); + if rendering { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } + let rem = remaining.map(hms).unwrap_or_else(|| "–".into()); p.text( pos2(top.max.x - 12.0, top.min.y + 16.0), Align2::RIGHT_CENTER, - format!("Elapsed: {} Est. Remain: {rem}", hms(pr.item_elapsed)), + format!("Elapsed: {} Est. Remain: {rem}", hms(elapsed)), Tokens::ui(11.5), t.text_dim, ); @@ -903,4 +923,26 @@ mod tests { assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15"); assert_eq!(super::hms(3725.4), "1:02:05"); } + + /// #2593: between progress reports the times count on from the last report, and a new + /// report starts the count again. + #[test] + fn render_times_tick_between_reports() { + let ctx = egui::Context::default(); + let mut pr = effectcraft_engine::render_queue::JobState { + current: Some(1), + done: 2, + total: 10, + item_elapsed: 20.0, + remaining: Some(80.0), + ..Default::default() + }; + assert_eq!(super::live_times(&ctx, &pr, 100.0), (20.0, Some(80.0))); + assert_eq!(super::live_times(&ctx, &pr, 103.0), (23.0, Some(77.0))); + assert_eq!(super::live_times(&ctx, &pr, 300.0).1, Some(0.0), "never below zero"); + (pr.done, pr.item_elapsed, pr.remaining) = (3, 31.0, Some(72.0)); + assert_eq!(super::live_times(&ctx, &pr, 301.0), (31.0, Some(72.0))); + pr.finished = true; + assert_eq!(super::live_times(&ctx, &pr, 400.0), (31.0, Some(72.0)), "a finished job stands still"); + } } From cf912e1f61e63181eae4891d5bd32fad93845ed3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 06:42:54 +0700 Subject: [PATCH 49/50] Automation: the file-parameter audit counts RUNS_SCRIPTS as classified file.runScript and script.run moved from REFUSED to RUNS_SCRIPTS, which check_command refuses too; the audit test now looks there as well. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/confine.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index f7e05ef2e..2b0795c93 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -396,6 +396,7 @@ mod tests { let classified = keys_of(READS, id).is_some() || keys_of(WRITES, id).is_some() || REFUSED.contains(&id) + || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) || IMPLICIT_WRITES.contains(&id) || id.starts_with("renderQueue.") || id == "render.addOutputModule" From e806f0d34e620f8bdb683f67d19fcb3868f7c13e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 06:43:12 +0700 Subject: [PATCH 50/50] i18n: the menu test expects German's own Can't Undo wording now that it has one Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0c55fed6d..895524188 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -7715,7 +7715,7 @@ mod tests { app.session.execute("prefs.set", json!({"key":"general.language", "value":"ru"})).unwrap(); assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Нечего отменять"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"de"})).unwrap(); - assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Can't Undo", "no German undo wording yet: the shown label"); + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Rückgängig nicht möglich"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); assert_eq!(entry(&app, &new_comp, new_comp.label.clone()), "New Composition..."); }