From fb715d9015503d22b1da8ac511857e6fd485d35f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 00:32:56 +1100 Subject: [PATCH 001/112] 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 002/112] 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 003/112] 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 59b144439ef5259f235b854ff49da041d5be5c70 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:31:10 +0000 Subject: [PATCH 004/112] #1436: the Gradient Editor previews live and leaves the window undimmed The Gradient Editor edited a draft and set the property only on OK, in a centred dialog over a dimmed, greyed-out window, so the canvas could not show the gradient while it was edited. In After Effects the dialog sits at the lower right, the window stays at full brightness, and the canvas updates live (Preview checkbox). - Preview (on by default): each edit is applied with `prop.set` and one merged undo step; OK keeps it, Cancel and Escape roll back to a checkpoint of the project and undo history taken on open (the Layer Styles dialog's mechanism, now shared). Preview off sets the property on OK only, as before; switching it off takes back what it applied. - The editor opens at the lower right (`Placement` for modal dialogs), without the dark backdrop, and the window behind is no longer faded (`Dialog::leaves_window_undimmed`); clicks still don't reach it. - Other dialogs are unchanged. Claude-Session: https://claude.ai/code/session_013fTnVxnXTv2MBW6uj2PPyU --- crates/ui-egui/src/lib.rs | 13 ++- crates/ui-egui/src/menus.rs | 7 ++ crates/ui-egui/src/panels/dialogs.rs | 34 ++++++- crates/ui-egui/src/panels/gradient_editor.rs | 88 +++++++++++++++--- .../ui-egui/src/panels/layer_styles_dialog.rs | 26 ++++-- crates/ui-egui/tests/ui_gradient_editor.rs | 93 ++++++++++++++++++- 6 files changed, 235 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..edf4a96c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,6 +101,14 @@ pub enum Dialog { DeleteTemplate, } +impl Dialog { + /// The dialog edits what is behind it live (Preview), so the window stays at full brightness + /// while it is open: clicks still don't reach it, but it is not greyed out (After Effects). + pub fn leaves_window_undimmed(self) -> bool { + matches!(self, Dialog::GradientEditor) + } +} + /// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send /// winit no theme). `None` = the host does not know. pub type SystemThemeFn = Box Option>; @@ -1680,8 +1688,11 @@ impl EffectcraftApp { } let t = self.tokens; let full = ui.max_rect(); - if self.dialog.is_some() { + if let Some(d) = self.dialog { ui.disable(); + if d.leaves_window_undimmed() { + ui.set_opacity(1.0); + } } ui.painter().rect_filled(full, 0.0, t.app_bg); let mut top = full.min.y; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8b5fd1c30..b104f0b9a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1785,6 +1785,9 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); if app.dialog == Some(crate::Dialog::LayerStyles) { crate::panels::layer_styles_dialog::finish(app, false); + } else if app.dialog == Some(crate::Dialog::GradientEditor) { + // Escape is Cancel: the preview rolls back. + crate::panels::gradient_editor::finish(app, false, ctx); } else if app.dialog == Some(crate::Dialog::Info) { crate::panels::forms::close_info(app); } else { @@ -1902,6 +1905,10 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only crate::panels::forms::close_info(app); continue; } + if app.dialog == Some(crate::Dialog::GradientEditor) { + crate::panels::gradient_editor::finish(app, false, ctx); + continue; + } // Escape in Settings is Cancel: restore the settings from when it opened. if app.dialog == Some(crate::Dialog::Settings) && let Some(p) = app.dialog_state.prefs_snapshot.take() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..f0e44368e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -176,6 +176,31 @@ pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, modal_with_scroll(ctx, title, size, t, true, body); } +/// Where a modal dialog opens and whether it dims the window behind it. The default is the middle +/// of the window over a dimmed backdrop. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Placement { + /// The opening position as a fraction of the room the dialog can move in, from the middle + /// `(0, 0)` to the bottom right `(1, 1)`. + pub start: egui::Vec2, + /// Dim the window behind the dialog. + pub dim: bool, +} + +impl Default for Placement { + fn default() -> Self { + Placement { start: vec2(0.0, 0.0), dim: true } + } +} + +/// [`modal`] at `place`: a dialog that edits what is behind it keeps the canvas in view. +pub(crate) fn modal_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui)) { + let max_height = (modal_space(ctx).y - 84.0).max(40.0); + modal_fit_placed(ctx, title, size, t, place, |ui, _| { + egui::ScrollArea::both().max_height(max_height).auto_shrink([false, true]).show(ui, body); + }); +} + fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, vertical_only: bool, body: impl FnOnce(&mut egui::Ui)) { let max_height = (modal_space(ctx).y - 84.0).max(40.0); modal_fit(ctx, title, size, t, |ui, _| { @@ -188,16 +213,21 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok /// only its content: `body` gets the height left under the title, with `size` kept within the /// window. pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) { + modal_fit_placed(ctx, title, size, t, Placement::default(), body); +} + +/// [`modal_fit`] at `place`. +pub(crate) fn modal_fit_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui, f32)) { let size = size.min(modal_space(ctx)); let id = egui::Id::new(("modal", title)); // Dragging the title row moves the dialog off what it edits. The offset is kept within the // window, so dragging back never has to undo a part the dialog could not follow. let offset_id = id.with("offset"); let room = ((ctx.content_rect().size() - size) * 0.5).max(vec2(0.0, 0.0)); - let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or_default().clamp(-room, room); + let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or(room * place.start).clamp(-room, room); egui::Modal::new(id) .area(egui::Modal::default_area(id).anchor(Align2::CENTER_CENTER, offset)) - .backdrop_color(Color32::from_black_alpha(120)) + .backdrop_color(Color32::from_black_alpha(if place.dim { 120 } else { 0 })) .frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18))) .show(ctx, |ui| { ui.set_width(size.x - 36.0); diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index bb6923025..bd20c28cd 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -3,14 +3,18 @@ //! Opacity stops sit above the preview bar and colour stops below it. Click above or below the //! bar to add a stop (it takes that track's value there), drag a stop to move it, edit the //! selected stop's location, opacity or colour, and Delete removes it (never the last stop of a -//! track). The editor works on a draft: OK sets the property once with `prop.set` at the time it -//! opened (one undo step, nothing at all when nothing changed); Cancel and Escape leave it as it -//! was. +//! track). The editor works on a draft. With **Preview** on (the default, as in After Effects) each +//! edit is applied to the property at once, with `prop.set` at the time the editor opened and one +//! merged undo step, so the canvas shows the gradient as it is edited; OK keeps it, Cancel and +//! Escape roll it back to the checkpoint taken when the editor opened. With Preview off, OK sets +//! the property once (one undo step, nothing at all when nothing changed). The dialog opens at +//! the lower right of the window and does not dim it, so the canvas stays in view. //! //! Automation ids: `dialog.gradient.bar`, `dialog.gradient.addOpacity`, `dialog.gradient.addColor`, //! `dialog.gradient.opacityStop.`, `dialog.gradient.colorStop.` (in order along the bar), //! `dialog.gradient.location`, `dialog.gradient.opacity`, `dialog.gradient.color`, -//! `dialog.gradient.delete`, `dialog.gradient.ok`, `dialog.gradient.cancel`. +//! `dialog.gradient.delete`, `dialog.gradient.preview`, `dialog.gradient.ok`, +//! `dialog.gradient.cancel`. use effectcraft_engine::keyframe::{Gradient, Value}; use effectcraft_engine::project::{ItemId, LayerId}; @@ -18,6 +22,7 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value as Json, json}; use crate::i18n::tr; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint, restore}; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; @@ -58,6 +63,12 @@ pub struct Draft { pub baseline: Gradient, /// Stop counts (colour, opacity) of the keyframes just before and after the edit time. pub neighbors: Vec<(usize, usize)>, + /// Preview: edits are applied to the property as they are made. + pub preview: bool, + /// The gradient the property was last given by the preview, if it has been. + pub applied: Option, + /// The project and undo history as the editor opened, for Cancel. + checkpoint: Option, next_id: u64, } @@ -220,7 +231,10 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { let before = prop.keys.iter().rev().find(|k| k.time < lt).and_then(|k| counts(&k.value)); let after = prop.keys.iter().find(|k| k.time > lt).and_then(|k| counts(&k.value)); let target = Target { comp: cid.0, layer: lid, prop: uid, time: ct.seconds() }; - app.dialog_state.gradient = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + let mut draft = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + draft.preview = true; + draft.checkpoint = Some(checkpoint(app)); + app.dialog_state.gradient = draft; app.dialog = Some(Dialog::GradientEditor); Ok(json!({"dialog": "gradientEditor"})) } @@ -230,6 +244,11 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { app.dialog = None; let d = std::mem::take(&mut app.dialog_state.gradient); + // The preview already set the property, in one merged undo step: OK keeps it. + if d.applied.is_some() { + app.session.history.merge_key = None; + return; + } let g = d.gradient(); let Some(tg) = d.target else { return }; if g == d.baseline { @@ -241,6 +260,49 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { } } +/// Close the editor without keeping the preview: the project and undo history go back to how they +/// were when it opened (nothing happens when the preview never changed the property). +pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) { + if ok { + commit(app, ctx); + return; + } + let d = std::mem::take(&mut app.dialog_state.gradient); + if d.applied.is_some() + && let Some(cp) = d.checkpoint + { + restore(app, cp); + } + app.session.history.merge_key = None; + app.dialog = None; +} + +/// Keep the property in step with the draft while Preview is on: apply a changed draft with one +/// merged undo step, and roll back to the checkpoint when Preview goes off or the draft is back to +/// how it opened. +fn sync_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { + let d = &app.dialog_state.gradient; + let Some(tg) = d.target.clone() else { return }; + let g = d.gradient(); + let want = d.preview && g != d.baseline; + if want && d.applied.as_ref() != Some(&g) { + let params = json!({ + "comp": tg.comp, "layer": tg.layer, "prop": tg.prop, "time": tg.time, "timeBase": "comp", + "value": Value::Gradient(g.clone()).to_json(), "merge": format!("gradient-editor-{}", tg.prop), + }); + match crate::menus::invoke(app, ctx, "prop.set", params) { + Ok(_) => app.dialog_state.gradient.applied = Some(g), + Err(e) => app.ui.status = e, + } + } else if !want + && d.applied.is_some() + && let Some(cp) = d.checkpoint.clone() + { + restore(app, cp); + app.dialog_state.gradient.applied = None; + } +} + fn to_color32(c: [f32; 4]) -> Color32 { let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3])) @@ -252,7 +314,9 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { return; } let mut answer: Option = None; - super::dialogs::modal(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, |ui| { + // At the lower right, undimmed: the canvas it edits stays in view (After Effects). + let place = super::dialogs::Placement { start: vec2(0.75, 0.8), dim: false }; + super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, place, |ui| { let w = ui.available_width().max(200.0); let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0)); @@ -410,6 +474,10 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if r.clicked() { answer = Some(true); } + let mut preview = app.dialog_state.gradient.preview; + let r = ui.checkbox(&mut preview, tr("Preview")); + app.auto.add("dialog.gradient.preview", r.rect, "Preview"); + app.dialog_state.gradient.preview = preview; }); }); // Delete / Backspace remove the selected stop, but not while typing in a field. @@ -421,12 +489,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { app.dialog_state.gradient.remove(track, id); } match answer { - Some(true) => commit(app, ctx), - Some(false) => { - app.dialog = None; - app.dialog_state.gradient = Draft::default(); - } - None => {} + Some(ok) => finish(app, ok, ctx), + None => sync_preview(app, ctx), } } diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index c0b03f77e..440784b7e 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -23,8 +23,9 @@ pub struct LayerStyleState { checkpoint: Option, } +/// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back. #[derive(Clone)] -struct Checkpoint { +pub(crate) struct Checkpoint { project: std::sync::Arc, history: effectcraft_engine::history::History, } @@ -35,7 +36,7 @@ impl std::fmt::Debug for Checkpoint { } } -fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { +pub(crate) fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { app.session.history.merge_key = None; Checkpoint { project: app.session.project.clone(), history: app.session.history.clone() } } @@ -257,14 +258,21 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool) { if let Some(checkpoint) = app.dialog_state.layer_style.checkpoint.take() && !ok { - let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); - app.session.project = checkpoint.project; - app.session.history = checkpoint.history; - app.session.sanitize_state(); - if changed { - app.session.bump(); - } + restore(app, checkpoint); } app.session.history.merge_key = None; app.dialog = None; } + +/// Put the project and undo history back as `checkpoint` has them (nothing happens when nothing +/// changed since). +pub(crate) fn restore(app: &mut EffectcraftApp, checkpoint: Checkpoint) { + let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); + app.session.project = checkpoint.project; + app.session.history = checkpoint.history; + app.session.sanitize_state(); + if changed { + app.session.bump(); + } + app.session.history.merge_key = None; +} diff --git a/crates/ui-egui/tests/ui_gradient_editor.rs b/crates/ui-egui/tests/ui_gradient_editor.rs index fa8c7b73f..7d8f46281 100644 --- a/crates/ui-egui/tests/ui_gradient_editor.rs +++ b/crates/ui-egui/tests/ui_gradient_editor.rs @@ -197,7 +197,8 @@ fn stop_edits_ok_sets_the_gradient_in_one_undo_step() { let from = center(&h, "dialog.gradient.opacityStop.1"); let to = along(&h, "dialog.gradient.opacityStop.1", 0.5); drag(&mut h, from, to); - assert_eq!(undo_len(&h), undo, "nothing is set while editing"); + assert_eq!(undo_len(&h), undo + 1, "the live preview is one merged undo step while editing"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the property shows the edit before OK"); click(&mut h, "dialog.gradient.ok"); assert_eq!(h.state().dialog, None); let g = gradient(&colors_prop(&h, l, colors).value); @@ -403,11 +404,13 @@ fn location_moves_past_a_neighbour_and_the_fields_follow_the_stop() { assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "{shown:?}"); } -/// A layer deleted while the editor is open: OK reports it, without a crash. +/// A layer deleted while the editor is open, Preview off: OK reports the failed set, without a +/// crash. #[test] fn a_layer_deleted_while_editing_is_reported() { let (mut h, l, colors) = setup(&[]); open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.preview"); let p = along(&h, "dialog.gradient.addColor", 0.5); click_at(&mut h, p); h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); @@ -417,6 +420,83 @@ fn a_layer_deleted_while_editing_is_reported() { assert!(!h.state().ui.status.is_empty(), "the failed set is reported"); } +/// The same with Preview on: the preview's set fails at the next edit and says so, and OK closes +/// the editor without a crash. +#[test] +fn a_layer_deleted_during_the_preview_is_reported_at_the_next_edit() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); + h.run_steps(2); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert!(!h.state().ui.status.is_empty(), "the failed preview set is reported"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); +} + +/// Preview (on by default, as in After Effects): edits reach the property as they are made, in one +/// undo step, and Cancel and Escape roll them back to how the editor opened. +#[test] +fn the_preview_applies_edits_live_and_cancel_rolls_them_back() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + assert!(h.state().gradient_draft().preview, "Preview starts on"); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the new stop shows at once"); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4); + assert_eq!(undo_len(&h), undo + 1, "both edits are one undo step"); + click(&mut h, "dialog.gradient.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before, "Cancel puts the gradient back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3); + key(&mut h, Key::Escape); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before); + assert_eq!(undo_len(&h), undo); +} + +/// With Preview off nothing is set until OK, and switching it off takes back what it applied. +#[test] +fn with_preview_off_nothing_is_set_until_ok() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "previewed"); + click(&mut h, "dialog.gradient.preview"); + assert!(!h.state().gradient_draft().preview); + assert_eq!(colors_prop(&h, l, colors).value, before, "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(colors_prop(&h, l, colors).value, before, "nothing is set while editing"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// The editor opens at the lower right of the window, so the canvas it edits stays in view. +#[test] +fn the_editor_opens_at_the_lower_right() { + let (mut h, _, colors) = setup(&[]); + open_editor(&mut h, colors); + let ok = center(&h, "dialog.gradient.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "the editor's OK button is in the lower right of the 1600 × 1000 window: {ok:?}"); +} + /// `prop.editGradient` by id (agents, MCP, the control channel) is an error for a target that /// isn't a gradient property, from the engine and through the UI, and opens nothing. #[test] @@ -491,3 +571,12 @@ fn the_escape_after_the_picker_closes_cancels_the_editor() { assert_eq!(h.state().dialog, None, "the next pass's Escape cancels the editor"); assert_eq!(h.state().session.project, project, "and changes nothing"); } + +/// The editor shows its edits on the canvas live, so the window behind it is not greyed out while +/// it is open; other dialogs still are. +#[test] +fn only_dialogs_that_preview_leave_the_window_undimmed() { + assert!(Dialog::GradientEditor.leaves_window_undimmed()); + assert!(!Dialog::SolidSettings.leaves_window_undimmed()); + assert!(!Dialog::KeyVelocity.leaves_window_undimmed()); +} From 25a752baa020faecdd4617ed955d5e5672b03031 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:04:30 +0000 Subject: [PATCH 005/112] #1436: Solid, Camera, Light and Composition Settings preview live The Gradient Editor's Preview now covers the other settings dialogs that edit something that exists: Solid Settings on a solid layer, Camera Settings, Light Settings and Composition Settings. A change of the draft reaches the project at once, so the canvas shows it while it is made. - One shared mechanism (`panels::live_preview`): each step goes back to a checkpoint of the project and undo history taken when the dialog opened, then runs the dialog's command once. So the project is always "as it opened, plus one command": OK leaves one undo step, Cancel and Escape restore the checkpoint, Preview off applies nothing until OK, and switching it off takes back what was applied. - A previewing dialog opens at the lower right, without the dark backdrop, and the window behind it is no longer greyed out; it can still be dragged by its title. The same dialogs creating something new (New Solid, New Camera, New Light, New Composition) are unchanged and have no Preview. - `Placement` is public, and `EffectcraftApp::dialog_state` gives tests and automation the drafts. Claude-Session: https://claude.ai/code/session_013fTnVxnXTv2MBW6uj2PPyU --- crates/ui-egui/src/lib.rs | 26 +- crates/ui-egui/src/menus.rs | 3 + crates/ui-egui/src/panels/comp_settings.rs | 4 + crates/ui-egui/src/panels/dialogs.rs | 55 +++- crates/ui-egui/src/panels/dialogs_3d.rs | 159 +++++++---- crates/ui-egui/src/panels/gradient_editor.rs | 3 +- crates/ui-egui/src/panels/live_preview.rs | 117 ++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/tests/ui_dialog_preview.rs | 268 +++++++++++++++++++ crates/ui-egui/tests/ui_gradient_editor.rs | 9 - crates/ui-egui/tests/ui_project_rows.rs | 5 +- 11 files changed, 568 insertions(+), 82 deletions(-) create mode 100644 crates/ui-egui/src/panels/live_preview.rs create mode 100644 crates/ui-egui/tests/ui_dialog_preview.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index edf4a96c3..592be9255 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,14 +101,6 @@ pub enum Dialog { DeleteTemplate, } -impl Dialog { - /// The dialog edits what is behind it live (Preview), so the window stays at full brightness - /// while it is open: clicks still don't reach it, but it is not greyed out (After Effects). - pub fn leaves_window_undimmed(self) -> bool { - matches!(self, Dialog::GradientEditor) - } -} - /// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send /// winit no theme). `None` = the host does not know. pub type SystemThemeFn = Box Option>; @@ -578,6 +570,18 @@ impl EffectcraftApp { &mut self.dialog_state.gradient } + /// The open settings dialog's draft and Preview state (`panels::dialogs`): Solid, Camera, + /// Light and Composition Settings keep what OK would apply here. + pub fn dialog_state(&self) -> &panels::dialogs::DialogState { + &self.dialog_state + } + + /// The open settings dialog's draft, to edit (tests and automation; the dialog's own fields + /// do the same). + pub fn dialog_state_mut(&mut self) -> &mut panels::dialogs::DialogState { + &mut self.dialog_state + } + /// Feed this frame's share of the queued synthetic input (control channel `ui.click`, /// `ui.drag`…) into `raw_input`. Pointer events go one per frame so egui sees press → moves /// → release as a real gesture. @@ -1688,9 +1692,11 @@ impl EffectcraftApp { } let t = self.tokens; let full = ui.max_rect(); - if let Some(d) = self.dialog { + if self.dialog.is_some() { ui.disable(); - if d.leaves_window_undimmed() { + // A dialog that edits what is behind it live (Preview) leaves the window at full + // brightness: clicks still don't reach it, but the canvas is not greyed out. + if self.dialog == Some(Dialog::GradientEditor) || panels::live_preview::active(self) { ui.set_opacity(1.0); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b104f0b9a..5d2479bb2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1800,6 +1800,8 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only if app.dialog == Some(crate::Dialog::UnsavedChanges) { app.dialog_state.unsaved.command = None; } + // Escape is Cancel: a Preview that edited the project rolls back. + crate::panels::live_preview::cancel(app); app.dialog = None; } return; @@ -1916,6 +1918,7 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only app.session.prefs = p; app.session.prefs_changed(); } + crate::panels::live_preview::cancel(app); app.dialog = None; continue; } diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 2cd19df5d..005b2bce7 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -248,6 +248,7 @@ pub fn show( auto: &mut Registry, custom: &[CompPreset], cmds: &mut Vec<(&'static str, serde_json::Value)>, + preview: Option<&mut bool>, ) -> Option { let mut out = None; ui.horizontal(|ui| { @@ -470,6 +471,9 @@ pub fn show( if r.clicked() { out = Some(serde_json::Value::Null); } + if let Some(on) = preview { + super::live_preview::checkbox(ui, auto, on, "dialog.comp.preview"); + } }); }); out diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f0e44368e..3e262bd89 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -65,6 +65,8 @@ pub struct DialogState { pub delete_items: super::delete_items::Pending, /// The Gradient Editor's draft and the property it edits. pub gradient: super::gradient_editor::Draft, + /// Preview of the settings dialog being edited (Solid, Camera, Light, Composition Settings). + pub live: super::live_preview::LivePreview, /// The user template the Delete Template prompt asks about: its id and name. pub delete_template: Option<(String, String)>, } @@ -75,6 +77,7 @@ pub fn open_new_comp(app: &mut EffectcraftApp) { app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, preset_name: None, ..last }; app.dialog_state.editing_existing = false; + app.dialog_state.live = Default::default(); app.dialog = Some(Dialog::NewComp); } @@ -107,6 +110,7 @@ pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<( app.dialog_state.editing_existing = true; app.dialog_state.editing_comp = Some(cid); app.dialog = Some(Dialog::CompSettings); + super::live_preview::begin(app, Dialog::CompSettings); Ok(()) } @@ -121,6 +125,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> { if d.solid_color == [0.0; 3] { d.solid_color = [0.2, 0.45, 0.85]; } + d.live = Default::default(); app.dialog = Some(Dialog::SolidSettings); Ok(()) } @@ -136,6 +141,7 @@ fn open_solid_settings(app: &mut EffectcraftApp, layer: u64, item: ItemId) -> Re (d.solid_name, d.solid_color, d.solid_size, d.solid_pixel_aspect) = (name, so.color, [so.width, so.height], so.pixel_aspect); (d.solid_layer, d.solid_shared, d.solid_affect_all) = (Some(layer), users > 1, false); app.dialog = Some(Dialog::SolidSettings); + super::live_preview::begin(app, Dialog::SolidSettings); Ok(()) } @@ -179,7 +185,7 @@ pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, /// Where a modal dialog opens and whether it dims the window behind it. The default is the middle /// of the window over a dimmed backdrop. #[derive(Clone, Copy, Debug)] -pub(crate) struct Placement { +pub struct Placement { /// The opening position as a fraction of the room the dialog can move in, from the middle /// `(0, 0)` to the bottom right `(1, 1)`. pub start: egui::Vec2, @@ -412,9 +418,13 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis let mut result = None; let custom = app.session.prefs.comp_presets.clone(); let mut cmds = Vec::new(); - modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { - result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds); + let live = existing && super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + modal_placed(ctx, "Composition Settings", vec2(600.0, 470.0), t, place, |ui| { + result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.preset_name.is_some() { // Save Preset asks for the name in its own window over the dialog, as After Effects does. result = None; @@ -432,11 +442,19 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog_state.comp = d; match result { - Some(serde_json::Value::Null) => app.dialog = None, + Some(serde_json::Value::Null) => { + super::live_preview::cancel(app); + app.dialog = None; + } Some(mut params) => { if existing && let (Some(cid), Some(m)) = (app.dialog_state.editing_comp, params.as_object_mut()) { m.insert("comp".into(), json!(cid.0)); } + // A preview already made the edit (one undo step): OK keeps it. + if existing && super::live_preview::keep(app) { + app.dialog = None; + return; + } let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) }; if !existing && r.is_ok() { app.dialog_state.last_new_comp = Some(app.dialog_state.comp.clone()); @@ -446,7 +464,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog = None; } - None => {} + None => { + if existing && let Some(cid) = app.dialog_state.editing_comp { + let mut params = super::comp_settings::params(&app.dialog_state.comp); + if let Some(m) = params.as_object_mut() { + m.insert("comp".into(), json!(cid.0)); + } + super::live_preview::sync(app, "comp.settings", params); + } + } } } @@ -494,8 +520,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080)); let lock_id = egui::Id::new("solid-lock-aspect"); let mut lock = ctx.data(|m| m.get_temp::(lock_id)).unwrap_or(true); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; let auto = &mut app.auto; - modal(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, |ui| { + modal_placed(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, place, |ui| { ui.horizontal(|ui| { ui.label(tr("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0)); @@ -581,16 +610,28 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let r = ui.button(tr("Cancel")); auto.add("dialog.solid.cancel", r.rect, "Cancel"); close = r.clicked(); + if live { + super::live_preview::checkbox(ui, auto, &mut preview, "dialog.solid.preview"); + } }); }); ctx.data_mut(|m| m.insert_temp(lock_id, lock)); let ds = &mut app.dialog_state; + ds.live.on = preview; (ds.solid_name, ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all) = (name, color, size, par, affect_all); + let (command, params) = (solid_command(ds), solid_params(ds)); if ok || ui_enter(ctx) { - if let Err(e) = app.session.execute(solid_command(ds), solid_params(ds)) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app) + && let Err(e) = app.session.execute(command, params) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, command, params); } if close { app.dialog = None; diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index a1a67bc60..a8d24e9a3 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -87,8 +87,14 @@ pub fn open_camera(app: &mut EffectcraftApp, layer: Option) -> Result<(), S } } }; + let editing = d.layer.is_some(); app.dialog_state.camera = d; app.dialog = Some(Dialog::CameraSettings); + if editing { + super::live_preview::begin(app, Dialog::CameraSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } @@ -137,13 +143,19 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option) -> Result<(), St } } }; + let editing = d.layer.is_some(); app.dialog_state.light = d; app.dialog = Some(Dialog::LightSettings); + if editing { + super::live_preview::begin(app, Dialog::LightSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } -fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (egui::Rect, egui::Rect) { - let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING); +fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool, preview: Option<&mut bool>) -> (egui::Rect, egui::Rect, egui::Rect) { + let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let b = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); r.0 = b.rect; @@ -155,6 +167,9 @@ fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (e if c.clicked() { *close = true; } + if let Some(on) = preview { + r.2 = ui.checkbox(on, tr("Preview")).rect; + } }); r } @@ -163,8 +178,11 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let w = comp_w(app); let mut d = app.dialog_state.camera.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Camera Settings", vec2(520.0, 420.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Camera Settings", vec2(520.0, 420.0), t, place, |ui| { egui::Grid::new("cam-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Type")); egui::ComboBox::from_id_salt("cam-type").selected_text(if d.two_node { "Two-Node Camera" } else { "One-Node Camera" }).show_ui(ui, |ui| { @@ -217,46 +235,66 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); }); ui.add_space(14.0); - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.lock_to_zoom { d.focus = d.zoom; } app.auto.add("dialog.camera.ok", rects.0, "OK"); app.auto.add("dialog.camera.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.camera.preview", rects.2, "Preview"); + } app.dialog_state.camera = d.clone(); + let (id, p) = camera_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let mut p = json!({ - "name": d.name, - "type": if d.two_node { "twoNode" } else { "oneNode" }, - "zoom": d.zoom, - "dof": d.dof, - "focusDistance": d.focus, - "aperture": d.aperture, - "blurLevel": d.blur, - }); - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.cameraSettings" - } - None => "layer.newCamera", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Camera Settings' OK runs, and its parameters. +fn camera_command(d: &CameraDraft) -> (&'static str, Value) { + let mut p = json!({ + "name": d.name, + "type": if d.two_node { "twoNode" } else { "oneNode" }, + "zoom": d.zoom, + "dof": d.dof, + "focusDistance": d.focus, + "aperture": d.aperture, + "blurLevel": d.blur, + }); + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.cameraSettings" + } + None => "layer.newCamera", + }; + (id, p) +} + pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut d = app.dialog_state.light.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Light Settings", vec2(480.0, 470.0), t, place, |ui| { egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Name")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); @@ -314,48 +352,65 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.add_space(14.0); ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim)); ui.add_space(8.0); - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; app.auto.add("dialog.light.ok", rects.0, "OK"); app.auto.add("dialog.light.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.light.preview", rects.2, "Preview"); + } app.dialog_state.light = d.clone(); + let (id, p) = light_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let kind = KINDS[d.kind.min(3)]; - let mut p = json!({ - "name": d.name, - "kind": kind.label(), - "color": [d.color[0], d.color[1], d.color[2]], - "intensity": d.intensity, - }); - if kind == LightKind::Spot { - p["coneAngle"] = json!(d.cone_angle); - p["coneFeather"] = json!(d.cone_feather); - } - if kind != LightKind::Ambient { - p["falloff"] = json!(d.falloff); - p["radius"] = json!(d.radius); - p["falloffDistance"] = json!(d.falloff_distance); - p["castsShadows"] = json!(d.casts_shadows); - p["shadowDarkness"] = json!(d.shadow_darkness); - p["shadowDiffusion"] = json!(d.shadow_diffusion); - } - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.lightSettings" - } - None => "layer.newLight", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Light Settings' OK runs, and its parameters. +fn light_command(d: &LightDraft) -> (&'static str, Value) { + let kind = KINDS[d.kind.min(3)]; + let mut p = json!({ + "name": d.name, + "kind": kind.label(), + "color": [d.color[0], d.color[1], d.color[2]], + "intensity": d.intensity, + }); + if kind == LightKind::Spot { + p["coneAngle"] = json!(d.cone_angle); + p["coneFeather"] = json!(d.cone_feather); + } + if kind != LightKind::Ambient { + p["falloff"] = json!(d.falloff); + p["radius"] = json!(d.radius); + p["falloffDistance"] = json!(d.falloff_distance); + p["castsShadows"] = json!(d.casts_shadows); + p["shadowDarkness"] = json!(d.shadow_darkness); + p["shadowDiffusion"] = json!(d.shadow_diffusion); + } + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.lightSettings" + } + None => "layer.newLight", + }; + (id, p) +} + /// Menu/shortcut routing: commands that open a 3D settings dialog when invoked without params. /// Returns true when a dialog was opened. pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result { diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index bd20c28cd..fbbfc01ab 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -315,8 +315,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } let mut answer: Option = None; // At the lower right, undimmed: the canvas it edits stays in view (After Effects). - let place = super::dialogs::Placement { start: vec2(0.75, 0.8), dim: false }; - super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, place, |ui| { + super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, super::live_preview::PLACE, |ui| { let w = ui.available_width().max(200.0); let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0)); diff --git a/crates/ui-egui/src/panels/live_preview.rs b/crates/ui-egui/src/panels/live_preview.rs new file mode 100644 index 000000000..861f8eb9f --- /dev/null +++ b/crates/ui-egui/src/panels/live_preview.rs @@ -0,0 +1,117 @@ +//! Preview for the settings dialogs that edit something that already exists (Solid Settings on a +//! solid layer, Camera Settings, Light Settings, Composition Settings): After Effects' Preview +//! checkbox. Each change of the draft is applied to the project at once, so the canvas shows it +//! while it is made; OK keeps it and Cancel and Escape take it back. +//! +//! Every step goes back to the checkpoint taken when the dialog opened (the project and its undo +//! history) and then runs the dialog's command once with the draft's parameters. So the project is +//! always "as it opened, plus one command": OK leaves exactly one undo step, whatever was tried +//! meanwhile, and Cancel only has to put the checkpoint back. With Preview off nothing is applied +//! until OK, and switching it off takes back what was applied. +//! +//! While a preview is active the dialog opens at the lower right of the window, without the dark +//! backdrop, and the window behind it is not greyed out, so the canvas stays in view. + +use egui::vec2; +use serde_json::Value; + +use crate::panels::dialogs::Placement; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint, restore}; +use crate::{Dialog, EffectcraftApp}; + +/// Where a previewing dialog opens: the lower right, undimmed. +pub const PLACE: Placement = Placement { start: vec2(0.75, 0.8), dim: false }; + +/// The state of the one dialog being previewed (only one dialog is open at a time). +#[derive(Clone, Debug, Default)] +pub struct LivePreview { + /// The dialog this belongs to; a preview left over from another one is ignored. + dialog: Option, + /// The Preview checkbox. + pub on: bool, + checkpoint: Option, + /// The draft's parameters when the dialog opened: nothing is applied while it equals them. + baseline: Option, + /// The command and parameters the preview last applied. + applied: Option<(&'static str, Value)>, +} + +impl LivePreview { + fn active_for(&self, dialog: Option) -> bool { + self.checkpoint.is_some() && self.dialog.is_some() && self.dialog == dialog + } +} + +/// Start previewing for `dialog`, which has just opened on an existing item (Preview on). +pub fn begin(app: &mut EffectcraftApp, dialog: Dialog) { + app.dialog_state.live = LivePreview { dialog: Some(dialog), on: true, checkpoint: Some(checkpoint(app)), ..Default::default() }; +} + +/// The dialog that is open is previewing. +pub fn active(app: &EffectcraftApp) -> bool { + app.dialog_state.live.active_for(app.dialog) +} + +/// Where the open dialog goes: the lower right, undimmed, while it previews; else the middle. +pub fn place(app: &EffectcraftApp) -> Placement { + if active(app) { PLACE } else { Placement::default() } +} + +/// Keep the project in step with the draft: `command` with `params` is what OK would run. The +/// first call (the draft as it opened) only notes it. +pub fn sync(app: &mut EffectcraftApp, command: &'static str, params: Value) { + if !active(app) { + return; + } + let live = &app.dialog_state.live; + if live.baseline.is_none() { + app.dialog_state.live.baseline = Some(params); + return; + } + let want = live.on && live.baseline.as_ref() != Some(¶ms); + let is_applied = live.applied.is_some(); + if want { + if live.applied.as_ref().is_some_and(|(c, p)| *c == command && *p == params) { + return; + } + if is_applied { + put_back(app); + } + match app.session.execute(command, params.clone()) { + Ok(_) => app.dialog_state.live.applied = Some((command, params)), + Err(e) => app.ui.status = e.to_string(), + } + } else if is_applied { + put_back(app); + } +} + +/// The project as the dialog found it. +fn put_back(app: &mut EffectcraftApp) { + if let Some(cp) = app.dialog_state.live.checkpoint.clone() { + restore(app, cp); + } + app.dialog_state.live.applied = None; +} + +/// OK: the preview's result is the edit. Returns whether it already is (the caller then must not +/// run the command again); otherwise the caller runs it, as without a preview. +pub fn keep(app: &mut EffectcraftApp) -> bool { + let kept = active(app) && app.dialog_state.live.applied.is_some(); + app.dialog_state.live = LivePreview::default(); + kept +} + +/// Cancel and Escape: take back whatever the preview applied. +pub fn cancel(app: &mut EffectcraftApp) { + if active(app) { + put_back(app); + } + app.dialog_state.live = LivePreview::default(); +} + +/// The Preview checkbox, when the open dialog previews; `id` is its automation id. +pub fn checkbox(ui: &mut egui::Ui, auto: &mut crate::automation::Registry, on: &mut bool, id: &str) { + let r = ui.checkbox(on, crate::i18n::tr("Preview")); + auto.add(id, r.rect, "Preview"); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1b6ef8c33..65cb199d3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -27,6 +27,7 @@ pub mod key_dialogs; pub mod layer_panel; pub mod layer_styles_dialog; pub mod learn; +pub mod live_preview; pub mod markers_ui; pub mod media_panels; pub mod misc; diff --git a/crates/ui-egui/tests/ui_dialog_preview.rs b/crates/ui-egui/tests/ui_dialog_preview.rs new file mode 100644 index 000000000..861548012 --- /dev/null +++ b/crates/ui-egui/tests/ui_dialog_preview.rs @@ -0,0 +1,268 @@ +//! Preview in the settings dialogs that edit something that exists (After Effects' Preview +//! checkbox): Solid Settings on a solid layer, Camera Settings, Light Settings and Composition +//! Settings. A change of the draft reaches the project at once, so the canvas shows it while it is +//! made; OK keeps it in one undo step, Cancel and Escape take it back, and with Preview off nothing +//! is applied until OK. The window behind a previewing dialog is not dimmed, and the dialog opens +//! at the lower right; the same dialogs creating something new keep the middle. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{ItemKind, LayerId}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp, menus}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::{Value, json}; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 10})).unwrap(); + setup(&mut s); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn open(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: Value) { + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(3); +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn escape(h: &mut Harness<'_, EffectcraftApp>) { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn undo_len(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.history.undo.len() +} + +fn solid_size(h: &Harness<'_, EffectcraftApp>, layer: u64) -> [u32; 2] { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + let effectcraft_engine::project::LayerSource::Solid { item } = l.source else { panic!("not a solid") }; + match &s.project.item(item).unwrap().kind { + ItemKind::Solid(so) => [so.width, so.height], + k => panic!("{k:?}"), + } +} + +fn scalar(h: &Harness<'_, EffectcraftApp>, layer: u64, path: &str) -> f64 { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + l.props.prop(path).unwrap().value_at(l.layer_time(s.time())).as_f64() +} + +fn comp_width(h: &Harness<'_, EffectcraftApp>) -> u32 { + h.state().session.active_comp().unwrap().width +} + +fn solid_setup(s: &mut Session) { + s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap(); +} + +fn solid_layer(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.active_comp().unwrap().layers[0].id.0 +} + +#[test] +fn solid_settings_previews_live_and_cancel_and_escape_take_it_back() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_some(), "a Preview checkbox"); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150], "the canvas shows the new size before OK"); + h.state_mut().dialog_state_mut().solid_size = [240, 120]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [240, 120]); + assert_eq!(undo_len(&h), undo + 1, "however much was tried, the project is the original plus one command"); + click(&mut h, "dialog.solid.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100], "Cancel puts it back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100]); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn solid_settings_ok_keeps_the_preview_as_one_undo_step() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + h.state_mut().dialog_state_mut().solid_size = [220, 130]; + h.run_steps(3); + click(&mut h, "dialog.solid.ok"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [220, 130]); + assert_eq!(undo_len(&h), undo + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(solid_size(&h, l), [100, 100], "undone in one go"); +} + +#[test] +fn with_preview_off_nothing_is_applied_until_ok() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + click(&mut h, "dialog.solid.preview"); + assert!(!h.state().dialog_state().live.on); + assert_eq!(solid_size(&h, l), [100, 100], "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + h.state_mut().dialog_state_mut().solid_size = [260, 140]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [100, 100], "nothing is applied while editing"); + click(&mut h, "dialog.solid.ok"); + assert_eq!(solid_size(&h, l), [260, 140], "OK applies the draft"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn camera_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newCamera", json!({})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "cameraOptions/zoom"); + let undo = undo_len(&h); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::CameraSettings)); + assert!(h.state().auto.find("dialog.camera.preview").is_some()); + h.state_mut().dialog_state_mut().camera.zoom = before + 500.0; + h.run_steps(3); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 500.0)).abs() < 1e-6, "the zoom changes live"); + assert_eq!(undo_len(&h), undo + 1); + click(&mut h, "dialog.camera.cancel"); + assert!((scalar(&h, l, "cameraOptions/zoom") - before).abs() < 1e-6, "Cancel puts the zoom back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + h.state_mut().dialog_state_mut().camera.zoom = before + 300.0; + h.run_steps(3); + click(&mut h, "dialog.camera.ok"); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 300.0)).abs() < 1e-6, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn light_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newLight", json!({"kind": "Point"})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "lightOptions/intensity"); + let undo = undo_len(&h); + open(&mut h, "layer.lightSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::LightSettings)); + assert!(h.state().auto.find("dialog.light.preview").is_some()); + h.state_mut().dialog_state_mut().light.intensity = before + 37.0; + h.run_steps(3); + assert!((scalar(&h, l, "lightOptions/intensity") - (before + 37.0)).abs() < 1e-6, "the intensity changes live"); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert!((scalar(&h, l, "lightOptions/intensity") - before).abs() < 1e-6, "Escape puts it back"); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn composition_settings_preview() { + let mut h = harness(|_| {}); + let before = comp_width(&h); + let undo = undo_len(&h); + open(&mut h, "comp.settings", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); + assert!(h.state().auto.find("dialog.comp.preview").is_some()); + h.state_mut().dialog_state_mut().comp.width = before + 80; + h.run_steps(3); + assert_eq!(comp_width(&h), before + 80, "the comp is resized live"); + click(&mut h, "dialog.comp.cancel"); + assert_eq!(comp_width(&h), before, "Cancel puts the size back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "comp.settings", json!({})); + h.state_mut().dialog_state_mut().comp.width = before + 40; + h.run_steps(3); + click(&mut h, "dialog.comp.ok"); + assert_eq!(comp_width(&h), before + 40, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// A previewing dialog opens at the lower right so the canvas stays in view; the same dialogs +/// creating something new keep the middle and have no Preview. +#[test] +fn previewing_dialogs_open_at_the_lower_right_and_new_ones_in_the_middle() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "Solid Settings on a layer is in the lower right: {ok:?}"); + click(&mut h, "dialog.solid.cancel"); + open(&mut h, "layer.newSolid", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_none(), "a new solid has no Preview"); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x < 1000.0, "New Solid stays in the middle: {ok:?}"); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + 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..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// A previewing dialog opens at the lower right, but it can be dragged by its title to wherever +/// the canvas needs to be seen, and the preview keeps working from there. +#[test] +fn a_previewing_dialog_can_be_dragged_by_its_title() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + let title = h.get_by_label("Solid Settings").rect().center(); + drag(&mut h, title, title + egui::vec2(-500.0, -300.0)); + let moved = center(&h, "dialog.solid.ok"); + assert!(moved.x < ok.x - 300.0 && moved.y < ok.y - 150.0, "the dialog followed the drag: {ok:?} -> {moved:?}"); + // Still previewing, and the position is kept from frame to frame. + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + assert_eq!(center(&h, "dialog.solid.ok"), moved, "it stays where it was put"); +} diff --git a/crates/ui-egui/tests/ui_gradient_editor.rs b/crates/ui-egui/tests/ui_gradient_editor.rs index 7d8f46281..3b5da558d 100644 --- a/crates/ui-egui/tests/ui_gradient_editor.rs +++ b/crates/ui-egui/tests/ui_gradient_editor.rs @@ -571,12 +571,3 @@ fn the_escape_after_the_picker_closes_cancels_the_editor() { assert_eq!(h.state().dialog, None, "the next pass's Escape cancels the editor"); assert_eq!(h.state().session.project, project, "and changes nothing"); } - -/// The editor shows its edits on the canvas live, so the window behind it is not greyed out while -/// it is open; other dialogs still are. -#[test] -fn only_dialogs_that_preview_leave_the_window_undimmed() { - assert!(Dialog::GradientEditor.leaves_window_undimmed()); - assert!(!Dialog::SolidSettings.leaves_window_undimmed()); - assert!(!Dialog::KeyVelocity.leaves_window_undimmed()); -} diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 27ce5e693..b8638bbd1 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -274,12 +274,13 @@ fn comp_context_menu_opens_that_comps_settings() { assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); assert_eq!(h.state().session.active_comp_id(), Some(main), "the viewer keeps its comp"); // A new name, then OK: Side's own size goes back to Side under it. Main's values in the - // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. + // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. The + // dialog previews the name as it is typed; OK keeps that as one undo step. + let undo = h.state().session.history.undo.len(); click(&mut h, "dialog.comp.name"); h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); h.input_mut().events.push(Event::Text("Side 2".into())); h.run_steps(2); - let undo = h.state().session.history.undo.len(); h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(2); assert_eq!(h.state().dialog, None, "OK closed the dialog"); From 93b06a6eab9c1636524be8173d95b074f02b20b8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 15 Oct 2026 22:56:38 +0200 Subject: [PATCH 006/112] Bump FilmCraft to 4e2d0bc2 (part of #804) Problem: the hardware-decoding work (#819) needs a newer FilmCraft than the pinned 2c5ba7a, and the pin bump should be reviewable apart from it. Fix: move all filmcraft-* deps to 4e2d0bc2. Adapt to MediaInfo.audio -> audio_streams and VideoStreamInfo.hdr; ask the pool for a quarter into the frame because FilmCraft now rounds requests half up; adapt rotation and the AAC downmix; use Decoder::with_threads(1) in the VP9 tests. No filmcraft-platform, no hardware setting. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 115 ++++++++++++++------------ Cargo.toml | 32 +++---- crates/export/tests/vp9_roundtrip.rs | 6 +- crates/host/tests/timeline_interop.rs | 3 +- crates/interchange/src/export.rs | 6 +- crates/interchange/src/import.rs | 4 +- crates/interchange/src/tests.rs | 6 +- crates/media/src/downmix.rs | 39 ++++++++- crates/media/src/pool.rs | 11 ++- crates/media/src/probe.rs | 15 +++- 10 files changed, 151 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce8fe1231..98e00791d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2025,8 +2025,8 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "filmcraft-aac" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2034,16 +2034,26 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-apv" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +dependencies = [ + "filmcraft-bitstream", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-av1" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2052,21 +2062,22 @@ dependencies = [ [[package]] name = "filmcraft-bitstream" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-cfb" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-codecs" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-aac", "filmcraft-ac3", + "filmcraft-apv", "filmcraft-av1", "filmcraft-bitstream", "filmcraft-color", @@ -2094,16 +2105,16 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "serde", ] [[package]] name = "filmcraft-dnx" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2112,8 +2123,8 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -2123,16 +2134,16 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "serde", ] [[package]] name = "filmcraft-h264" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2141,8 +2152,8 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2151,8 +2162,8 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2161,8 +2172,8 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2178,8 +2189,8 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2187,13 +2198,13 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-media" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -2206,8 +2217,8 @@ dependencies = [ [[package]] name = "filmcraft-mpeg2v" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "rayon", "thiserror 2.0.21", @@ -2215,31 +2226,31 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-mxf" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-ogg" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-opus" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-project" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2252,8 +2263,8 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2262,16 +2273,16 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "serde", ] [[package]] name = "filmcraft-vp9" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", diff --git a/Cargo.toml b/Cargo.toml index 9f4e9d17f..cb8b7ea38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,26 +71,26 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). # Only `crates/media`, `crates/export` and `crates/interchange` may depend on these. -filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # encoders + muxer (crates/export) -filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency) -filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only) -filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only) -filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # gpu (the same wgpu eframe / egui-wgpu use) wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] } diff --git a/crates/export/tests/vp9_roundtrip.rs b/crates/export/tests/vp9_roundtrip.rs index e7c2b0346..1432e3892 100644 --- a/crates/export/tests/vp9_roundtrip.rs +++ b/crates/export/tests/vp9_roundtrip.rs @@ -41,7 +41,7 @@ fn source(w: usize, h: usize, kind: u32) -> (Vec, Vec, Vec) { } fn decode(frame: &[u8]) -> filmcraft_vp9::Picture { - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); let mut pics = d.decode(frame, 0).expect("decode"); assert_eq!(pics.len(), 1); pics.remove(0) @@ -189,7 +189,7 @@ fn planes_of(p: &filmcraft_vp9::Picture, w: usize, h: usize) -> [Vec; 3] { fn run_sequence(cfg: EncoderConfig, n: usize, kind: u32) -> (usize, f64) { let (w, h) = (cfg.width as usize, cfg.height as usize); let mut e = Vp9Encoder::new(cfg); - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); let (mut bytes, mut db) = (0usize, 0.0); for t in 0..n { let (y, u, v) = moving(w, h, t, kind); @@ -344,7 +344,7 @@ fn frames_never_end_in_a_superframe_marker() { } let Some((w, h, q, frames, recon)) = padded else { panic!("no frame needed the padding: widen the search") }; // The padded frames decode exactly, with FilmCraft's decoder and with ffmpeg. - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); for (t, (f, r)) in frames.iter().zip(&recon).enumerate() { let mut pics = d.decode(f, t as i64).unwrap_or_else(|e| panic!("{w}x{h} q{q} frame {t}: {e:?}")); assert_eq!(planes_of(&pics.remove(0), w, h), *r, "{w}x{h} q{q} frame {t}"); diff --git a/crates/host/tests/timeline_interop.rs b/crates/host/tests/timeline_interop.rs index 5123dc721..9688a409f 100644 --- a/crates/host/tests/timeline_interop.rs +++ b/crates/host/tests/timeline_interop.rs @@ -56,8 +56,9 @@ fn still(p: &mut fp::Project, path: &str) -> fp::ItemId { color: Default::default(), has_alpha: false, bitrate: None, + hdr: None, }), - audio: None, + audio_streams: Vec::new(), container: String::new(), start_timecode: None, file_size: None, diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs index 7da18913a..da6ef1382 100644 --- a/crates/interchange/src/export.rs +++ b/crates/interchange/src/export.rs @@ -684,8 +684,12 @@ fn media_info(name: &str, kind: MediaKind, duration: Tick, video: Option { MediaKind::AudioOnly => FootageKind::Audio, MediaKind::Still => FootageKind::Still, MediaKind::ImageSequence => FootageKind::Sequence, - _ if v.is_none() && info.audio.is_some() => FootageKind::Audio, + _ if v.is_none() && info.has_audio() => FootageKind::Audio, _ => FootageKind::Video, }; let _ = name; @@ -284,7 +284,7 @@ impl Importer<'_> { frame_rate: rate_in(info.frame_rate()), duration: tick_in(info.duration), has_video: kind != FootageKind::Audio, - has_audio: info.audio.is_some() || kind == FootageKind::Audio, + has_audio: info.has_audio() || kind == FootageKind::Audio, alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore }, codec: v.map(|v| v.codec.clone()).unwrap_or_default(), missing: true, diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 3ad7fcaae..6e29c79d2 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -36,8 +36,12 @@ fn info(kind: filmcraft_media::MediaKind, video: bool, audio: bool) -> filmcraft color: Default::default(), has_alpha: false, bitrate: None, + hdr: None, }), - audio: audio.then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }), + audio_streams: audio + .then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }) + .into_iter() + .collect(), container: String::new(), start_timecode: None, file_size: None, diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs index 2ef417dd7..e8a9237e0 100644 --- a/crates/media/src/downmix.rs +++ b/crates/media/src/downmix.rs @@ -64,11 +64,30 @@ pub fn stereo_gains(count: usize, aac: bool) -> Vec { out } +/// The gains for AAC as FilmCraft's decoder hands it over: channel configurations 3 to 6 (3 to 6 +/// channels) are already in WAV/SMPTE order (L R C [Cs] / [LFE] Ls Rs), everything else (mono, +/// stereo, 7 and 8 channels, program-config layouts) in AAC's element order. +pub fn decoded_aac_gains(count: usize) -> Vec { + match count { + // C L R Cs, reordered to L R C Cs: the same gains, permuted. + 4 => { + let g = stereo_gains(4, true); + [1, 2, 0, 3].iter().filter_map(|&i| g.get(i).copied()).collect() + } + 3 | 5 | 6 => stereo_gains(count, false), + _ => stereo_gains(count, true), + } +} + /// Downmix planar `channels` to interleaved stereo in `out` (`out.len() / 2` frames); frames a /// channel is too short for count as silence, extra frames in `out` stay as they are. pub fn downmix_into(channels: &[Vec], aac: bool, out: &mut [f32]) { - let gains = stereo_gains(channels.len(), aac); - for (ch, g) in channels.iter().zip(&gains) { + let gains = if aac { decoded_aac_gains(channels.len()) } else { stereo_gains(channels.len(), false) }; + mix(channels, &gains, out); +} + +fn mix(channels: &[Vec], gains: &[Pair], out: &mut [f32]) { + for (ch, g) in channels.iter().zip(gains) { if g[0] == 0.0 && g[1] == 0.0 { continue; } @@ -98,6 +117,22 @@ mod tests { } } + #[test] + fn decoded_aac_is_in_wav_order_for_three_to_six_channels() { + // FilmCraft hands AAC 3.0 to 5.1 over as L R C [LFE] Ls Rs, the order of the plain layouts. + for n in [3, 5, 6] { + assert_eq!(decoded_aac_gains(n), stereo_gains(n, false), "{n} channels"); + } + let g = decoded_aac_gains(4); + assert_eq!(g.len(), 4); + assert!(g[0][0] > 0.0 && g[0][1] == 0.0 && g[1][1] > 0.0 && g[1][0] == 0.0, "L R first"); + assert!(g[2][0] > 0.0 && g[2][0] == g[2][1], "the centre is third and feeds both sides"); + assert!(g[3][0] > 0.0 && g[3][0] < g[2][0], "the rear centre is last, below the centre"); + for n in [1, 2, 7, 8, 9] { + assert_eq!(decoded_aac_gains(n), stereo_gains(n, true), "{n} channels"); + } + } + #[test] fn five_one_matches_the_bs775_matrix() { let k = 1.0 / (1.0 + 2.0 * FRAC_1_SQRT_2); diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 98c707108..33fb0b26f 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -509,7 +509,7 @@ impl MediaPool { let path: Arc = footage.path.as_str().into(); let src = self.inner.source(&path).ok_or("the file can't be opened")?; let s0 = start.to_units_floor(rate as i64); - let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate); + let native = src.info().audio().map_or(rate, |a| a.sample_rate); if native == 0 || rate == 0 || native == rate { return read_stereo(&src, s0, frames, rate); } @@ -700,9 +700,12 @@ impl Inner { let rate = footage.frame_rate; let n = Self::frame_count(rate, footage.duration); let i = Self::frame_index(rate, t, n, footage.loop_count); - // Middle of the frame in the file's own timing (robust to timestamp rounding). + // A quarter into the frame in the file's own timing: FilmCraft rounds a request to + // the track's nearest timestamp unit (halves up), so the middle of a frame would + // land on the next one when the timescale is the frame rate; a quarter still + // tolerates timestamps rounded either way. let native = footage.native_rate.unwrap_or(rate); - let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 2); + let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 4); Loc { key: key(&footage.path, i), media_t: Some(mt), float } } } @@ -915,7 +918,7 @@ fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::re } let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?; let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) }; - let aac = src.info().audio.as_ref().is_some_and(|a| a.codec.starts_with("AAC")); + let aac = src.info().audio().is_some_and(|a| a.codec.starts_with("AAC")); crate::downmix::downmix_into(&buf.channels, aac, dst); Ok(out) } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index bae714c81..7c5d1c491 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -119,7 +119,7 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - (_, None) => FootageKind::Audio, }; let rate = v.map_or(FrameRate::FPS_30, |v| rate_from_fc(v.frame_rate)); - let codec = match (v, info.audio.as_ref()) { + let codec = match (v, info.audio()) { (Some(v), _) => v.codec.clone(), (None, Some(a)) => a.codec.clone(), _ => String::new(), @@ -134,7 +134,7 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - native_rate: None, duration: if kind == FootageKind::Still { Tick::ZERO } else { Tick(info.duration.0) }, has_video: v.is_some(), - has_audio: info.audio.is_some(), + has_audio: info.has_audio(), alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore }, premul_color: [0.0; 3], loop_count: 1, @@ -239,12 +239,19 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> im /// The rotation an MP4/MOV movie is shown with (a phone held upright records sideways frames and /// says so in its video track's header): the quarter turn nearest the track's rotation. Only the /// rotation counts; any mirroring, scaling or offset in the header is ignored, and other -/// containers have none. +/// containers have none. FilmCraft turns the frames of a pure 0/90/180/270° header itself, so this +/// is only the quarter turn nearest to a header it does not turn. pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { let Ok(file) = filmcraft_isobmff::open(bytes) else { return Orientation::NoTransforms }; - let Some(m) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video).map(|t| t.matrix) else { + let Some(track) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video) else { return Orientation::NoTransforms; }; + // A pure rotation is already applied by FilmCraft's decoder (frames and size come out + // upright); only the headers it leaves as stored (mirrored, scaled, sheared) are turned here. + if track.display_rotation().is_some() { + return Orientation::NoTransforms; + } + let m = track.matrix; // [a b u; c d v; x y w]: (a, b) is where the picture's x axis points. let (a, b) = (m[0] as i64, m[1] as i64); match (a.abs() >= b.abs(), a >= 0, b >= 0) { From 7b3d3455cb7bb66ddf30ac2c4af0edfdfbf883b4 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 02:35:07 +1100 Subject: [PATCH 007/112] 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 008/112] 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 be9c9077cd62232f3a6ecac2a6c2d309cfb68545 Mon Sep 17 00:00:00 2001 From: p01ntov <278898977+p01ntov@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:56:35 +0300 Subject: [PATCH 009/112] G2: tessellate viewer path overlays in screen space --- crates/ui-egui/src/panels/viewer_overlays.rs | 152 ++++++++++++++++++- 1 file changed, 146 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 9117103d5..d0875941e 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -132,17 +132,52 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option Vec { - let k = effectcraft_engine::render::kurbo_path(sp); + let mut k = effectcraft_engine::render::kurbo_path(sp); + // A path-space tolerance becomes visible corners when the viewer or a layer/group is + // magnified. Flatten affine paths in screen space so the error stays below half a point. + // Projective transforms must still map the flattened points: projecting Bezier control + // points would change the curve rather than just its tessellation. + let screen_space = m.is_affine(); + if screen_space { + let z = f64::from(map.zoom); + k.apply_affine(kurbo::Affine::new([ + m.0[0][0] * z, + m.0[1][0] * z, + m.0[0][1] * z, + m.0[1][1] * z, + m.0[0][2] * z + f64::from(map.origin.x), + m.0[1][2] * z + f64::from(map.origin.y), + ])); + } + if !k.is_finite() { + return Vec::new(); + } + let bounds = k.control_box(); + if [bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().any(|v| v.abs() > f64::from(f32::MAX)) { + return Vec::new(); + } + // Bound subdivision work even for extreme finite scales from a malformed project. For + // ordinary on-screen paths the tolerance remains half a point. + let tolerance = (bounds.width().max(bounds.height()) / 1_000_000.0).max(0.5); let mut pts: Vec = vec![]; - kurbo::flatten(k.iter(), 0.5, |el| match el { + kurbo::flatten(k.iter(), tolerance, |el| match el { kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => { - let c = m.apply(gv2(q.x, q.y)); - pts.push(map.to_screen([c.x, c.y])); + let p = if screen_space { + egui::pos2(q.x as f32, q.y as f32) + } else { + let c = m.apply(gv2(q.x, q.y)); + map.to_screen([c.x, c.y]) + }; + if p.is_finite() { + pts.push(p); + } } _ => {} }); - if sp.closed && !pts.is_empty() { - pts.push(pts[0]); + if sp.closed + && let Some(first) = pts.first().copied() + { + pts.push(first); } pts } @@ -679,6 +714,111 @@ mod tests { PathInfo { layer: LayerId(3), uid: 7, m: Mat3::IDENTITY, sp, is_mask: true, feather: Vec::new() } } + fn circle() -> ShapePath { + let r = 38.0; + let h = r * 0.552_284_749_830_793_6; + ShapePath { + vertices: vec![[0.0, -r], [r, 0.0], [0.0, r], [-r, 0.0]], + in_tangents: vec![[-h, 0.0], [0.0, -h], [h, 0.0], [0.0, h]], + out_tangents: vec![[h, 0.0], [0.0, h], [-h, 0.0], [0.0, -h]], + closed: true, + feather: Vec::new(), + } + } + + /// Measure the actual screen-space error against dense samples of the original cubics, + /// independently of the tessellator's chosen subdivisions. + fn screen_error(sp: &ShapePath, m: &Mat3, map: &ViewerMap, points: &[Pos2]) -> f32 { + let mut worst = 0.0_f32; + for i in 0..sp.vertices.len() { + let Some(c) = segment(sp, i) else { continue }; + for step in 0..=1024 { + let q = cubic(c[0], c[1], c[2], c[3], f64::from(step) / 1024.0); + let q = m.apply(gv2(q[0], q[1])); + let p = map.to_screen([q.x, q.y]); + let distance = points + .windows(2) + .map(|pair| { + let a = pair[0]; + let v = pair[1] - a; + let t = if v.length_sq() > 0.0 { ((p - a).dot(v) / v.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(a + v * t) + }) + .fold(f32::INFINITY, f32::min); + worst = worst.max(distance); + } + } + worst + } + + #[test] + fn curve_overlay_stays_smooth_at_viewer_magnifications() { + let sp = circle(); + let mut counts = Vec::new(); + for zoom in [0.5, 1.0, 5.0] { + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &Mat3::IDENTITY, &map); + let error = screen_error(&sp, &Mat3::IDENTITY, &map, &points); + assert!(error <= 0.51, "{zoom}x: {error} screen points from the curve"); + assert_eq!(points.first(), points.last(), "closed paths keep their closing edge"); + for v in &sp.vertices { + let anchor = map.to_screen(*v); + assert!(points.iter().any(|p| p.distance(anchor) < 0.001), "original vertex missing at {zoom}x"); + } + counts.push(points.len()); + } + assert!(counts[0] < counts[1] && counts[1] < counts[2], "tessellation follows visible curve size: {counts:?}"); + } + + #[test] + fn curve_overlay_accounts_for_nonuniform_layer_and_group_transforms() { + let sp = circle(); + let layer = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8)); + let group = Mat3::rotate_deg(-19.0) * Mat3::skew_deg(25.0, 11.0) * Mat3::scale(gv2(0.7, 2.5)); + let m = layer * group; + for zoom in [0.5, 1.0, 5.0] { + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &m, &map); + let error = screen_error(&sp, &m, &map, &points); + assert!(error <= 0.51, "{zoom}x transformed path: {error} screen points from the curve"); + } + } + + #[test] + fn open_overlay_preserves_its_transformed_endpoints() { + let sp = info(vec![[10.0, 20.0], [50.0, 60.0]], false).sp; + let m = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8)); + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &m, &map); + assert_ne!(points.first(), points.last(), "an open path must not gain a closing edge"); + for (point, vertex) in [points.first(), points.last()].into_iter().zip([sp.vertices.first(), sp.vertices.last()]) { + let q = m.apply(gv2(vertex.unwrap()[0], vertex.unwrap()[1])); + assert!(point.unwrap().distance(map.to_screen([q.x, q.y])) < 0.001); + } + } + + #[test] + fn invalid_overlay_coordinates_are_not_tessellated() { + let sp = circle(); + for zoom in [f32::NAN, f32::INFINITY] { + let map = ViewerMap { origin: Pos2::ZERO, zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + assert!(flatten(&sp, &Mat3::IDENTITY, &map).is_empty()); + } + let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + assert!(flatten(&sp, &Mat3::scale(gv2(f64::INFINITY, 1.0)), &map).is_empty()); + } + + #[test] + fn extreme_finite_overlay_scales_keep_subdivision_bounded() { + let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let sp = circle(); + let points = flatten(&sp, &Mat3::scale(gv2(1e12, 1e12)), &map); + assert!(!points.is_empty()); + assert!(points.len() < 10_000, "extreme scales must not allocate millions of vertices"); + assert!(points.iter().all(|p| p.is_finite())); + assert!(flatten(&sp, &Mat3::scale(gv2(1e300, 1e300)), &map).is_empty()); + } + /// #912: clicking the last vertex of an open path with the Pen resumes that path. #[test] fn pen_resumes_an_open_path_at_its_last_vertex() { From ee17172fb688ffe1abb07de2db9c7d806f4e5198 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 10:50:04 -0400 Subject: [PATCH 010/112] Media: update FilmCraft to 09aaf9b, which reads AVI (#1701) Moves the pin past FilmCraft's AVI / OpenDML reader (Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3). No EffectCraft API changes. --- Cargo.lock | 144 +++++++++++++++++++++++++++++++++-------------------- Cargo.toml | 32 ++++++------ 2 files changed, 106 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06da2cebd..1dea8187a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2043,8 +2043,8 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "filmcraft-aac" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2052,16 +2052,16 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-apv" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2070,33 +2070,39 @@ dependencies = [ [[package]] name = "filmcraft-av1" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-avi" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" + [[package]] name = "filmcraft-bitstream" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-cfb" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-codecs" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-aac", "filmcraft-ac3", "filmcraft-apv", "filmcraft-av1", + "filmcraft-avi", "filmcraft-bitstream", "filmcraft-color", "filmcraft-dnx", @@ -2106,6 +2112,7 @@ dependencies = [ "filmcraft-isobmff", "filmcraft-matroska", "filmcraft-media", + "filmcraft-mpa", "filmcraft-mpeg2v", "filmcraft-mpegts", "filmcraft-mxf", @@ -2113,9 +2120,11 @@ dependencies = [ "filmcraft-opus", "filmcraft-prores", "filmcraft-time", + "filmcraft-vp8", "filmcraft-vp9", "image", "log", + "rayon", "symphonia", "thiserror 2.0.21", "web-time", @@ -2123,16 +2132,25 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] +[[package]] +name = "filmcraft-dng" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-dnx" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2141,8 +2159,8 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -2152,16 +2170,16 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] [[package]] name = "filmcraft-h264" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2170,8 +2188,8 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2180,8 +2198,8 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2190,8 +2208,8 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2207,8 +2225,8 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2216,15 +2234,16 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-media" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", + "filmcraft-dng", "filmcraft-frame", "filmcraft-time", "image", @@ -2233,10 +2252,19 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-mpa" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "filmcraft-bitstream", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-mpeg2v" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "rayon", "thiserror 2.0.21", @@ -2244,31 +2272,31 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-mxf" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-ogg" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-opus" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-project" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2281,8 +2309,8 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2291,16 +2319,24 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] +[[package]] +name = "filmcraft-vp8" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-vp9" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", diff --git a/Cargo.toml b/Cargo.toml index 7b9983788..8810e3c16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,26 +72,26 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). # Only `crates/media`, `crates/export` and `crates/interchange` may depend on these. -filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # encoders + muxer (crates/export) -filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency) -filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only) -filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only) -filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned # rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that. symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] } From 01ad187b97888efedd588130f12d20887e7a610b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 10:53:04 -0400 Subject: [PATCH 011/112] Media: drop the AIFF workaround dependency (#1701) FilmCraft's codecs now turn on symphonia's AIFF reader themselves. --- Cargo.lock | 1 - Cargo.toml | 3 --- crates/media/Cargo.toml | 2 -- 3 files changed, 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1dea8187a..8fc3183ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1455,7 +1455,6 @@ dependencies = [ "log", "rayon", "serde_json", - "symphonia", "thiserror 2.0.21", ] diff --git a/Cargo.toml b/Cargo.toml index 8810e3c16..b1c6caaf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,9 +92,6 @@ filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9b filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } -# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned -# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that. -symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] } # gpu (the same wgpu eframe / egui-wgpu use) wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] } diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index bb47dccb0..a3ff624b4 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -24,8 +24,6 @@ filmcraft-isobmff = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } -# AIFF import (#1428): enables the aiff reader of filmcraft-codecs' symphonia. -symphonia = { workspace = true } exr = { workspace = true } gif = { workspace = true } image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] } From ca724a3d84c8fcd1724b3de724ed4c5c4507ee53 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 10:53:05 -0400 Subject: [PATCH 012/112] Media: downmix 7.1 AAC in the order FilmCraft now delivers it (#1701) FilmCraft reorders AAC channel configuration 12 (7.1) to WAVE/SMPTE order, as it does 3 to 6, so 7.1 AAC's left channel was mixed as the centre. The downmix oracle test gains 6.1 (a program config element, left in AAC's order) and 7.1 AAC fixtures. --- crates/media/src/downmix.rs | 16 +++++++++------- crates/media/tests/downmix.rs | 10 ++++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs index e8a9237e0..1ad9721ba 100644 --- a/crates/media/src/downmix.rs +++ b/crates/media/src/downmix.rs @@ -65,8 +65,10 @@ pub fn stereo_gains(count: usize, aac: bool) -> Vec { } /// The gains for AAC as FilmCraft's decoder hands it over: channel configurations 3 to 6 (3 to 6 -/// channels) are already in WAV/SMPTE order (L R C [Cs] / [LFE] Ls Rs), everything else (mono, -/// stereo, 7 and 8 channels, program-config layouts) in AAC's element order. +/// channels) and 12 (7.1, 8 channels) are already in WAV/SMPTE order (L R C [Cs] / [LFE] Ls Rs, +/// L R C LFE Lb Rb Ls Rs), everything else (mono, stereo, program-config layouts such as the 6.1 +/// ffmpeg writes) in AAC's element order. Configuration 11 (6.1) and 7 (7.1 with front wides) +/// can't be told from those by the channel count and are read in the order of the commoner one. pub fn decoded_aac_gains(count: usize) -> Vec { match count { // C L R Cs, reordered to L R C Cs: the same gains, permuted. @@ -74,7 +76,7 @@ pub fn decoded_aac_gains(count: usize) -> Vec { let g = stereo_gains(4, true); [1, 2, 0, 3].iter().filter_map(|&i| g.get(i).copied()).collect() } - 3 | 5 | 6 => stereo_gains(count, false), + 3 | 5 | 6 | 8 => stereo_gains(count, false), _ => stereo_gains(count, true), } } @@ -118,9 +120,9 @@ mod tests { } #[test] - fn decoded_aac_is_in_wav_order_for_three_to_six_channels() { - // FilmCraft hands AAC 3.0 to 5.1 over as L R C [LFE] Ls Rs, the order of the plain layouts. - for n in [3, 5, 6] { + fn decoded_aac_is_in_wav_order_for_three_to_six_and_eight_channels() { + // FilmCraft hands AAC 3.0 to 5.1 and 7.1 over in the order of the plain layouts. + for n in [3, 5, 6, 8] { assert_eq!(decoded_aac_gains(n), stereo_gains(n, false), "{n} channels"); } let g = decoded_aac_gains(4); @@ -128,7 +130,7 @@ mod tests { assert!(g[0][0] > 0.0 && g[0][1] == 0.0 && g[1][1] > 0.0 && g[1][0] == 0.0, "L R first"); assert!(g[2][0] > 0.0 && g[2][0] == g[2][1], "the centre is third and feeds both sides"); assert!(g[3][0] > 0.0 && g[3][0] < g[2][0], "the rear centre is last, below the centre"); - for n in [1, 2, 7, 8, 9] { + for n in [1, 2, 7, 9] { assert_eq!(decoded_aac_gains(n), stereo_gains(n, true), "{n} channels"); } } diff --git a/crates/media/tests/downmix.rs b/crates/media/tests/downmix.rs index e0e7d2252..e2c95c251 100644 --- a/crates/media/tests/downmix.rs +++ b/crates/media/tests/downmix.rs @@ -94,8 +94,18 @@ fn cases() -> Vec { let (n71, g71) = seven_one(); let k5 = 1.0 / (1.0 + 2.0 * K); let kq = 1.0 / (1.0 + K); + let k61 = 1.0 / (1.5 + 2.0 * K); vec![ Case { file: "dm51.m4a", layout: "5.1", names: n51, codec: &["-c:a", "aac", "-b:a", "384k"], gains: g51.clone(), tol: 0.15 }, + Case { + file: "dm61.m4a", + layout: "6.1", + names: &["FL", "FR", "FC", "LFE", "BC", "SL", "SR"], + codec: &["-c:a", "aac", "-b:a", "448k"], + gains: vec![[k61, 0.0], [0.0, k61], [k61 * K, k61 * K], [0.0, 0.0], [k61 / 2.0, k61 / 2.0], [k61 * K, 0.0], [0.0, k61 * K]], + tol: 0.15, + }, + Case { file: "dm71.m4a", layout: "7.1", names: n71, codec: &["-c:a", "aac", "-b:a", "512k"], gains: g71.clone(), tol: 0.15 }, Case { file: "dm51.ac3.mp4", layout: "5.1", names: n51, codec: &["-c:a", "ac3", "-b:a", "640k"], gains: g51.clone(), tol: 0.15 }, Case { file: "dm51.wav", layout: "5.1", names: n51, codec: &["-c:a", "pcm_s16le"], gains: g51.clone(), tol: 0.02 }, Case { file: "dm51.flac", layout: "5.1", names: n51, codec: &["-c:a", "flac"], gains: g51, tol: 0.02 }, From bed49c4959787c41e4fd5986db9683f2debe8af5 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 013/112] Media: import AVI (#1701) One list of movie extensions, in effectcraft-project, with AVI added: FilmCraft reads AVI 1.0 / OpenDML. Tests against ffmpeg fixtures: Motion JPEG with PCM audio, an unsupported codec (imports and says why it has no picture), a file that isn't an AVI (an import error), and a damaged one (a truncated recording imports as far as it goes; a garbled packet loses its own frame only). --- crates/media/src/lib.rs | 3 +- crates/media/tests/footage.rs | 135 ++++++++++++++++++++++++++++++++-- crates/project/src/lib.rs | 5 ++ 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 04a9d95e3..1b40fcfac 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -63,8 +63,7 @@ pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "tga", "hdr", "psd", "psb", "svg", "pdf", "ai", "eps"]; /// File extensions recognised as audio-only files. pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; -/// File extensions recognised as movies. -pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf"]; +pub use effectcraft_project::VIDEO_EXTENSIONS; /// File extensions recognised as 3D models (Advanced 3D model layers). pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 13ca605e2..8dd4db672 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -168,9 +168,14 @@ fn tone_wav() -> Option { /// Frame `n` of `src` as RGBA PNG extracted by ffmpeg (Y'CbCr → RGB with the BT.709 matrix). fn reference_frame(src: &Path, n: usize) -> Image { + reference_frame_in(src, n, "bt709") +} + +/// [`reference_frame`] of a movie whose Y'CbCr is in `matrix` (`bt601` for Motion JPEG). +fn reference_frame_in(src: &Path, n: usize, matrix: &str) -> Image { let stem = src.file_stem().expect("stem").to_string_lossy().to_string(); let name = format!("ref_{stem}_{n}.png"); - let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix=bt709:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba"); + let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix={matrix}:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba"); let p = ffmpeg_fixture(&name, &["-i", src.to_str().expect("utf8"), "-vf", &vf, "-fps_mode", "passthrough", "-frames:v", "1"]).expect("ffmpeg"); load_png(&p) } @@ -472,12 +477,12 @@ fn dnxhd_mxf() -> Option { ) } -/// MXF is importable footage (After Effects imports it), whatever the extension's case. +/// MXF and AVI are importable footage (After Effects imports them), whatever the extension's case. #[test] -fn mxf_is_importable() { - assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.mxf"))); - assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.MXF"))); - assert!(!effectcraft_media::is_importable(Path::new("/media/A001C003.avi")), "no AVI demuxer yet"); +fn mxf_and_avi_are_importable() { + for name in ["A001C003.mxf", "A001C003.MXF", "capture.avi", "CAPTURE.AVI"] { + assert!(effectcraft_media::is_importable(&Path::new("/media").join(name)), "{name}"); + } } #[test] @@ -523,6 +528,124 @@ fn mxf_dnxhd_frames() { } } +fn mjpeg_avi() -> Option { + ffmpeg_fixture( + "testsrc2_mjpeg_pcm.avi", + &[ + "-f", + "lavfi", + "-i", + "testsrc2=size=640x360:rate=25:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + "-c:v", + "mjpeg", + "-q:v", + "2", + "-pix_fmt", + "yuvj420p", + "-c:a", + "pcm_s16le", + "-ac", + "2", + ], + ) +} + +#[test] +fn avi_mjpeg_with_pcm_audio() { + let Some(p) = mjpeg_avi() else { return }; + let f = probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Video); + assert_eq!((f.width, f.height), (640, 360)); + assert_eq!(f.frame_rate, FrameRate::FPS_25); + assert!((f.duration.seconds() - 2.0).abs() < 0.1, "duration {:?}", f.duration.seconds()); + assert!(f.has_video && f.has_audio); + assert_eq!(f.codec, "Motion JPEG"); + let pool = MediaPool::new(); + for n in [0usize, 1, 30, 7, 49] { + let img = pool.frame_at(&f, t_of(f.frame_rate, n as i64)).expect("frame"); + let db = psnr(&img, &reference_frame_in(&p, n, "bt601")); + assert!(db > 35.0, "frame {n}: {db:.2} dB"); + } + let s = pool.audio_samples(&f, Tick(TICKS_PER_SECOND / 2), 4800, 48_000); + assert_eq!(s.len(), 9600); + let rms = (s.iter().map(|v| v * v).sum::() / s.len() as f32).sqrt(); + assert!((rms - 0.0625).abs() < 0.01, "rms {rms}"); +} + +/// An AVI whose video FilmCraft can't decode imports, and says why it shows no picture (as an +/// unsupported stream in any other container does), rather than failing as a broken file. +#[test] +fn avi_with_an_unsupported_codec_says_so() { + let Some(p) = ffmpeg_fixture("testsrc2_xvid.avi", &["-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25:duration=1", "-c:v", "mpeg4", "-vtag", "XVID"]) + else { + return; + }; + let f = probe(&p).expect("a valid AVI imports"); + assert_eq!((f.kind, f.width, f.height), (FootageKind::Video, 320, 180)); + let pool = MediaPool::new(); + if pool.frame(ItemId(1), &f, Tick::ZERO).is_some() { + // FilmCraft decodes MPEG-4 Part 2 now: nothing to report + assert_eq!(pool.problem(&f), None); + return; + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("MPEG-4 Part 2") && why.contains("isn't supported"), "{why}"); +} + +/// A file that only looks like an AVI is an import error that says so. +#[test] +fn an_invalid_avi_is_an_import_error() { + let e = probe_bytes("broken.avi", Arc::from(&b"RIFF\x10\0\0\0AVI LIST\0\0\0\0garbage"[..])).expect_err("not an AVI"); + assert!(e.to_string().contains("invalid AVI"), "{e}"); +} + +/// A damaged AVI imports: a truncated recording as far as it goes, and a packet that doesn't +/// decode loses its own frame only, with a reason that says the file is damaged rather than +/// unsupported. +#[test] +fn a_damaged_avi_loses_only_what_is_damaged() { + let Some(p) = mjpeg_avi() else { return }; + let bytes = std::fs::read(&p).expect("read"); + let full = probe(&p).expect("probe"); + let pool = MediaPool::new(); + + let cut: Arc<[u8]> = bytes[..bytes.len() / 3].into(); + let f = probe_bytes("cut.avi", cut.clone()).expect("a truncated AVI imports"); + assert!(f.duration > Tick::ZERO && f.duration < full.duration, "{:?} of {:?}", f.duration, full.duration); + pool.add_bytes("cut.avi", cut); + let last = f.frame_rate.frame_at(f.duration - Tick(1)); + assert!(pool.frame(ItemId(1), &f, t_of(f.frame_rate, last)).is_some(), "its last frame"); + + // Garble the JPEG data of the video chunk of frame 10. + let mut bad = bytes.clone(); + let mut at = bad.windows(4).position(|w| w == b"movi").expect("movi") + 4; + let mut video = 0; + while let Some(head) = bad.get(at..at + 8) { + let size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize; + if &head[2..4] == b"dc" { + if video == 10 { + bad[at + 8 + 200..at + 8 + size.min(3000)].iter_mut().enumerate().for_each(|(i, b)| *b = if i % 7 == 0 { 0xFF } else { 0 }); + break; + } + video += 1; + } + at += 8 + size + size % 2; + } + assert_eq!(video, 10, "found frame 10's chunk"); + let f = probe_bytes("bad.avi", bad.clone().into()).expect("a damaged AVI imports"); + pool.add_bytes("bad.avi", bad.into()); + assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, 10)).is_none(), "the damaged frame"); + for n in [9, 11, 30] { + assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, n)).is_some(), "frame {n}"); + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("can't be decoded") && !why.contains("isn't supported"), "{why}"); +} + #[test] fn png_sequence_import() { let Some(first) = png_sequence() else { return }; diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 16d782165..70a85a718 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1089,6 +1089,11 @@ impl FieldOrder { } } +/// File extensions of the movie containers footage is imported from (FilmCraft reads them). The +/// import and proxy file dialogs, the Media Browser and `effectcraft_media::is_importable` all go +/// by this list. +pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf", "avi"]; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum AlphaMode { #[default] From e26b4af5c07fe8dd29139c71fe5e0a4848d26a43 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 014/112] Media Browser: list movies by the importer's list (#1701) --- crates/engine/src/media_browser.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index b394ad937..775e3db98 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -197,7 +197,6 @@ fn sort_entries(entries: &mut [Entry]) { entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase())); } -const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"]; const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"]; const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"]; @@ -207,7 +206,8 @@ const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; pub fn kind_of(path: &str) -> Option<&'static str> { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); let e = ext.as_str(); - if VIDEO.contains(&e) { + // (An animated GIF plays as a movie.) + if e == "gif" || effectcraft_project::VIDEO_EXTENSIONS.contains(&e) { Some("video") } else if IMAGE.contains(&e) { Some("image") @@ -420,6 +420,15 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// Every movie type the importer reads lists as video, as an animated GIF does. + #[test] + fn movies_list_as_video() { + for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain(&["gif"]) { + assert_eq!(kind_of(&format!("/clips/A.{}", ext.to_uppercase())), Some("video"), "{ext}"); + } + assert_eq!(kind_of("/clips/capture.avi"), Some("video")); + } + #[test] fn virtual_tree_lists_like_a_folder() { let mut t = VirtualTree::default(); From 93a9295125e5487c0b9e99cb904cd07bb340901b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 015/112] File dialogs: offer every movie type the importer reads (#1701) Import and Set Proxy build their filters from the shared movie list, so they offer AVI; the web build's file picker gets the same list. --- crates/ui-egui/src/menus.rs | 19 ++++++++++++++----- crates/ui-egui/tests/ui_menus.rs | 20 ++++++++++++-------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 26a7b66f6..eda7ccf7e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1337,6 +1337,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("paths", Ask::Import), @@ -1366,7 +1368,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), - "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), + "file.setProxy" => ("path", Ask::OpenFootage(&["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), _ => return None, }; if params.get(key).is_some() @@ -1377,19 +1379,22 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option>(); // A host whose modal dialogs would run a nested event loop inside this frame (macOS: a // drag over the window then re-entered winit's event handler and aborted, #511) shows the // dialog without blocking; the command continues when the answer arrives. if let Some(spawn) = app.hooks.file_dialog_async.as_ref() { let request = match ask { Ask::Folder => crate::FileDialog::Folder, - Ask::Import => crate::FileDialog::Open { exts: MEDIA.iter().map(|e| e.to_string()).collect(), many: key == "paths" }, + Ask::Import => crate::FileDialog::Open { exts: footage(MEDIA).iter().map(|e| e.to_string()).collect(), many: key == "paths" }, Ask::OpenProject => crate::FileDialog::OpenProject, Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false }, + Ask::OpenFootage(more) => crate::FileDialog::Open { exts: footage(more).iter().map(|e| e.to_string()).collect(), many: false }, Ask::Save(default) => crate::FileDialog::save(default), }; let tx = app.dialog_answers.0.clone(); @@ -1409,7 +1414,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option { let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) }; - picked_value(key, f(MEDIA)) + picked_value(key, f(&footage(MEDIA))) } Ask::OpenProject => { let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; @@ -1419,6 +1424,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option { + let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; + f(&footage(more)).first().map(|s| json!(s)) + } Ask::Save(default) => { let Some(picked) = app.hooks.save_dialog(default) else { return Some(Err("no file dialog available (pass `path`)".into())) }; picked.map(|s| json!(s)) diff --git a/crates/ui-egui/tests/ui_menus.rs b/crates/ui-egui/tests/ui_menus.rs index ca4c8cabc..ce6b97800 100644 --- a/crates/ui-egui/tests/ui_menus.rs +++ b/crates/ui-egui/tests/ui_menus.rs @@ -205,10 +205,10 @@ fn relink_restores_decoded_pixels_and_invalidates_viewer_content() { std::fs::remove_dir_all(dir).unwrap(); } -/// #767: File ▸ Import ▸ File… offers MXF footage (OP1a / OP-Atom from broadcast cameras and -/// Avid), which the Media Browser lists and the importer reads. +/// #767, #1701: File ▸ Import ▸ File… and the proxy dialog offer every movie type the importer +/// reads (MXF from broadcast cameras and Avid, AVI, …), the list the Media Browser goes by too. #[test] -fn import_dialog_offers_mxf() { +fn import_and_proxy_dialogs_offer_every_movie_type() { use std::cell::RefCell; use std::rc::Rc; @@ -219,11 +219,15 @@ fn import_dialog_offers_mxf() { *seen.borrow_mut() = exts.iter().map(|e| e.to_string()).collect(); Vec::new() })); - let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.import", json!({})).unwrap(); - assert!(result.is_null(), "cancelled: nothing imported"); - let offered = offered.borrow(); - assert!(offered.iter().any(|e| e == "mxf"), "offered {offered:?}"); - assert!(offered.iter().any(|e| e == "mov")); + for (command, still) in [("file.import", "psd"), ("file.setProxy", "png")] { + let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), command, json!({})).unwrap(); + assert!(result.is_null(), "{command} cancelled: nothing done"); + let offered = offered.borrow(); + for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain([&still]) { + assert!(offered.iter().any(|e| e == ext), "{command} offers {ext}: {offered:?}"); + } + assert!(offered.iter().any(|e| e == "avi"), "{command}: {offered:?}"); + } } /// #1059: opening the demo from the start screen and immediately pressing Spacebar previews From 84d0f567656a0ba1b68da5ccbf804c64d2cdc74f Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 016/112] Docs: AVI import (#1701) --- docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9e2249e9b..f6e8913c4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -65,7 +65,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW | | WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects | | MXF (OP1a / OP-Atom) | R/W | yes (FilmCraft's demuxer: AVC, MPEG-2 incl. XDCAM / D-10, VC-3 / DNxHD, ProRes; PCM audio) | none | partial: import (#767), checked against ffmpeg; no MXF output | -| AVI | R | none | none | Media Browser lists it | +| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | | DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | | BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | | Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus | diff --git a/docs/gaps.md b/docs/gaps.md index a20e779cd..bda5eba76 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 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 624 rows each); panels in 6 (ja, zh-hans, zh-hant, uk, pt-br, cs); effect names translated in Simplified Chinese (306, 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** | The Media Browser, the import and proxy dialogs and `media::is_importable` share one movie list since #1701, AVI included (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; AVI since #1701, without DV, MPEG-4 Part 2 or other legacy codecs yet); #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) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eac744b6f..74111b27d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -114,7 +114,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Area | Weight | Breadth | Ready | Hours | Biggest gaps (details in [gaps.md](gaps.md)) | |---|---:|---:|---:|---:|---| -| 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 | +| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; TGA/HDR/DPX/MXF/AVI import since #745, #756, #767, #1701 (no DV or MPEG-4 Part 2 AVI yet); #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) | From c7715a3f2d43d79e5d419c29aba736444443dd4d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 14:02:18 -0400 Subject: [PATCH 017/112] Footage: a proxy in use that can't be decoded says why (#1701) The picture comes from an item's proxy while Use Proxy is on, but the Project panel, the Viewer, project.summary and footage.info asked only about the item's own file: a proxy that couldn't be decoded (an AVI with an unsupported codec, say) left the layer empty with no reason given. They now ask FootageSource::item_problem, which gives the proxy's reason, said to be the proxy's, for footage and compositions alike. --- crates/engine/src/commands/footage_panel.rs | 5 +-- crates/engine/src/commands/query.rs | 8 ++-- crates/engine/src/tests_panels.rs | 44 +++++++++++++++++++ crates/render/src/lib.rs | 12 +++++ crates/ui-egui/src/panels/project.rs | 9 ++-- crates/ui-egui/src/panels/viewer.rs | 11 ++--- crates/ui-egui/tests/ui_unreadable_footage.rs | 39 +++++++++++----- 7 files changed, 97 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/commands/footage_panel.rs b/crates/engine/src/commands/footage_panel.rs index 1bd0d2236..7336d0c08 100644 --- a/crates/engine/src/commands/footage_panel.rs +++ b/crates/engine/src/commands/footage_panel.rs @@ -207,10 +207,7 @@ fn info(s: &mut Session, _: &Value) -> Result { let item = s.project.item(v.item); let name = item.map(|i| i.name.clone()).unwrap_or_default(); let frame = fr.frame_at(v.time); - let problem = match item.map(|i| &i.kind) { - Some(ItemKind::Footage(f)) => s.footage.problem(f), - _ => None, - }; + let problem = item.and_then(|i| s.footage.item_problem(i)); Ok( json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}), ) diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 2ea8048fa..ed5eeffd1 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -46,11 +46,9 @@ fn summary(s: &mut Session, _: &Value) -> Result { o["layers"] = json!(c.layers.len()); o["frameRate"] = json!(c.frame_rate.as_f64()); } - // Footage that shows no picture although its file is there (a codec or profile the - // decoders lack): why, so an agent can tell the user. - if let ItemKind::Footage(f) = &i.kind - && let Some(why) = s.footage.problem(f) - { + // Footage (or the proxy in use) that shows no picture although its file is there (a + // codec or profile the decoders lack): why, so an agent can tell the user. + if let Some(why) = s.footage.item_problem(i) { o["problem"] = json!(why); } o diff --git a/crates/engine/src/tests_panels.rs b/crates/engine/src/tests_panels.rs index b442d7c3a..622088745 100644 --- a/crates/engine/src/tests_panels.rs +++ b/crates/engine/src/tests_panels.rs @@ -672,3 +672,47 @@ fn footage_that_cannot_be_decoded_reports_why() { s.execute("footage.open", json!({"item": item.0})).unwrap(); assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]); } + +/// A proxy in use that can't be decoded leaves its item without a picture although the item's +/// own file is fine: `project.summary` and `footage.info` give the proxy's reason, said to be the +/// proxy's, for footage and compositions alike, and nothing once Use Proxy is off. +#[test] +fn a_proxy_in_use_that_cannot_be_decoded_reports_why() { + struct BrokenProxies; + impl FootageSource for BrokenProxies { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn problem(&self, f: &Footage) -> Option { + f.path.ends_with(".avi").then(|| format!("{} isn't supported by the built-in decoder", f.codec)) + } + } + let mut s = Session { footage: Arc::new(BrokenProxies), ..Default::default() }; + let comp = s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let still = Footage { kind: FootageKind::Still, path: "a.png".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("Plate", Default::default(), None, ItemKind::Footage(still)); + let proxy = Footage { + kind: FootageKind::Video, + path: "plate.avi".into(), + codec: "MPEG-4 Part 2".into(), + width: 32, + height: 18, + has_video: true, + ..Default::default() + }; + let want = json!("Proxy: MPEG-4 Part 2 isn't supported by the built-in decoder"); + let problem = |s: &mut Session, id: u64| { + let sum = s.execute("project.summary", json!({})).unwrap(); + sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(id)).unwrap().get("problem").cloned() + }; + for id in [item.0, comp] { + let p = std::sync::Arc::make_mut(&mut s.project); + p.items.get_mut(&ItemId(id)).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy.clone(), enabled: true })); + assert_eq!(problem(&mut s, id), Some(want.clone()), "item {id}"); + std::sync::Arc::make_mut(&mut s.project).items.get_mut(&ItemId(id)).unwrap().proxy.as_mut().unwrap().enabled = false; + assert_eq!(problem(&mut s, id), None, "item {id}: its own file shows, and is fine"); + } + std::sync::Arc::make_mut(&mut s.project).items.get_mut(&item).unwrap().proxy.as_mut().unwrap().enabled = true; + s.execute("footage.open", json!({"item": item.0})).unwrap(); + assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], want); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cfefd02db..b5aada454 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -130,6 +130,18 @@ pub trait FootageSource: Send + Sync { fn problem(&self, _footage: &Footage) -> Option { None } + /// [`Self::problem`] of what `item` shows: its proxy's while Use Proxy shows the proxy + /// instead (the reason then says it is the proxy's), else the footage item's own. A + /// composition has one only through its proxy. + fn item_problem(&self, item: &effectcraft_project::Item) -> Option { + match item.proxy.as_deref().filter(|px| px.enabled && px.footage.has_video) { + Some(px) => self.problem(&px.footage).map(|why| format!("Proxy: {why}")), + None => match &item.kind { + ItemKind::Footage(f) => self.problem(f), + _ => None, + }, + } + } } /// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel` diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 76309a5d0..ab5936765 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -678,12 +678,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("file.useProxy".into(), json!({"item": id.0}))); } } - // Footage that can't be decoded (no decoder for its codec / profile): a warning marker - // at the end of the name cell, the reason on hover. - let problem = match &it.kind { - ItemKind::Footage(f) => app.session.footage.problem(f), - _ => None, - }; + // Footage (or the proxy in use) that can't be decoded (no decoder for its codec / + // profile): a warning marker at the end of the name cell, the reason on hover. + let problem = app.session.footage.item_problem(it); let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list); if let Some(why) = &problem { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 51bab7fda..a50c30e53 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1155,13 +1155,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("viewer.warpBanner", r, &text); } - // Footage that can't be decoded (no decoder for its codec / profile): say why on the layer - // instead of leaving a black picture. + // Footage (or the proxy in use for footage or a precomp) that can't be decoded (no decoder + // for its codec / profile): say why on the layer instead of leaving a black picture. let mut notices = 0; for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) { - let effectcraft_engine::project::LayerSource::Footage { item } = &layer.source else { continue }; - let Some(effectcraft_engine::project::ItemKind::Footage(f)) = app.session.project.item(*item).map(|i| &i.kind) else { continue }; - let Some(why) = app.session.footage.problem(f) else { continue }; + let (effectcraft_engine::project::LayerSource::Footage { item } | effectcraft_engine::project::LayerSource::Comp { item }) = &layer.source else { + continue; + }; + let Some(why) = app.session.project.item(*item).and_then(|i| app.session.footage.item_problem(i)) else { continue }; if notices >= 3 { break; } diff --git a/crates/ui-egui/tests/ui_unreadable_footage.rs b/crates/ui-egui/tests/ui_unreadable_footage.rs index 9b48b9531..b1b12d6d2 100644 --- a/crates/ui-egui/tests/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/ui_unreadable_footage.rs @@ -1,10 +1,11 @@ -//! Footage whose codec the decoders lack says so: a warning marker in the Project panel and a -//! notice on the layer in the Composition viewer, instead of a black picture with no reason. +//! Footage (or the proxy in use for it) whose codec the decoders lack says so: a warning marker in +//! the Project panel and a notice on the layer in the Composition viewer, instead of a black +//! picture with no reason. use std::sync::Arc; use effectcraft_engine::Session; -use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind}; +use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind, Proxy}; use effectcraft_engine::render::FootageSource; use effectcraft_render::Image; use effectcraft_time::Tick; @@ -14,19 +15,19 @@ use serde_json::json; const WHY: &str = "H.264 (High 4:2:2) isn't supported by the built-in decoder: chroma_format_idc 2 (only 4:2:0 is implemented)"; -/// A source that decodes nothing and, for `broken` movies, says why. -struct Stub(bool); +/// A source that decodes nothing and says why for the files `broken` picks. +struct Stub(fn(&Footage) -> bool); impl FootageSource for Stub { fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { None } - fn problem(&self, _: &Footage) -> Option { - self.0.then(|| WHY.to_string()) + fn problem(&self, f: &Footage) -> Option { + (self.0)(f).then(|| WHY.to_string()) } } -fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { +fn harness(broken: fn(&Footage) -> bool, proxy: Option) -> (Harness<'static, EffectcraftApp>, ItemId) { let mut s = Session { footage: Arc::new(Stub(broken)), ..Default::default() }; s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); let footage = Footage { @@ -38,7 +39,11 @@ fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { duration: Tick::from_seconds_f64(2.0), ..Default::default() }; - let clip = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + let p = std::sync::Arc::make_mut(&mut s.project); + let clip = p.add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + if let (Some(item), Some(footage)) = (p.items.get_mut(&clip), proxy) { + item.proxy = Some(Box::new(Proxy { footage, enabled: true })); + } s.execute("layer.addItem", json!({"item": clip.0})).unwrap(); s.execute("project.select", json!({"items": []})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); @@ -48,7 +53,7 @@ fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { #[test] fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { - let (h, clip) = harness(true); + let (h, clip) = harness(|_| true, None); let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); assert!(marker.label.contains("4:2:2"), "{marker:?}"); let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); @@ -57,7 +62,19 @@ fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { #[test] fn readable_footage_has_no_notice() { - let (h, clip) = harness(false); + let (h, clip) = harness(|_| false, None); assert!(h.state().auto.find(&format!("project.item.{}.problem", clip.0)).is_none()); assert!(h.state().auto.query("viewer.footageProblem.").is_empty()); } + +/// The clip's own file is fine, but the proxy in use for it can't be decoded: the item and the +/// layer say why, and that it is the proxy. +#[test] +fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() { + let proxy = Footage { kind: FootageKind::Video, path: "clip_proxy.avi".into(), width: 32, height: 18, has_video: true, ..Default::default() }; + let (h, clip) = harness(|f| f.path.ends_with(".avi"), Some(proxy)); + let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); + assert!(marker.label.starts_with("Proxy: ") && marker.label.contains("4:2:2"), "{marker:?}"); + let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); + assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}"); +} From 0a57bb9fc21a909adb822c017f22a0fd497a840d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 14:02:18 -0400 Subject: [PATCH 018/112] Docs: the AVI variants that don't import yet (#1701) --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f6e8913c4..8ca81f715 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -65,7 +65,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW | | WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects | | MXF (OP1a / OP-Atom) | R/W | yes (FilmCraft's demuxer: AVC, MPEG-2 incl. XDCAM / D-10, VC-3 / DNxHD, ProRes; PCM audio) | none | partial: import (#767), checked against ffmpeg; no MXF output | -| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | +| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; H.264 needs its parameter sets in the chunks (global headers and copies with an `avcC` record don't decode until storytold/filmcraft#2303), and a damaged video index loses the video (storytold/filmcraft#2302); DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | | DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | | BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | | Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus | From 5939735c0b79cad7ec80a386272f1417d8c42cfd Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Sun, 18 Oct 2026 02:25:40 +0000 Subject: [PATCH 019/112] 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 f52ffb63ceb762638fb866a6074e8faae324b9c2 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 18 Oct 2026 12:59:24 -0400 Subject: [PATCH 020/112] Viewer: no proxy notice on a collapsed precomp, which draws its own layers (#1701) --- crates/ui-egui/src/panels/viewer.rs | 28 +++++++++++-------- crates/ui-egui/tests/ui_unreadable_footage.rs | 26 ++++++++++++++++- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 795ac0650..bec2440d6 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -8,8 +8,8 @@ use effectcraft_engine::commands::shape_tool::PaintKind; use effectcraft_engine::effects::puppet::PinKind; use effectcraft_engine::geom::{Mat3, vec2 as gv2}; use effectcraft_engine::project::{Comp, Layer, LayerId, LayerSource}; -use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::three_d::{self, CameraState, View3D}; +use effectcraft_engine::render::{EvalCtx, Renderer}; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; @@ -1194,16 +1194,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Footage (or the proxy in use for footage or a precomp) that can't be decoded (no decoder - // for its codec / profile): say why on the layer instead of leaving a black picture. - let mut notices = 0; - for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) { - let (effectcraft_engine::project::LayerSource::Footage { item } | effectcraft_engine::project::LayerSource::Comp { item }) = &layer.source else { - continue; - }; - let Some(why) = app.session.project.item(*item).and_then(|i| app.session.footage.item_problem(i)) else { continue }; - if notices >= 3 { - break; - } + // for its codec / profile): say why on the layer instead of leaving a black picture. A + // collapsed precomp draws its own layers, never the comp's proxy. + let problems: Vec<(&Layer, String)> = { + let r = Renderer::new(&snap_project, &*app.session.footage, Default::default()); + comp.layers + .iter() + .filter(|l| l.is_active_at(time) && l.switches.video && r.collapsed(&ectx, l).is_none()) + .filter_map(|layer| { + let (LayerSource::Footage { item } | LayerSource::Comp { item }) = &layer.source else { return None }; + Some((layer, snap_project.item(*item).and_then(|i| app.session.footage.item_problem(i))?)) + }) + .take(3) + .collect() + }; + for (notices, (layer, why)) in problems.into_iter().enumerate() { let (m, _) = l2c(&ectx, layer); let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer); let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0)); @@ -1215,7 +1220,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { painter.rect_stroke(r, 3.0, Stroke::new(1.0, t.warning), StrokeKind::Inside); painter.galley(pos2(r.min.x + 12.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text); - notices += 1; } // Free transform box of a path (double-click a path with the Selection tool). diff --git a/crates/ui-egui/tests/ui_unreadable_footage.rs b/crates/ui-egui/tests/ui_unreadable_footage.rs index b1b12d6d2..63e3a75c2 100644 --- a/crates/ui-egui/tests/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/ui_unreadable_footage.rs @@ -45,10 +45,14 @@ fn harness(broken: fn(&Footage) -> bool, proxy: Option) -> (Harness<'st item.proxy = Some(Box::new(Proxy { footage, enabled: true })); } s.execute("layer.addItem", json!({"item": clip.0})).unwrap(); + (open(s), clip) +} + +fn open(mut s: Session) -> Harness<'static, EffectcraftApp> { s.execute("project.select", json!({"items": []})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(4); - (h, clip) + h } #[test] @@ -78,3 +82,23 @@ fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() { let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}"); } + +/// A precomp whose proxy can't be decoded: the layer says so while it renders through the +/// proxy, but not with Collapse Transformations on, which draws the precomp's own layers. +#[test] +fn a_collapsed_precomp_has_no_notice_for_its_proxy() { + for collapse in [false, true] { + let mut s = Session { footage: Arc::new(Stub(|f| f.path.ends_with(".avi"))), ..Default::default() }; + s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 36, "duration": 4})).unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let p = Arc::make_mut(&mut s.project); + let inner = p.items.values_mut().find(|i| i.name == "Inner").unwrap(); + let proxy = Footage { kind: FootageKind::Video, path: "inner_proxy.avi".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + inner.proxy = Some(Box::new(Proxy { footage: proxy, enabled: true })); + let inner = inner.id; + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": "collapse", "value": collapse})).unwrap(); + let h = open(s); + assert_eq!(h.state().auto.query("viewer.footageProblem.").is_empty(), collapse, "collapse {collapse}"); + } +} From ad8b6acb6d6f2c8bf356a1c40958247571c1b058 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:04:24 +0900 Subject: [PATCH 021/112] =?UTF-8?q?i18n(ja):=20panels=20and=20dialogs=20(3?= =?UTF-8?q?00=20rows;=2027%=20=E2=86=92=2039%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel catalog behind the menu bar translated the dialogs and Preferences, but much of the work area was still English: panel tabs, form fields, panel context menus, the tracker and graph controls, text-panel options, the File panel's footers and the credits. Add 300 rows to the Japanese catalog — and the matching Simplified and Traditional Chinese sources the tests keep it in step with (the other languages may lag, an untranslated row falls back to English): - panel tabs (dock.rs / dock_ui.rs titles), the Project panel's columns, footer tooltips and column menu; - form and dialog labels and choice options (the forms the panels open — import, interpret footage, render queue, trackers, text animators…); - panel context menus (timeline layer/property menus, graph editor); - the tracker, graph editor, text-panel, animator and Content-Aware Fill labels; - the Preview panel's transport tooltips, credits table headers and sort options, the composition-settings tabs and the About links; - the engine's text-animator menu labels, added as a data source like the settings pages (they are interface text the panels draw). Strings are wrapped where they are drawn with the existing `tr`/`tr_args` mechanism (the label helpers already ask the catalog, so the data tables need no new ids); the catalog tests keep the three complete languages listing the same sources, reject duplicate or empty rows and keep the placeholder counts. A new test assertion in `japanese_rows_translate_the_panels` pins a sample of this step. The panel catalogs are now 993 Japanese and 995 Chinese rows (the two Chinese catalogs are the complete source list); with the 628-row menu catalogs Japanese is 1,621 of ≈ 4,200 interface strings (39%, up from 27%). Still English: effect parameter names (≈ 1,650 registry names, drawn from the model, not the catalogs), Home-screen literals, tooltips drawn from engine data, engine error messages, and the rows other languages lack. Docs: localization-parity (header, per-language table, overall share, revision history), ROADMAP (language table, progress log, revision history). --- ROADMAP.md | 23 +- crates/ui-egui/src/credits.rs | 8 +- crates/ui-egui/src/dock_ui.rs | 4 +- crates/ui-egui/src/i18n/ui.rs | 913 +++++++++++++++++++++ crates/ui-egui/src/panels/comp_settings.rs | 2 +- crates/ui-egui/src/panels/graph.rs | 1 + crates/ui-egui/src/panels/misc.rs | 3 +- crates/ui-egui/src/panels/project.rs | 12 +- crates/ui-egui/src/panels/tracker.rs | 2 +- docs/localization-parity.md | 29 +- 10 files changed, 962 insertions(+), 35 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1887d7b82..76baa4c7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs; Japanese 27% → 39%) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -80,24 +80,24 @@ Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#b ## Languages -UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)). +UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/localization-parity.md)). | Language | Status | % | |---|---|---:| | English | full | 100% | -| Simplified Chinese | partial (menus, part of the panels) | 29% | -| Spanish | menus only | 16% | +| Simplified Chinese | partial (menus, most of the panels) | 39% | +| Spanish | menus only | 15% | | Hindi | none | 0% | | Arabic | none | 0% | -| French | menus only | 16% | -| Portuguese (Brazil) | partial (menus, part of the panels) | 26% | +| French | menus only | 15% | +| Portuguese (Brazil) | partial (menus, part of the panels) | 25% | | Indonesian | none | 0% | -| Japanese | partial (menus, part of the panels) | 27% | -| German | menus only | 16% | +| Japanese | partial (menus, most of the panels) | 39% | +| German | menus only | 15% | | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%) and Russian (menus only, 16%). No language has had +Also shipped: Traditional Chinese (partial, 39%), Ukrainian (partial, 27%), Czech (partial, 27%) and Russian (menus only, 15%). No language has had native-speaker review. ## Upcoming @@ -117,6 +117,10 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-22: Localization: the panel/dialog catalogs gain 300 rows in Japanese, Simplified + Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, + tracker/graph/text-panel controls, File-panel footers and credits. Japanese is 1,621 of + ≈ 4,200 interface strings (39%), Simplified and Traditional Chinese 1,623 (39%). - 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 @@ -154,6 +158,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs (panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits); language shares recounted (Japanese 27% → 39%, Chinese 29% → 39%, overall 18% → 27%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 77357e876..72e5f30ae 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -246,7 +246,7 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { ui.horizontal_wrapped(|ui| { ui.label(tr("Show")); for m in NameMode::ALL { - let r = ui.selectable_label(v.names == m, m.label()); + let r = ui.selectable_label(v.names == m, tr(m.label())); auto.add(&format!("about.credits.show.{}", m.id()), r.rect, m.label()); if r.clicked() { v.names = m; @@ -254,9 +254,9 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { } ui.separator(); ui.label(tr("Sort")); - let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(tr(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { - let r = ui.selectable_label(v.key == k, k.label().0); + let r = ui.selectable_label(v.key == k, tr(k.label().0)); auto.add(&format!("about.credits.sort.{}", k.id()), r.rect, k.label().0); if r.clicked() { v.key = k; @@ -309,7 +309,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, auto: &mut Regi egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0); + let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(tr(k.label().0)); auto.add(&format!("about.credits.header.{}", k.id()), r.rect, k.label().0); if r.clicked() { if v.key == k { diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index a92d376e9..fb4a2d2e0 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -206,7 +206,7 @@ impl EffectcraftApp { let titles = self.tab_titles(); let decos = self.tab_decos(); let docs = self.tab_docs(); - let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); + let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| crate::i18n::tr(p.title()).to_string()); let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs }; // Where each group's tabs are (tab drags show where a tab will land between them). let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![]; @@ -570,7 +570,7 @@ impl EffectcraftApp { } } // Ghost of the dragged tab. - let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); + let g = painter.layout_no_wrap(crate::i18n::tr(p.title()).to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0)); painter.rect_filled(gr, 4.0, t.focus.gamma_multiply(0.85)); painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 434f2b79c..e27945f70 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -825,6 +825,309 @@ const JAPANESE: &[(&str, &str)] = &[ ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), + ("Force alphabetical order", "アルファベット順を強制"), + ("Frame rate (fps)", "フレームレート (fps)"), + ("Import Kind", "読み込みの種類"), + ("Delay (s)", "遅延 (秒)"), + ("Amount (%)", "量 (%)"), + ("Cascade (farther pins trail longer)", "カスケード (離れたピンほど長く遅れる)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "下書き変形を使用"), + ("Show Mesh", "メッシュを表示"), + ("Scale X (%)", "X 方向の拡大率 (%)"), + ("Scale Y (%)", "Y 方向の拡大率 (%)"), + ("Rotation (degrees)", "回転 (度)"), + ("Move X", "X 移動"), + ("Move Y", "Y 移動"), + ("Position (pixels)", "位置 (ピクセル)"), + ("Position (percent of the composition)", "位置 (コンポジションに対する割合)"), + ("Use Dimensions From", "使用するサイズの基準"), + ("Still Duration (seconds)", "静止画のデュレーション (秒)"), + ("Add to Render Queue", "レンダーキューに追加"), + ("Sequence Layers", "レイヤーをシーケンス化"), + ("Duration (seconds)", "デュレーション (秒)"), + ("Matte color (premultiplied)", "マットの色 (プリマルチプライ)"), + ("Invert Alpha", "アルファを反転"), + ("Assume this frame rate", "このフレームレートとみなす"), + ("Separate Fields", "フィールドを分離"), + ("Pixel Aspect Ratio", "ピクセル縦横比"), + ("Loop (times)", "ループ (回)"), + ("Assign Profile", "プロファイルを割り当て"), + ("Preserve RGB", "RGB を保持"), + ("Interpret As Linear Light", "リニア光として解釈"), + ("Start Timecode", "開始タイムコード"), + ("Override Start", "開始値を上書き"), + ("Time display style", "タイムコード表示"), + ("Color engine", "カラーエンジン"), + ("Working space", "作業用スペース"), + ("Working gamma", "作業用ガンマ"), + ("Linearize working space", "作業用スペースをリニア化"), + ("Blend colors using 1.0 gamma", "1.0 ガンマで色を混合"), + ("Output color space", "出力カラースペース"), + ("Video rendering and effects", "ビデオのレンダリングとエフェクト"), + ("Time Span", "時間範囲"), + ("Blur (pixels before auto-trace)", "ブラー (自動トレース前のピクセル)"), + ("Tolerance (pixels)", "許容値 (ピクセル)"), + ("Threshold (%)", "しきい値 (%)"), + ("Minimum Area (pixels)", "最小領域 (ピクセル)"), + ("Corner Roundness (%)", "角の丸み (%)"), + ("Apply To New Layer", "新しいレイヤーに適用"), + ("Sensitivity threshold (0–1, lower finds more)", "感度のしきい値 (0~1、低いほど多く検出)"), + ("Time field (empty: automatic)", "時間フィールド (空欄: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ビデオ開始 (ISO 日時、hh:mm:ss、タイムコード、空欄: ファイルの日付)"), + ("First sample at (comp seconds)", "最初のサンプル位置 (コンポジション秒)"), + ("Frame rate", "フレームレート"), + ("Color (#rrggbb)", "カラー (#rrggbb)"), + ("Embed footage files", "フッテージを埋め込む"), + ("Embed up to (MB)", "埋め込み上限 (MB)"), + ("Orient Along Path", "パスに沿って向きを設定"), + ("Orient Towards Camera", "カメラの方向に向ける"), + ("Orient Towards Point of Interest", "対象点の方向に向ける"), + ("Single Composition", "単一コンポジション"), + ("Multiple Compositions", "複数のコンポジション"), + ("Dissolve Front Layer", "前面のレイヤーをディゾルブ"), + ("Cross Dissolve Front and Back Layers", "前後のレイヤーをクロスディゾルブ"), + ("Interpret Straight - Unmatted", "ストレート (マットなし) として解釈"), + ("Interpret Premultiplied - Matted With Color", "プリマルチプライ (色でマット) として解釈"), + ("Upper Field First", "偶数フィールド優先"), + ("Lower Field First", "奇数フィールド優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "ソースファイルのタイムコードを使用"), + ("Feet + Frames (35mm)", "フィート + フレーム (35mm)"), + ("Feet + Frames (16mm)", "フィート + フレーム (16mm)"), + ("Current Frame", "現在のフレーム"), + ("Work Area", "作業領域"), + ("Create Markers", "マーカーを作成"), + ("Split Layers", "レイヤーを分割"), + ("Split and Precompose", "分割してプリコンポーズ"), + ("All layers", "すべてのレイヤー"), + ("None (leave them out)", "なし (除外)"), + ("Nested sequences", "ネストしたシーケンス"), + ("Solid…", "平面…"), + ("Light…", "ライト…"), + ("Camera…", "カメラ…"), + ("Null Object", "ヌルオブジェクト"), + ("Shape Layer", "シェイプレイヤー"), + ("Adjustment Layer", "調整レイヤー"), + ("Layer Settings…", "レイヤー設定…"), + ("Create Shapes from Vector Layer", "ベクターレイヤーからシェイプを作成"), + ("Create Shapes from Text", "テキストからシェイプを作成"), + ("Create Masks from Text", "テキストからマスクを作成"), + ("New Mask", "新しいマスク"), + ("Center In View", "ビュー内で中央揃え"), + ("Fit to Comp", "コンポジションに合わせる"), + ("Flip Horizontal", "水平方向に反転"), + ("Flip Vertical", "垂直方向に反転"), + ("Enable Time Remapping", "時間リマップを有効化"), + ("Time-Reverse Layer", "レイヤーを時間反転"), + ("Freeze Frame", "フレームを固定"), + ("Freeze On Last Frame", "最後のフレームで固定"), + ("Add Marker", "マーカーを追加"), + ("Bring Layer to Front", "レイヤーを最前面へ"), + ("Bring Layer Forward", "レイヤーを前面へ"), + ("Send Layer Backward", "レイヤーを背面へ"), + ("Send Layer to Back", "レイヤーを最背面へ"), + ("Pre-compose…", "プリコンポーズ…"), + ("Split Layer", "レイヤーを分割"), + ("Layer Style Options...", "レイヤースタイルのオプション..."), + ("Convert to Editable Styles", "編集可能なスタイルに変換"), + ("Show All", "すべて表示"), + ("Remove All", "すべて削除"), + ("Separate Dimensions", "次元を分離"), + ("Add Property to Essential Graphics", "エッセンシャルグラフィックスにプロパティを追加"), + ("Continuous Bezier", "連続ベジェ"), + ("Auto Bezier", "自動ベジェ"), + ("Lock To Time", "時間に固定"), + ("Rove Across Time", "時間をまたいで移動"), + ("Anchor Point", "アンカーポイント"), + ("All Transform Properties", "すべてのトランスフォームプロパティ"), + ("Line Anchor", "行のアンカー"), + ("Line Spacing", "行間"), + ("Character Offset", "文字オフセット"), + ("Character Value", "文字値"), + ("Fill Color: RGB", "塗りの色: RGB"), + ("Fill Color: Hue", "塗りの色: 色相"), + ("Fill Color: Saturation", "塗りの色: 彩度"), + ("Fill Color: Brightness", "塗りの色: 明るさ"), + ("Fill Color: Opacity", "塗りの色: 不透明度"), + ("Stroke Color: RGB", "線の色: RGB"), + ("Stroke Color: Hue", "線の色: 色相"), + ("Stroke Color: Saturation", "線の色: 彩度"), + ("Stroke Color: Brightness", "線の色: 明るさ"), + ("Stroke Color: Opacity", "線の色: 不透明度"), + ("Stroke Width", "線幅"), + ("Front: Color", "前面: 色"), + ("Bevel: Color", "ベベル: 色"), + ("Side: Color", "側面: 色"), + ("Back: Color", "背面: 色"), + ("Effect Controls", "エフェクトコントロール"), + ("Effects & Presets", "エフェクト&プリセット"), + ("Motion Sketch", "モーションスケッチ"), + ("Render Queue", "レンダーキュー"), + ("Mask Interpolation", "マスク補間"), + ("Script Console", "スクリプトコンソール"), + ("Essential Graphics", "エッセンシャルグラフィックス"), + ("ScriptUI Panel", "ScriptUI パネル"), + ("Lumetri Scopes", "Lumetri スコープ"), + ("Media Browser", "メディアブラウザー"), + ("Content-Aware Fill", "コンテンツに応じた塗り"), + ("Create Nulls From Paths", "パスからヌルを作成"), + ("VR Comp Editor", "VR コンポジションエディター"), + ("Media Duration", "メディアの長さ"), + ("Frame Rate", "フレームレート"), + ("File Path", "ファイルパス"), + ("Interpret Footage", "フッテージの解釈"), + ("Create a new Folder", "新しいフォルダーを作成"), + ("Create a new Composition", "新しいコンポジションを作成"), + ("First Frame", "最初のフレーム"), + ("Previous Frame", "前のフレーム"), + ("Next Frame", "次のフレーム"), + ("Last Frame", "最後のフレーム"), + ("Name (A–Z)", "名前 (A–Z)"), + ("Merged PRs", "マージ済み PR"), + ("Lines added", "追加行数"), + ("Lines deleted", "削除行数"), + ("Line delta", "行数の差分"), + ("Binary assets added", "追加バイナリ"), + ("Binary assets removed", "削除バイナリ"), + ("First commit", "最初のコミット"), + ("Last commit", "最後のコミット"), + ("Display name", "表示名"), + ("Real name", "本名"), + ("ArtCraft website", "ArtCraft のウェブサイト"), + ("Source code on GitHub", "GitHub のソースコード"), + ("3D Renderer", "3D レンダラー"), + ("Preset Name", "プリセット名"), + ("Save Preset…", "プリセットを保存…"), + ("Delete Preset", "プリセットを削除"), + ("Lock Aspect Ratio", "縦横比を固定"), + ("Frame Rate presets", "フレームレートのプリセット"), + ("Background Color", "背景色"), + ("Shutter Angle", "シャッター角度"), + ("Shutter Phase", "シャッター位相"), + ("Samples Per Frame", "フレームあたりのサンプル数"), + ("Adaptive Sample Limit", "適応サンプル数の上限"), + ("X and Y", "X と Y"), + ("X only", "X のみ"), + ("Y only", "Y のみ"), + ("Track Camera", "カメラをトラッキング"), + ("Warp Stabilizer", "ワープスタビライザー"), + ("Track Motion", "モーションをトラッキング"), + ("Stabilize Motion", "モーションを安定化"), + ("Motion Source:", "モーションソース:"), + ("Current Track:", "現在のトラック:"), + ("Track Type:", "トラックの種類:"), + ("Motion Target:", "モーションの適用先:"), + ("Track Mask:", "トラックマスク:"), + ("Analyze forward", "前方を解析"), + ("Analyze backward", "後方を解析"), + ("Continue Tracking", "トラッキングを続行"), + ("Stop Tracking", "トラッキングを停止"), + ("Adapt Feature", "特徴を適応"), + ("Extrapolate Motion", "モーションを外挿"), + ("Attach Point", "アタッチポイント"), + ("Track Name", "トラック名"), + ("Motion Tracker Options", "モーショントラッカーのオプション"), + ("Motion Tracker Apply Options", "モーショントラッカーの適用オプション"), + ("Edit Value Graph", "値グラフを編集"), + ("Edit Speed Graph", "速度グラフを編集"), + ("Show Reference Graph", "参照グラフを表示"), + ( + "Auto-Select Graph Type (speed graph for spatial properties, else value graph)", + "グラフの種類を自動選択 (空間プロパティは速度グラフ、それ以外は値グラフ)", + ), + ("Auto-zoom graph height", "グラフの高さを自動ズーム"), + ("Fit all graphs to view", "すべてのグラフをビューに合わせる"), + ("Fit selection to view", "選択項目をビューに合わせる"), + ("Snap (key drags snap to the current time and other keys)", "スナップ (キーのドラッグを現在の時間や他のキーに合わせる)"), + ("Show Transform Box when multiple keys are selected", "複数のキーを選択したときにトランスフォームボックスを表示"), + ("Separate Dimensions (Position)", "次元を分離 (位置)"), + ("Convert selected keyframes to Hold", "選択したキーフレームをホールドに変換"), + ("Convert selected keyframes to Linear", "選択したキーフレームをリニアに変換"), + ("Convert selected keyframes to Auto Bezier", "選択したキーフレームを自動ベジェに変換"), + ("Easy Ease (F9)", "イージーイーズ (F9)"), + ("Easy Ease In (Shift+F9)", "イージーイーズイン (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "イージーイーズアウト (Ctrl+Shift+F9)"), + ("Spatial Path", "空間パス"), + ("Temporal Path", "時間パス"), + ("All the Same", "すべて同じ"), + ("All Independently", "すべて個別"), + ("Noise Type:", "ノイズの種類:"), + ("Apply To:", "適用先:"), + ("Temporal Graph", "時間グラフ"), + ("Keyframe Rate:", "キーフレームレート:"), + ("Capture speed at:", "速度の記録位置:"), + ("Start Capture", "記録を開始"), + ("Cancel Capture", "記録をキャンセル"), + ("Small Caps", "スモールキャップ"), + ("All Small Caps", "すべてスモールキャップ"), + ("Superscript / Superior", "上付き文字"), + ("Subscript / Inferior", "下付き文字"), + ("Standard Ligatures", "標準合字"), + ("Discretionary Ligatures", "任意合字"), + ("Contextual Alternates", "状況依存の代替字形"), + ("Stylistic Alternates", "スタイル代替字形"), + ("Titling Alternates", "タイトル用代替字形"), + ("Default Figure Style", "既定の数字スタイル"), + ("Tabular Lining", "等幅ライニング"), + ("Proportional Oldstyle", "プロポーショナルオールドスタイル"), + ("Proportional Lining", "プロポーショナルライニング"), + ("Tabular Oldstyle", "等幅オールドスタイル"), + ("Fill Over Stroke", "線の上に塗り"), + ("Stroke Over Fill", "塗りの上に線"), + ("Stroke order", "線の順序"), + ("Single-line Composer", "単一行コンポーザー"), + ("Every-line Composer", "全行コンポーザー"), + ("Text direction", "テキストの方向"), + ("Recently Used", "最近使用した項目"), + ("User Presets", "ユーザープリセット"), + ("Remove from Favorites", "お気に入りから削除"), + ("Show Animation Presets", "アニメーションプリセットを表示"), + (" Refresh List", " リストを更新"), + (" Clear Recently Used", " 最近使用した項目を消去"), + ("Show 32 bpc Effects Only", "32 bpc のエフェクトのみ表示"), + ("Show GPU Accelerated Effects Only", "GPU アクセラレーションのエフェクトのみ表示"), + ("Show Effects for All Color Depths", "すべての色深度のエフェクトを表示"), + ("* Animation Presets", "* アニメーションプリセット"), + ("Select a layer to apply to", "適用するレイヤーを選択してください"), + ("Search effects and presets", "エフェクトとプリセットを検索"), + ("Alpha Expansion:", "アルファの拡張:"), + ("Fill Method:", "塗りの方法:"), + ("Edge Blend", "エッジのブレンド"), + ("Entire Duration", "全体のデュレーション"), + ("Reference Frame:", "参照フレーム:"), + ("Use Current Time", "現在の時間を使用"), + ("Generate Fill Layer", "塗りレイヤーを生成"), + ("Lighting Correction:", "ライティングの補正:"), + ("Create Text and Camera", "テキストとカメラを作成"), + ("Create Solid and Camera", "平面とカメラを作成"), + ("Create Null and Camera", "ヌルとカメラを作成"), + ("Create Shadow Catcher, Camera and Light", "シャドウキャッチャー、カメラ、ライトを作成"), + ("Unable to solve camera", "カメラを解析できません"), + ("Waiting to solve", "解析待ち"), + ("Create Camera", "カメラを作成"), + ("track point", "トラックポイント"), + ("Ruler zero point", "ルーラーの原点"), + ("Vertical ruler", "垂直ルーラー"), + ("Horizontal ruler", "水平ルーラー"), + ("Viewer controls", "ビューアーのコントロール"), + ("Resolution/Down Sample Factor", "解像度/ダウンサンプル係数"), + ("Adjust Exposure", "露出を調整"), + ("Snapshot taken", "スナップショットを撮影しました"), + ("Fast Previews", "高速プレビュー"), + ("3D View", "3D ビュー"), + ("Current time", "現在の時間"), + ("Layer Transform", "レイヤーのトランスフォーム"), + ("Reset Transform", "トランスフォームをリセット"), + ("Text Animation", "テキストアニメーション"), + ("Text Animation Presets", "テキストアニメーションのプリセット"), + ("EffectCraft on getartcraft.com", "getartcraft.com の EffectCraft"), + ("Small Screen", "小さい画面"), + ("Tool Creates Shape", "ツールでシェイプを作成"), + ("Tool Creates Mask", "ツールでマスクを作成"), + ("Blend Mode", "描画モード"), + ("Show mesh", "メッシュを表示"), + ("Record Options...", "記録オプション..."), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -1565,6 +1868,306 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "总偏移"), ("Per Layer", "每个图层"), ("Track", "跟踪"), + ("Force alphabetical order", "强制按字母顺序"), + ("Frame rate (fps)", "帧速率 (fps)"), + ("Import Kind", "导入类型"), + ("Delay (s)", "延迟 (秒)"), + ("Amount (%)", "数量 (%)"), + ("Cascade (farther pins trail longer)", "级联 (越远的图钉滞后越长)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿变形"), + ("Show Mesh", "显示网格"), + ("Scale X (%)", "X 缩放 (%)"), + ("Scale Y (%)", "Y 缩放 (%)"), + ("Rotation (degrees)", "旋转 (度)"), + ("Move X", "移动 X"), + ("Move Y", "移动 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸来源"), + ("Still Duration (seconds)", "静止图像持续时间 (秒)"), + ("Add to Render Queue", "添加到渲染队列"), + ("Sequence Layers", "序列图层"), + ("Duration (seconds)", "持续时间 (秒)"), + ("Matte color (premultiplied)", "遮罩颜色 (预乘)"), + ("Invert Alpha", "反转 Alpha"), + ("Assume this frame rate", "按此帧速率处理"), + ("Separate Fields", "分离场"), + ("Pixel Aspect Ratio", "像素长宽比"), + ("Loop (times)", "循环 (次)"), + ("Assign Profile", "指定配置文件"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解释为线性光"), + ("Start Timecode", "开始时间码"), + ("Override Start", "覆盖开始"), + ("Time display style", "时间显示样式"), + ("Color engine", "颜色引擎"), + ("Working space", "工作空间"), + ("Working gamma", "工作伽马"), + ("Linearize working space", "线性化工作空间"), + ("Blend colors using 1.0 gamma", "使用 1.0 伽马混合颜色"), + ("Output color space", "输出颜色空间"), + ("Video rendering and effects", "视频渲染与效果"), + ("Time Span", "时间范围"), + ("Blur (pixels before auto-trace)", "模糊 (自动描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "阈值 (%)"), + ("Minimum Area (pixels)", "最小区域 (像素)"), + ("Corner Roundness (%)", "角圆度 (%)"), + ("Apply To New Layer", "应用到新图层"), + ("Sensitivity threshold (0–1, lower finds more)", "灵敏度阈值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "时间字段 (为空: 自动)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "视频开始 (ISO 日期时间、hh:mm:ss、时间码;为空: 文件日期)"), + ("First sample at (comp seconds)", "首个采样位置 (合成秒)"), + ("Frame rate", "帧速率"), + ("Color (#rrggbb)", "颜色 (#rrggbb)"), + ("Embed footage files", "嵌入素材文件"), + ("Embed up to (MB)", "嵌入上限 (MB)"), + ("Orient Along Path", "沿路径定向"), + ("Orient Towards Camera", "朝向摄像机"), + ("Orient Towards Point of Interest", "朝向目标点"), + ("Single Composition", "单个合成"), + ("Multiple Compositions", "多个合成"), + ("Dissolve Front Layer", "溶解前层"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前后层"), + ("Interpret Straight - Unmatted", "解释为直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解释为预乘 (以颜色遮罩)"), + ("Upper Field First", "上场优先"), + ("Lower Field First", "下场优先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用源文件时间码"), + ("Feet + Frames (35mm)", "英尺 + 帧 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 帧 (16mm)"), + ("Current Frame", "当前帧"), + ("Work Area", "工作区域"), + ("Create Markers", "创建标记"), + ("Split Layers", "拆分图层"), + ("Split and Precompose", "拆分并预合成"), + ("All layers", "所有图层"), + ("None (leave them out)", "无 (不含)"), + ("Nested sequences", "嵌套序列"), + ("Solid…", "纯色…"), + ("Light…", "灯光…"), + ("Camera…", "摄像机…"), + ("Null Object", "空对象"), + ("Shape Layer", "形状图层"), + ("Adjustment Layer", "调整图层"), + ("Layer Settings…", "图层设置…"), + ("Create Shapes from Vector Layer", "从矢量图层创建形状"), + ("Create Shapes from Text", "从文本创建形状"), + ("Create Masks from Text", "从文本创建遮罩"), + ("New Mask", "新建遮罩"), + ("Center In View", "在视图中居中"), + ("Fit to Comp", "适应合成"), + ("Flip Horizontal", "水平翻转"), + ("Flip Vertical", "垂直翻转"), + ("Enable Time Remapping", "启用时间重映射"), + ("Time-Reverse Layer", "时间反向图层"), + ("Freeze Frame", "冻结帧"), + ("Freeze On Last Frame", "在最后一帧冻结"), + ("Add Marker", "添加标记"), + ("Bring Layer to Front", "图层置顶"), + ("Bring Layer Forward", "图层上移一层"), + ("Send Layer Backward", "图层下移一层"), + ("Send Layer to Back", "图层置底"), + ("Pre-compose…", "预合成…"), + ("Split Layer", "拆分图层"), + ("Layer Style Options...", "图层样式选项..."), + ("Convert to Editable Styles", "转换为可编辑样式"), + ("Show All", "全部显示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分离维度"), + ("Add Property to Essential Graphics", "将属性添加到基本图形"), + ("Continuous Bezier", "连续贝塞尔"), + ("Auto Bezier", "自动贝塞尔"), + ("Lock To Time", "锁定到时间"), + ("Rove Across Time", "跨时间漫游"), + ("Anchor Point", "锚点"), + ("All Transform Properties", "所有变换属性"), + ("Line Anchor", "行锚点"), + ("Line Spacing", "行距"), + ("Character Offset", "字符偏移"), + ("Character Value", "字符值"), + ("Fill Color: RGB", "填充颜色: RGB"), + ("Fill Color: Hue", "填充颜色: 色相"), + ("Fill Color: Saturation", "填充颜色: 饱和度"), + ("Fill Color: Brightness", "填充颜色: 亮度"), + ("Fill Color: Opacity", "填充颜色: 不透明度"), + ("Stroke Color: RGB", "描边颜色: RGB"), + ("Stroke Color: Hue", "描边颜色: 色相"), + ("Stroke Color: Saturation", "描边颜色: 饱和度"), + ("Stroke Color: Brightness", "描边颜色: 亮度"), + ("Stroke Color: Opacity", "描边颜色: 不透明度"), + ("Stroke Width", "描边宽度"), + ("Front: Color", "前面: 颜色"), + ("Bevel: Color", "斜面: 颜色"), + ("Side: Color", "侧面: 颜色"), + ("Back: Color", "背面: 颜色"), + ("Effect Controls", "效果控件"), + ("Effects & Presets", "效果和预设"), + ("Motion Sketch", "运动草图"), + ("Render Queue", "渲染队列"), + ("Mask Interpolation", "遮罩插值"), + ("Script Console", "脚本控制台"), + ("Essential Graphics", "基本图形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒体浏览器"), + ("Content-Aware Fill", "内容识别填充"), + ("Create Nulls From Paths", "从路径创建空对象"), + ("VR Comp Editor", "VR 合成编辑器"), + ("Media Duration", "媒体持续时间"), + ("Frame Rate", "帧速率"), + ("File Path", "文件路径"), + ("Interpret Footage", "解释素材"), + ("Create a new Folder", "新建文件夹"), + ("Create a new Composition", "新建合成"), + ("First Frame", "第一帧"), + ("Previous Frame", "上一帧"), + ("Next Frame", "下一帧"), + ("Last Frame", "最后一帧"), + ("Name (A–Z)", "名称 (A–Z)"), + ("Merged PRs", "已合并 PR"), + ("Lines added", "添加的行数"), + ("Lines deleted", "删除的行数"), + ("Line delta", "行数差异"), + ("Binary assets added", "添加的二进制资源"), + ("Binary assets removed", "删除的二进制资源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "显示名称"), + ("Real name", "真实姓名"), + ("ArtCraft website", "ArtCraft 网站"), + ("Source code on GitHub", "GitHub 上的源代码"), + ("3D Renderer", "3D 渲染器"), + ("Preset Name", "预设名称"), + ("Save Preset…", "保存预设…"), + ("Delete Preset", "删除预设"), + ("Lock Aspect Ratio", "锁定长宽比"), + ("Frame Rate presets", "帧速率预设"), + ("Background Color", "背景颜色"), + ("Shutter Angle", "快门角度"), + ("Shutter Phase", "快门相位"), + ("Samples Per Frame", "每帧采样数"), + ("Adaptive Sample Limit", "自适应采样上限"), + ("X and Y", "X 和 Y"), + ("X only", "仅 X"), + ("Y only", "仅 Y"), + ("Track Camera", "跟踪摄像机"), + ("Warp Stabilizer", "变形稳定器"), + ("Track Motion", "跟踪运动"), + ("Stabilize Motion", "稳定运动"), + ("Motion Source:", "运动源:"), + ("Current Track:", "当前跟踪:"), + ("Track Type:", "跟踪类型:"), + ("Motion Target:", "运动目标:"), + ("Track Mask:", "跟踪遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向后分析"), + ("Continue Tracking", "继续跟踪"), + ("Stop Tracking", "停止跟踪"), + ("Adapt Feature", "自适应特征"), + ("Extrapolate Motion", "外推运动"), + ("Attach Point", "附加点"), + ("Track Name", "跟踪名称"), + ("Motion Tracker Options", "运动跟踪器选项"), + ("Motion Tracker Apply Options", "运动跟踪器应用选项"), + ("Edit Value Graph", "编辑值图表"), + ("Edit Speed Graph", "编辑速度图表"), + ("Show Reference Graph", "显示参考图表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自动选择图表类型 (空间属性用速度图表,否则用值图表)"), + ("Auto-zoom graph height", "自动缩放图表高度"), + ("Fit all graphs to view", "使所有图表适合视图"), + ("Fit selection to view", "使所选内容适合视图"), + ("Snap (key drags snap to the current time and other keys)", "吸附 (关键帧拖动时吸附到当前时间和其他关键帧)"), + ("Show Transform Box when multiple keys are selected", "选择多个关键帧时显示变换框"), + ("Separate Dimensions (Position)", "分离维度 (位置)"), + ("Convert selected keyframes to Hold", "将所选关键帧转换为定格"), + ("Convert selected keyframes to Linear", "将所选关键帧转换为线性"), + ("Convert selected keyframes to Auto Bezier", "将所选关键帧转换为自动贝塞尔"), + ("Easy Ease (F9)", "缓动 (F9)"), + ("Easy Ease In (Shift+F9)", "缓入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "缓出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空间路径"), + ("Temporal Path", "时间路径"), + ("All the Same", "全部相同"), + ("All Independently", "全部独立"), + ("Noise Type:", "噪波类型:"), + ("Apply To:", "应用到:"), + ("Temporal Graph", "时间图表"), + ("Keyframe Rate:", "关键帧速率:"), + ("Capture speed at:", "速度捕获位置:"), + ("Start Capture", "开始捕获"), + ("Cancel Capture", "取消捕获"), + ("Small Caps", "小型大写字母"), + ("All Small Caps", "全部小型大写字母"), + ("Superscript / Superior", "上标"), + ("Subscript / Inferior", "下标"), + ("Standard Ligatures", "标准连字"), + ("Discretionary Ligatures", "可选连字"), + ("Contextual Alternates", "上下文替换"), + ("Stylistic Alternates", "样式替换"), + ("Titling Alternates", "标题替换"), + ("Default Figure Style", "默认数字样式"), + ("Tabular Lining", "等宽线条"), + ("Proportional Oldstyle", "比例旧式"), + ("Proportional Lining", "比例线条"), + ("Tabular Oldstyle", "等宽旧式"), + ("Fill Over Stroke", "填充在描边之上"), + ("Stroke Over Fill", "描边在填充之上"), + ("Stroke order", "描边顺序"), + ("Single-line Composer", "单行书写器"), + ("Every-line Composer", "逐行书写器"), + ("Text direction", "文本方向"), + ("Recently Used", "最近使用"), + ("User Presets", "用户预设"), + ("Remove from Favorites", "从收藏夹移除"), + ("Show Animation Presets", "显示动画预设"), + (" Refresh List", " 刷新列表"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "仅显示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "仅显示 GPU 加速效果"), + ("Show Effects for All Color Depths", "显示所有颜色深度的效果"), + ("* Animation Presets", "* 动画预设"), + ("Select a layer to apply to", "请选择要应用到的图层"), + ("Search effects and presets", "搜索效果和预设"), + ("Alpha Expansion:", "Alpha 扩展:"), + ("Fill Method:", "填充方式:"), + ("Edge Blend", "边缘混合"), + ("Entire Duration", "整个持续时间"), + ("Reference Frame:", "参考帧:"), + ("Use Current Time", "使用当前时间"), + ("Generate Fill Layer", "生成填充图层"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "创建文本和摄像机"), + ("Create Solid and Camera", "创建纯色和摄像机"), + ("Create Null and Camera", "创建空对象和摄像机"), + ("Create Shadow Catcher, Camera and Light", "创建阴影捕捉器、摄像机和灯光"), + ("Unable to solve camera", "无法求解摄像机"), + ("Waiting to solve", "等待求解"), + ("Create Camera", "创建摄像机"), + ("track point", "跟踪点"), + ("Ruler zero point", "标尺零点"), + ("Vertical ruler", "垂直标尺"), + ("Horizontal ruler", "水平标尺"), + ("Viewer controls", "查看器控件"), + ("Resolution/Down Sample Factor", "分辨率/降采样系数"), + ("Adjust Exposure", "调整曝光"), + ("Snapshot taken", "已拍摄快照"), + ("Fast Previews", "快速预览"), + ("3D View", "3D 视图"), + ("Current time", "当前时间"), + ("Layer Transform", "图层变换"), + ("Reset Transform", "重置变换"), + ("Text Animation", "文本动画"), + ("Text Animation Presets", "文本动画预设"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小屏幕"), + ("Tool Creates Shape", "工具创建形状"), + ("Tool Creates Mask", "工具创建遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "显示网格"), + ("Record Options...", "记录选项..."), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -2300,6 +2903,306 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "總偏移"), ("Per Layer", "每個圖層"), ("Track", "追蹤"), + ("Force alphabetical order", "強制依字母順序"), + ("Frame rate (fps)", "幀率 (fps)"), + ("Import Kind", "匯入類型"), + ("Delay (s)", "延遲 (秒)"), + ("Amount (%)", "數量 (%)"), + ("Cascade (farther pins trail longer)", "階層 (越遠的圖釘延遲越長)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿變形"), + ("Show Mesh", "顯示網格"), + ("Scale X (%)", "X 縮放 (%)"), + ("Scale Y (%)", "Y 縮放 (%)"), + ("Rotation (degrees)", "旋轉 (度)"), + ("Move X", "移動 X"), + ("Move Y", "移動 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸來源"), + ("Still Duration (seconds)", "靜態影像持續時間 (秒)"), + ("Add to Render Queue", "新增至轉譯佇列"), + ("Sequence Layers", "序列圖層"), + ("Duration (seconds)", "持續時間 (秒)"), + ("Matte color (premultiplied)", "遮罩顏色 (預乘)"), + ("Invert Alpha", "反轉 Alpha"), + ("Assume this frame rate", "以此幀率為準"), + ("Separate Fields", "分離場"), + ("Pixel Aspect Ratio", "像素長寬比"), + ("Loop (times)", "循環 (次)"), + ("Assign Profile", "指定設定檔"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解譯為線性光"), + ("Start Timecode", "開始時間碼"), + ("Override Start", "覆寫開始"), + ("Time display style", "時間顯示樣式"), + ("Color engine", "色彩引擎"), + ("Working space", "工作空間"), + ("Working gamma", "工作 Gamma"), + ("Linearize working space", "線性化工作空間"), + ("Blend colors using 1.0 gamma", "使用 1.0 Gamma 混合顏色"), + ("Output color space", "輸出色彩空間"), + ("Video rendering and effects", "視訊轉譯與效果"), + ("Time Span", "時間範圍"), + ("Blur (pixels before auto-trace)", "模糊 (自動描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "閾值 (%)"), + ("Minimum Area (pixels)", "最小區域 (像素)"), + ("Corner Roundness (%)", "角圓度 (%)"), + ("Apply To New Layer", "套用至新圖層"), + ("Sensitivity threshold (0–1, lower finds more)", "靈敏度閾值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "時間欄位 (空白: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "視訊開始 (ISO 日期時間、hh:mm:ss、時間碼;空白: 檔案日期)"), + ("First sample at (comp seconds)", "首個取樣位置 (合成秒)"), + ("Frame rate", "幀率"), + ("Color (#rrggbb)", "顏色 (#rrggbb)"), + ("Embed footage files", "內嵌素材檔案"), + ("Embed up to (MB)", "內嵌上限 (MB)"), + ("Orient Along Path", "沿路徑定向"), + ("Orient Towards Camera", "朝向攝影機"), + ("Orient Towards Point of Interest", "朝向目標點"), + ("Single Composition", "單一合成"), + ("Multiple Compositions", "多個合成"), + ("Dissolve Front Layer", "溶解前層"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前後層"), + ("Interpret Straight - Unmatted", "解譯為直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解譯為預乘 (以顏色遮罩)"), + ("Upper Field First", "上場優先"), + ("Lower Field First", "下場優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用來源檔案時間碼"), + ("Feet + Frames (35mm)", "英尺 + 幀 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 幀 (16mm)"), + ("Current Frame", "目前幀"), + ("Work Area", "工作區域"), + ("Create Markers", "建立標記"), + ("Split Layers", "分割圖層"), + ("Split and Precompose", "分割並預合成"), + ("All layers", "所有圖層"), + ("None (leave them out)", "無 (不含)"), + ("Nested sequences", "巢狀序列"), + ("Solid…", "純色…"), + ("Light…", "燈光…"), + ("Camera…", "攝影機…"), + ("Null Object", "空物件"), + ("Shape Layer", "形狀圖層"), + ("Adjustment Layer", "調整圖層"), + ("Layer Settings…", "圖層設定…"), + ("Create Shapes from Vector Layer", "從向量圖層建立形狀"), + ("Create Shapes from Text", "從文字建立形狀"), + ("Create Masks from Text", "從文字建立遮罩"), + ("New Mask", "新增遮罩"), + ("Center In View", "在視圖中置中"), + ("Fit to Comp", "符合合成"), + ("Flip Horizontal", "水平翻轉"), + ("Flip Vertical", "垂直翻轉"), + ("Enable Time Remapping", "啟用時間重對應"), + ("Time-Reverse Layer", "時間反轉圖層"), + ("Freeze Frame", "凍結幀"), + ("Freeze On Last Frame", "在最後一幀凍結"), + ("Add Marker", "新增標記"), + ("Bring Layer to Front", "圖層移至最上層"), + ("Bring Layer Forward", "圖層上移一層"), + ("Send Layer Backward", "圖層下移一層"), + ("Send Layer to Back", "圖層移至最下層"), + ("Pre-compose…", "預合成…"), + ("Split Layer", "分割圖層"), + ("Layer Style Options...", "圖層樣式選項..."), + ("Convert to Editable Styles", "轉換為可編輯樣式"), + ("Show All", "全部顯示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分離維度"), + ("Add Property to Essential Graphics", "將屬性新增至基本圖形"), + ("Continuous Bezier", "連續貝茲"), + ("Auto Bezier", "自動貝茲"), + ("Lock To Time", "鎖定至時間"), + ("Rove Across Time", "跨時間漫遊"), + ("Anchor Point", "錨點"), + ("All Transform Properties", "所有變換屬性"), + ("Line Anchor", "行錨點"), + ("Line Spacing", "行距"), + ("Character Offset", "字元偏移"), + ("Character Value", "字元值"), + ("Fill Color: RGB", "填色: RGB"), + ("Fill Color: Hue", "填色: 色相"), + ("Fill Color: Saturation", "填色: 飽和度"), + ("Fill Color: Brightness", "填色: 亮度"), + ("Fill Color: Opacity", "填色: 不透明度"), + ("Stroke Color: RGB", "筆畫顏色: RGB"), + ("Stroke Color: Hue", "筆畫顏色: 色相"), + ("Stroke Color: Saturation", "筆畫顏色: 飽和度"), + ("Stroke Color: Brightness", "筆畫顏色: 亮度"), + ("Stroke Color: Opacity", "筆畫顏色: 不透明度"), + ("Stroke Width", "筆畫寬度"), + ("Front: Color", "前面: 顏色"), + ("Bevel: Color", "斜角: 顏色"), + ("Side: Color", "側面: 顏色"), + ("Back: Color", "背面: 顏色"), + ("Effect Controls", "效果控制項"), + ("Effects & Presets", "效果與預設集"), + ("Motion Sketch", "運動草圖"), + ("Render Queue", "轉譯佇列"), + ("Mask Interpolation", "遮罩插補"), + ("Script Console", "指令碼主控台"), + ("Essential Graphics", "基本圖形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒體瀏覽器"), + ("Content-Aware Fill", "內容感知填色"), + ("Create Nulls From Paths", "從路徑建立空物件"), + ("VR Comp Editor", "VR 合成編輯器"), + ("Media Duration", "媒體持續時間"), + ("Frame Rate", "幀率"), + ("File Path", "檔案路徑"), + ("Interpret Footage", "解譯素材"), + ("Create a new Folder", "新增資料夾"), + ("Create a new Composition", "新增合成"), + ("First Frame", "第一幀"), + ("Previous Frame", "上一幀"), + ("Next Frame", "下一幀"), + ("Last Frame", "最後一幀"), + ("Name (A–Z)", "名稱 (A–Z)"), + ("Merged PRs", "已合併 PR"), + ("Lines added", "新增的行數"), + ("Lines deleted", "刪除的行數"), + ("Line delta", "行數差異"), + ("Binary assets added", "新增的二進位資源"), + ("Binary assets removed", "刪除的二進位資源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "顯示名稱"), + ("Real name", "真實姓名"), + ("ArtCraft website", "ArtCraft 網站"), + ("Source code on GitHub", "GitHub 上的原始碼"), + ("3D Renderer", "3D 轉譯器"), + ("Preset Name", "預設集名稱"), + ("Save Preset…", "儲存預設集…"), + ("Delete Preset", "刪除預設集"), + ("Lock Aspect Ratio", "鎖定長寬比"), + ("Frame Rate presets", "幀率預設集"), + ("Background Color", "背景顏色"), + ("Shutter Angle", "快門角度"), + ("Shutter Phase", "快門相位"), + ("Samples Per Frame", "每幀取樣數"), + ("Adaptive Sample Limit", "自適應取樣上限"), + ("X and Y", "X 和 Y"), + ("X only", "僅 X"), + ("Y only", "僅 Y"), + ("Track Camera", "追蹤攝影機"), + ("Warp Stabilizer", "彎曲穩定器"), + ("Track Motion", "追蹤運動"), + ("Stabilize Motion", "穩定運動"), + ("Motion Source:", "運動來源:"), + ("Current Track:", "目前追蹤:"), + ("Track Type:", "追蹤類型:"), + ("Motion Target:", "運動目標:"), + ("Track Mask:", "追蹤遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向後分析"), + ("Continue Tracking", "繼續追蹤"), + ("Stop Tracking", "停止追蹤"), + ("Adapt Feature", "自適應特徵"), + ("Extrapolate Motion", "外推運動"), + ("Attach Point", "附加點"), + ("Track Name", "追蹤名稱"), + ("Motion Tracker Options", "運動追蹤器選項"), + ("Motion Tracker Apply Options", "運動追蹤器套用選項"), + ("Edit Value Graph", "編輯數值圖表"), + ("Edit Speed Graph", "編輯速度圖表"), + ("Show Reference Graph", "顯示參考圖表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自動選擇圖表類型 (空間屬性用速度圖表,否則用數值圖表)"), + ("Auto-zoom graph height", "自動縮放圖表高度"), + ("Fit all graphs to view", "使所有圖表符合視圖"), + ("Fit selection to view", "使所選內容符合視圖"), + ("Snap (key drags snap to the current time and other keys)", "貼齊 (關鍵影格拖曳時貼齊目前時間和其他關鍵影格)"), + ("Show Transform Box when multiple keys are selected", "選擇多個關鍵影格時顯示變換框"), + ("Separate Dimensions (Position)", "分離維度 (位置)"), + ("Convert selected keyframes to Hold", "將所選關鍵影格轉換為定格"), + ("Convert selected keyframes to Linear", "將所選關鍵影格轉換為線性"), + ("Convert selected keyframes to Auto Bezier", "將所選關鍵影格轉換為自動貝茲"), + ("Easy Ease (F9)", "緩和 (F9)"), + ("Easy Ease In (Shift+F9)", "緩入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "緩出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空間路徑"), + ("Temporal Path", "時間路徑"), + ("All the Same", "全部相同"), + ("All Independently", "全部獨立"), + ("Noise Type:", "雜訊類型:"), + ("Apply To:", "套用至:"), + ("Temporal Graph", "時間圖表"), + ("Keyframe Rate:", "關鍵影格率:"), + ("Capture speed at:", "速度擷取位置:"), + ("Start Capture", "開始擷取"), + ("Cancel Capture", "取消擷取"), + ("Small Caps", "小型大寫字母"), + ("All Small Caps", "全部小型大寫字母"), + ("Superscript / Superior", "上標"), + ("Subscript / Inferior", "下標"), + ("Standard Ligatures", "標準連字"), + ("Discretionary Ligatures", "選用連字"), + ("Contextual Alternates", "上下文替代"), + ("Stylistic Alternates", "樣式替代"), + ("Titling Alternates", "標題替代"), + ("Default Figure Style", "預設數字樣式"), + ("Tabular Lining", "等寬襯線數字"), + ("Proportional Oldstyle", "比例舊式"), + ("Proportional Lining", "比例襯線數字"), + ("Tabular Oldstyle", "等寬舊式"), + ("Fill Over Stroke", "填色在筆畫之上"), + ("Stroke Over Fill", "筆畫在填色之上"), + ("Stroke order", "筆畫順序"), + ("Single-line Composer", "單行排版器"), + ("Every-line Composer", "逐行排版器"), + ("Text direction", "文字方向"), + ("Recently Used", "最近使用"), + ("User Presets", "使用者預設集"), + ("Remove from Favorites", "從我的最愛移除"), + ("Show Animation Presets", "顯示動畫預設集"), + (" Refresh List", " 重新整理清單"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "僅顯示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "僅顯示 GPU 加速效果"), + ("Show Effects for All Color Depths", "顯示所有色彩深度的效果"), + ("* Animation Presets", "* 動畫預設集"), + ("Select a layer to apply to", "請選擇要套用的圖層"), + ("Search effects and presets", "搜尋效果與預設集"), + ("Alpha Expansion:", "Alpha 擴張:"), + ("Fill Method:", "填色方式:"), + ("Edge Blend", "邊緣混合"), + ("Entire Duration", "整個持續時間"), + ("Reference Frame:", "參考幀:"), + ("Use Current Time", "使用目前時間"), + ("Generate Fill Layer", "產生填色圖層"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "建立文字和攝影機"), + ("Create Solid and Camera", "建立純色和攝影機"), + ("Create Null and Camera", "建立空物件和攝影機"), + ("Create Shadow Catcher, Camera and Light", "建立陰影擷取器、攝影機和燈光"), + ("Unable to solve camera", "無法求解攝影機"), + ("Waiting to solve", "等待求解"), + ("Create Camera", "建立攝影機"), + ("track point", "追蹤點"), + ("Ruler zero point", "尺規零點"), + ("Vertical ruler", "垂直尺規"), + ("Horizontal ruler", "水平尺規"), + ("Viewer controls", "檢視器控制項"), + ("Resolution/Down Sample Factor", "解析度/降取樣係數"), + ("Adjust Exposure", "調整曝光"), + ("Snapshot taken", "已拍攝快照"), + ("Fast Previews", "快速預覽"), + ("3D View", "3D 視圖"), + ("Current time", "目前時間"), + ("Layer Transform", "圖層變換"), + ("Reset Transform", "重設變換"), + ("Text Animation", "文字動畫"), + ("Text Animation Presets", "文字動畫預設集"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小螢幕"), + ("Tool Creates Shape", "工具建立形狀"), + ("Tool Creates Mask", "工具建立遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "顯示網格"), + ("Record Options...", "記錄選項..."), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -4447,6 +5350,7 @@ mod tests { const CONVERTED: &[(&str, &str)] = &[ ("color_picker.rs", include_str!("../color_picker.rs")), ("credits.rs", include_str!("../credits.rs")), + ("dock.rs", include_str!("../dock.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), ("widgets.rs", include_str!("../widgets.rs")), @@ -4504,6 +5408,8 @@ mod tests { ("engine settings pages", include_str!("../../../engine/src/prefs.rs")), ("engine viewer channels", include_str!("../../../engine/src/viewer.rs")), ("engine viewer commands", include_str!("../../../engine/src/commands/viewer_cmds.rs")), + // The text animator menu's labels come from the engine as data, like the settings pages. + ("engine text animator kinds", include_str!("../../../project/src/build.rs")), ]; /// Call shapes that hand a string literal straight to the user. @@ -4658,6 +5564,13 @@ mod tests { fn japanese_rows_translate_the_panels() { set_current("ja"); assert_eq!(tr("Composition Name:"), "コンポジション名:"); + // The panel/dialog second step: panel tabs, form labels, context menus and messages. + assert_eq!(tr("Effect Controls"), "エフェクトコントロール"); + assert_eq!(tr("Media Duration"), "メディアの長さ"); + assert_eq!(tr("Bring Layer to Front"), "レイヤーを最前面へ"); + assert_eq!(tr("All Transform Properties"), "すべてのトランスフォームプロパティ"); + assert_eq!(tr("Fill Color: RGB"), "塗りの色: RGB"); + assert_eq!(tr("Lines added"), "追加行数"); assert_eq!(tr("my layer.mp4"), "my layer.mp4"); assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "プロキシの使用:Best"); assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ステップ1/3:Colour"); diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 2cd19df5d..04c9f20ec 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -259,7 +259,7 @@ pub fn show( ui.add_space(8.0); ui.horizontal(|ui| { for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { - let r = ui.selectable_label(d.tab == tab, label); + let r = ui.selectable_label(d.tab == tab, crate::i18n::tr(label)); auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); if r.clicked() { d.tab = tab; diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 0b372c0a0..50ba90c64 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -245,6 +245,7 @@ fn text_button( tip: &str, ) -> bool { let t = app.tokens; + let (label, tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; // Once one button overflows, the rest follow it into the menu (they keep their order). if !bar.hidden.is_empty() || bar.x + w > bar.limit { diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index cd0ab6897..95e27da93 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -42,7 +42,8 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (icon, cmd, tip) in buttons { let r = Rect::from_min_size(pos2(x, y), vec2(bw, 28.0)); let icon = if cmd == "playback.toggle" && app.playback.playing { Icon::Pause } else { icon }; - if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::new(("pv", cmd))).on_hover_text(tip).clicked() { + if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::new(("pv", cmd))).on_hover_text(tr(tip)).clicked() + { // The play button plays with the options of the shortcut shown below. let params = if cmd == "playback.toggle" { json!({"shortcut": current.id()}) } else { json!({}) }; let _ = crate::menus::invoke(app, &ctx, cmd, params); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index c4e9894c4..30d18b722 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -475,7 +475,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click()); - hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim }); + hp.with_clip_rect(hr).text( + pos2(*x, hdr.center().y), + Align2::LEFT_CENTER, + crate::i18n::tr(label), + Tokens::ui(11.0), + if resp.hovered() { t.text } else { t.text_dim }, + ); if *key == "label" { icons::paint(&hp, Rect::from_center_size(pos2(label_x, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); } @@ -981,7 +987,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { (Icon::NewComp, "newComp", "Create a new Composition"), ] { let r = Rect::from_min_size(pos2(x, foot.min.y + 3.0), vec2(22.0, 22.0)); - let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(tip); + let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("project.{id}"), r, tip); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(tip))); if resp.clicked() { @@ -1106,7 +1112,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak()); for (key, label, _) in COLUMNS { let mut on = app.ui.project_columns.iter().any(|c| c == key); - if ui.checkbox(&mut on, label).changed() { + if ui.checkbox(&mut on, crate::i18n::tr(label)).changed() { set_column(&mut app.ui.project_columns, key, on); } } diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index bd54625b8..87f59a33a 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -615,7 +615,7 @@ pub fn apply_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, "Motion Tracker Apply Options", vec2(380.0, 170.0), t, |ui| { ui.horizontal(|ui| { ui.label(crate::i18n::tr("Apply Dimensions:")); - let labels = ["X and Y", "X only", "Y only"]; + let labels = [crate::i18n::tr("X and Y"), crate::i18n::tr("X only"), crate::i18n::tr("Y only")]; let r = egui::ComboBox::from_id_salt("track-apply-dims") .selected_text(labels[d.dims.min(2)]) .show_ui(ui, |ui| { diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5606a857d..f35bcc74c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (the panel/dialog catalogs grow by 300 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; Japanese 1,621 and Chinese 1,623 of ≈ 4,200 interface strings; previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -63,16 +63,16 @@ the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| -| English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,130 (≈ 29%): menus 624 rows, panels 506 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | -| Spanish | `es` | 624 (≈ 16%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | +| English | `en` | ≈ 4,200 (100%, source) | All; docs in English | — | yes | full | — | +| Simplified Chinese | `zh-hans` | 1,623 (≈ 39%): menus 628 rows, panels 995 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Spanish | `es` | 628 (≈ 15%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | -| French | `fr` | 624 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | -| Portuguese (Brazil) | `pt-br` | 1,024 (≈ 26%): menus 624 rows, panels 400 | Panels and dialogs partly | Latin, fine | no | partial | 6–9 h + review | +| French | `fr` | 628 (≈ 15%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | +| Portuguese (Brazil) | `pt-br` | 1,045 (≈ 25%): menus 628 rows, panels 417 | Panels and dialogs partly | Latin, fine | no | partial | 6–9 h + review | | Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | -| Japanese | `ja` | 1,034 (≈ 27%): menus 624 rows, panels 410 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | -| German | `de` | 624 (≈ 16%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | +| Japanese | `ja` | 1,621 (≈ 39%): menus 628 rows, panels 993 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| German | `de` | 628 (≈ 15%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | @@ -80,14 +80,14 @@ Other languages EffectCraft ships (3): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,130 (≈ 29%): menus 624 rows, panels 506 | partial | 6–9 h + review | -| Ukrainian | `uk` | 1,129 (≈ 29%): menus 624 rows, panels 505 | partial | 6–9 h + review | -| Czech | `cs` | 1,125 (≈ 29%): menus 624 rows, panels 501 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | +| Traditional Chinese | `zh-hant` | 1,623 (≈ 39%): menus 628 rows, panels 995 | partial | 6–9 h + review | +| Ukrainian | `uk` | 1,152 (≈ 27%): menus 628 rows, panels 524 | partial | 6–9 h + review | +| Czech | `cs` | 1,146 (≈ 27%): menus 628 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | -Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). +Russian (`ru`) has a menu catalog (628 rows, ≈ 15%); After Effects also ships Italian, which EffectCraft does not (8–12 h). -**Overall: ≈ 18%**, the average coverage of After Effects' 10 non-English languages -(Simplified and Traditional Chinese 29%, Japanese 27%, Brazilian Portuguese 26%, Spanish, French, +**Overall: ≈ 27%**, the average coverage of After Effects' 10 non-English languages +(Simplified and Traditional Chinese 39%, Japanese 39%, Ukrainian 27%, Czech 27%, Brazilian Portuguese 25%, Spanish, French and German 15%, German and Russian 16%, Korean and Italian 0%; 175 / 10, was ≈ 12% on 10 October). This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). @@ -103,6 +103,7 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese (and no changes to the other catalogs) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 993 Japanese and 995 Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources | | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | From 5300c9fa87d564c344e41c8a7c5f4339754f7a6d Mon Sep 17 00:00:00 2001 From: midasdf Date: Thu, 22 Oct 2026 23:10:58 +0900 Subject: [PATCH 022/112] i18n(ja): add the missing panel rows and recount localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up to the panel/dialog step: the composition-settings tabs translate "Basic" and "Advanced" through `tr(variable)` and the Preview transport translates "Play/Stop", but none of the three had a catalog row, so they stayed English. Add them to the Japanese, Simplified and Traditional Chinese catalogs and pin them in `japanese_rows_translate_the_panels`. The shared "Type" key means "kind" in the Project column and the Camera Settings dialog, so the Chinese rows read 类型/類型 instead of 文字 ("text"). Keep the graph button bar's automation labels English: translate only the drawn label and tooltip, and translate the stored English strings when the hidden-button menu draws them. Recount the docs from the catalogs: 628 menu rows, 996 Japanese and 998 Chinese panel rows; count Simplified Chinese's 306 effect names in its numerator (46%) and average the ten After Effects languages the table actually names (21%, not the stale 27%). Update the measurement basis, the cross-references in gaps.md and target-app-parity.md, and ROADMAP. --- ROADMAP.md | 15 ++++++----- crates/ui-egui/src/i18n/ui.rs | 18 +++++++++++-- crates/ui-egui/src/panels/graph.rs | 11 ++++---- docs/gaps.md | 4 +-- docs/localization-parity.md | 43 +++++++++++++++--------------- docs/target-app-parity.md | 2 +- 6 files changed, 55 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 76baa4c7d..8b32cc87f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs; Japanese 27% → 39%) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (localization: 303 more panel/dialog rows in Japanese and the Chinese catalogs; Japanese 27% → 39%, Simplified Chinese 29% → 46%, overall 18% → 21%) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -51,7 +51,7 @@ Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and | UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) | | File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) | | Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | 18% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | +| Localization | 21% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | | Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) | @@ -85,7 +85,7 @@ UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/lo | Language | Status | % | |---|---|---:| | English | full | 100% | -| Simplified Chinese | partial (menus, most of the panels) | 39% | +| Simplified Chinese | partial (menus, most of the panels) | 46% | | Spanish | menus only | 15% | | Hindi | none | 0% | | Arabic | none | 0% | @@ -117,10 +117,11 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log -- 2026-10-22: Localization: the panel/dialog catalogs gain 300 rows in Japanese, Simplified +- 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, - tracker/graph/text-panel controls, File-panel footers and credits. Japanese is 1,621 of - ≈ 4,200 interface strings (39%), Simplified and Traditional Chinese 1,623 (39%). + tracker/graph/text-panel controls, File-panel footers and credits. Japanese is 1,624 of + ≈ 4,200 interface strings (39%); the Chinese panel catalogs hold 1,626 rows each, and + Simplified Chinese is 46% with its 306 effect names. - 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 @@ -158,7 +159,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| -| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs (panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits); language shares recounted (Japanese 27% → 39%, Chinese 29% → 39%, overall 18% → 27%) | +| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted (Japanese 27% → 39%, Simplified Chinese 29% → 46% with its effect names, Traditional Chinese 29% → 39%, overall 18% → 21%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index e27945f70..d8b982dbf 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -120,6 +120,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Add:", "追加:"), ("Adjust Exposure (stops)", "露出を調整(段単位)"), ("Adjust composition duration to the time span of the selected layers", "コンポジションのデュレーションを、選択したレイヤーの時間範囲に合わせる"), + ("Advanced", "詳細"), ("Advanced 3D", "アドバンスド3D"), ("Align Layers to:", "レイヤーの整列基準:"), ("All six face cameras turn together; the scene is not changed.", "6つの面のカメラがすべて一緒に回転します。シーンは変更されません。"), @@ -392,6 +393,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Parameter Name:", "パラメーター名:"), ("Parent pick whip: drag onto a layer", "親ピックウィップ:レイヤー上にドラッグ"), ("Pixel Aspect Ratio:", "ピクセルアスペクト比:"), + ("Play/Stop", "再生/停止"), ("Playing on Video Preview", "ビデオプレビューで再生中"), ("Post-Render Action: {}", "レンダリング後の処理:{}"), ("Preserve frame rate when nested or in render queue", "ネスト時またはレンダーキュー内でフレームレートを保持"), @@ -1003,6 +1005,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Lock Aspect Ratio", "縦横比を固定"), ("Frame Rate presets", "フレームレートのプリセット"), ("Background Color", "背景色"), + ("Basic", "基本"), ("Shutter Angle", "シャッター角度"), ("Shutter Phase", "シャッター位相"), ("Samples Per Frame", "フレームあたりのサンプル数"), @@ -1164,6 +1167,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "调整曝光(光圈级数)"), ("Adjust composition duration to the time span of the selected layers", "将合成时长调整为所选图层的时间范围"), + ("Advanced", "高级"), ("Advanced 3D", "高级 3D"), ("Align Layers to:", "将图层对齐到:"), ("All six face cameras turn together; the scene is not changed.", "六个面摄像机一起旋转;场景不会改变。"), @@ -1427,6 +1431,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Parameter Name:", "参数名称:"), ("Parent pick whip: drag onto a layer", "父级拾取器:拖到图层上"), ("Pixel Aspect Ratio:", "像素长宽比:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在视频预览中播放"), ("Post-Render Action: {}", "渲染后动作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在渲染队列中时保留帧速率"), @@ -1557,7 +1562,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Transparency grid", "透明网格"), ("Tutorial complete", "教程完成"), ("Two-Node Camera", "双节点摄像机"), - ("Type", "文字"), + ("Type", "类型"), ("Type a command or effect…", "输入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), @@ -2046,6 +2051,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Lock Aspect Ratio", "锁定长宽比"), ("Frame Rate presets", "帧速率预设"), ("Background Color", "背景颜色"), + ("Basic", "基本"), ("Shutter Angle", "快门角度"), ("Shutter Phase", "快门相位"), ("Samples Per Frame", "每帧采样数"), @@ -2204,6 +2210,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "調整曝光(光圈級數)"), ("Adjust composition duration to the time span of the selected layers", "將合成時長調整為所選圖層的時間範圍"), + ("Advanced", "進階"), ("Advanced 3D", "高級 3D"), ("Align Layers to:", "將圖層對齊到:"), ("All six face cameras turn together; the scene is not changed.", "六個面攝影機一起旋轉;場景不會改變。"), @@ -2467,6 +2474,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Parameter Name:", "參數名稱:"), ("Parent pick whip: drag onto a layer", "上層拾取線:拖到圖層上"), ("Pixel Aspect Ratio:", "像素長寬比例:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在視訊預覽中播放"), ("Post-Render Action: {}", "算圖後動作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在算圖佇列中時保留影格速率"), @@ -2597,7 +2605,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Transparency grid", "透明格點"), ("Tutorial complete", "教學完成"), ("Two-Node Camera", "雙節點攝影機"), - ("Type", "文字"), + ("Type", "類型"), ("Type a command or effect…", "輸入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), @@ -3081,6 +3089,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Lock Aspect Ratio", "鎖定長寬比"), ("Frame Rate presets", "幀率預設集"), ("Background Color", "背景顏色"), + ("Basic", "基本"), ("Shutter Angle", "快門角度"), ("Shutter Phase", "快門相位"), ("Samples Per Frame", "每幀取樣數"), @@ -5571,6 +5580,11 @@ mod tests { assert_eq!(tr("All Transform Properties"), "すべてのトランスフォームプロパティ"); assert_eq!(tr("Fill Color: RGB"), "塗りの色: RGB"); assert_eq!(tr("Lines added"), "追加行数"); + // The composition-settings tabs and the Preview transport pass these through `tr` as + // variables, so a missing row would fall back to English unnoticed. + assert_eq!(tr("Basic"), "基本"); + assert_eq!(tr("Advanced"), "詳細"); + assert_eq!(tr("Play/Stop"), "再生/停止"); assert_eq!(tr("my layer.mp4"), "my layer.mp4"); assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "プロキシの使用:Best"); assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ステップ1/3:Colour"); diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 50ba90c64..076ad725b 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -218,7 +218,7 @@ impl ButtonBar { let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| { let mut chosen = None; for (id, label, on, tip) in &self.hidden { - let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str()); + let item = ui.selectable_label(*on, crate::i18n::tr(label)).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip); if item.clicked() { chosen = Some(id.clone()); @@ -245,15 +245,16 @@ fn text_button( tip: &str, ) -> bool { let t = app.tokens; - let (label, tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); - let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; + let (shown, shown_tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); + let w = p.layout_no_wrap(shown.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; // Once one button overflows, the rest follow it into the menu (they keep their order). if !bar.hidden.is_empty() || bar.x + w > bar.limit { + // The menu translates these English labels when it draws them; automation keeps them. bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string())); return bar.chosen.as_deref() == Some(id); } let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); - let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(shown_tip); p.rect_filled( r, 3.0, @@ -265,7 +266,7 @@ fn text_button( Color32::from_rgb(0x2c, 0x2c, 0x2c) }, ); - p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); + p.text(r.center(), Align2::CENTER_CENTER, shown, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); bar.x += w + 4.0; resp.clicked() diff --git a/docs/gaps.md b/docs/gaps.md index fd92c22a4..7507a06d7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -37,7 +37,7 @@ because 179 issues were closed with regression tests between 5 and 10 October. | UI/UX fidelity | ≈ 65% | 60–120 | [ui-parity.md](ui-parity.md) | | File formats | ≈ 50% | 130–230 | [file-format-parity.md](file-format-parity.md) | | Hardware | ≈ 40% | 60–120 | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 18% | 60–110 + review | [localization-parity.md](localization-parity.md) | +| Localization | ≈ 21% | 60–110 + review | [localization-parity.md](localization-parity.md) | | Stability | ≈ 45% | 40–80 | 23 never-crash PRs landed on 4 October and the "Never crash" rules bind every crate, but crash reports are open (#580 Chromatic Aberrations pan/tilt, #501 crash on startup, #426 timeline drag while playing, #341 quit on Intel Mac, #234 macOS 27 exit, #879 `--gpu` export aborts on a failed allocation; #591, #306 and #511 are closed), and no CI workflow runs `cargo xtask ci` on pull requests | | Real-user experience | ≈ 60%, uneven by platform | (in G2) | 179 issues closed 5–10 October (as counted on 10 October), nearly all with regression tests ([log below](#real-user-report-log-510-october)); 216 open and 328 closed on 12 October, many from Linux and Windows. Most checking still happens on macOS | | Performance | unknown against After Effects, ≈ 35% | 50–100 | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU on an M4 Pro). Users report stutter on cached frames (#595, #304; the RAM preview now prefetches nearest the playhead, 271000b2), late audio (#504, #335); slow scrubbing (#329, closed by #784) and #554 are closed. No 4K / hundreds-of-layers project tested | @@ -72,7 +72,7 @@ are grouped by area; the issue tracker has the rest. | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 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 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 624 rows each); panels in 6 (ja, zh-hans, zh-hant, uk, pt-br, cs); effect names translated in Simplified Chinese (306, 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) | +| 16 | **Localization depth** | Menus in 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 628 rows each); panels in 6 (ja 996 rows, zh-hans and zh-hant 998 each, uk 524, pt-br 417, cs 518); effect names translated in Simplified Chinese (306, 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) | | 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) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index f35bcc74c..a03d72e1c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (the panel/dialog catalogs grow by 300 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; Japanese 1,621 and Chinese 1,623 of ≈ 4,200 interface strings; previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (the panel/dialog catalogs grow by 303 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; Japanese 1,624 of ≈ 4,200 interface strings (39%), Simplified and Traditional Chinese 1,626 (Simplified Chinese 1,932 with its 306 effect names, 46%); previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -19,12 +19,12 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea - **Menus:** `crates/ui-egui/src/i18n.rs`, one catalog per language keyed by `(command, English label)`. A test (`every_translation_key_exists_in_the_actual_menu_tree`) fails if any fixed menu entry is untranslated or a row is stale, so every catalog covers all - fixed entries (624 catalog rows each at 470ea7a3, counted). The generated Effect menu is not part of that fixed tree: its entries + fixed entries (628 catalog rows each at `ad8b6acb`, counted). The generated Effect menu is not part of that fixed tree: its entries and category submenus take their labels from the effect catalog below. - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English - source string, with English fallback. Catalogs exist for Japanese (410 rows), Simplified and - Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501); - counted at 470ea7a3. + source string, with English fallback. Catalogs exist for Japanese (996 rows), Simplified and + Traditional Chinese (998 each), Ukrainian (524), Brazilian Portuguese (417), Czech (518) and + Spanish (513); counted at `ad8b6acb`. - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified Chinese (306 rows, one per effect in the registry, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts @@ -53,25 +53,26 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea ## Measurement -Measured 10 October 2026 at `origin/main` 1446e27 and recounted on 12 October at `upstream/main` -470ea7a3 by counting catalog rows (`const` tables in `crates/ui-egui/src/i18n.rs` and -`i18n/ui.rs`; menu rows 624 per language, so the share is computed from 624 rather than 618). The -denominator, **≈ 3,900 user-visible strings**, was estimated on 10 October and is kept: 618 fixed menu entries + ≈ 1,320 capitalised UI -literals in `crates/ui-egui/src` outside tests and catalogs (a regex count, so an upper bound) + -≈ 1,650 distinct effect parameter names + 306 effect names. Engine messages are not counted, so -the true percentages are slightly lower. +Measured 10 October 2026 at `origin/main` 1446e27 and recounted on 22 October 2026 at `ad8b6acb` +by counting catalog rows (`const` tables in `crates/ui-egui/src/i18n.rs` and `i18n/ui.rs`: 628 +menu rows and 998 panel/dialog sources per complete language, 996 in Japanese). The denominator, +**≈ 4,200 user-visible strings**, is a rounded estimate: the 628 fixed menu entries, the 998 +panel/dialog sources, 306 effect names, ≈ 1,650 distinct effect parameter names and ≈ 620 other +unwrapped interface strings (Home screen literals, engine-built tooltips, error and status +messages). It counts distinct strings, not catalog rows; engine messages are only partly counted, +so the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| | English | `en` | ≈ 4,200 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,623 (≈ 39%): menus 628 rows, panels 995 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Simplified Chinese | `zh-hans` | 1,932 (≈ 46%): menus 628 rows, panels 998, effect names 306 | Panels and dialogs partly; no help, effect names translated and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | | Spanish | `es` | 628 (≈ 15%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | | French | `fr` | 628 (≈ 15%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | | Portuguese (Brazil) | `pt-br` | 1,045 (≈ 25%): menus 628 rows, panels 417 | Panels and dialogs partly | Latin, fine | no | partial | 6–9 h + review | | Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | -| Japanese | `ja` | 1,621 (≈ 39%): menus 628 rows, panels 993 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| Japanese | `ja` | 1,624 (≈ 39%): menus 628 rows, panels 996 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | | German | `de` | 628 (≈ 15%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | @@ -80,20 +81,20 @@ Other languages EffectCraft ships (3): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,623 (≈ 39%): menus 628 rows, panels 995 | partial | 6–9 h + review | +| Traditional Chinese | `zh-hant` | 1,626 (≈ 39%): menus 628 rows, panels 998 | partial | 6–9 h + review | | Ukrainian | `uk` | 1,152 (≈ 27%): menus 628 rows, panels 524 | partial | 6–9 h + review | | Czech | `cs` | 1,146 (≈ 27%): menus 628 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | Russian (`ru`) has a menu catalog (628 rows, ≈ 15%); After Effects also ships Italian, which EffectCraft does not (8–12 h). -**Overall: ≈ 27%**, the average coverage of After Effects' 10 non-English languages -(Simplified and Traditional Chinese 39%, Japanese 39%, Ukrainian 27%, Czech 27%, Brazilian Portuguese 25%, Spanish, French and German 15%, -German and Russian 16%, Korean and Italian 0%; 175 / 10, was ≈ 12% on 10 October). This is the localization row in -[target-app-parity.md](target-app-parity.md#by-dimension). +**Overall: ≈ 21%**, the average coverage of After Effects' 10 non-English languages +(Simplified Chinese 46%, Traditional Chinese 39%, Japanese 39%, Brazilian Portuguese 25%, +Spanish, French, German and Russian 15%, Korean and Italian 0%; 209 / 10, was ≈ 18% on 12 October). +This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). **Effort.** One-time work first: route parameter names and engine messages through a catalog, wrap the remaining panel literals, and add a Hangul probe (10–20 h). Then each language -needs ≈ 2,900 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After +needs ≈ 2,600 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After Effects' 10 non-English languages: **≈ 60–110 h**; the four extra languages of the standard list (Hindi, Arabic, Indonesian, Vietnamese) add ≈ 50–80 h, most of it right-to-left layout and complex shaping in the interface. Every language needs native-speaker review, which only a human @@ -103,7 +104,7 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| -| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese (and no changes to the other catalogs) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 993 Japanese and 995 Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources | +| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese, plus the three rows the review found missing (Basic, Advanced and Play/Stop) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 996 Japanese and 998 Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources; Simplified Chinese reaches 1,932 (46%) with its 306 effect names, and the overall share is 21% | | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3ebaa4524..80be7108f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -139,7 +139,7 @@ Feature depth (≈ 50%) feeds the overall *ready* number with the other dimensio | UI/UX fidelity | 65% | 60–120 | After Effects-shaped menus (587 entries), panels, workspaces, shortcuts; many interaction issues still open; multi-monitor only as separate panel windows (#486 open); Effect Controls not yet like 26.5's | [ui-parity.md](ui-parity.md) | | File formats | 50% | 130–230 | `.aep` / `.aepx` / `.mogrt` / `.ffx` can't be read; strong modern codecs (H.264, HEVC, AV1, VP9, ProRes) | [file-format-parity.md](file-format-parity.md) | | Hardware | 40% | 60–120 | wgpu GPU on Metal/Vulkan/Direct3D 12/WebGPU with CPU fallback; no hardware decode/encode; Direct3D 12 compositor fails (#613); no video-out hardware | [hardware-parity.md](hardware-parity.md) | -| Localization | 18% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 3,900 strings): menus in 10 languages, panels in 6, effects in none; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | +| Localization | 21% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 4,200 strings): menus in 10 languages, panels in 6, effects in Simplified Chinese; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | | Performance | 35% | 50–100 | Internal numbers only; users report stutter and slow scrubbing | [gaps.md](gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | Open crash reports #580, #501, #426, #341, #234, #879 (#591, #306 and #511 are closed); no CI runs the tests on pull requests | [gaps.md](gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | Releases for macOS, Windows x64/x86/ARM64, Linux, FreeBSD and the web (more than After Effects), but Linux and Windows users hit basic bugs | [gaps.md](gaps.md#g2-real-user-reliability-on-every-platform) | From 9e39aea45b5d21e02409bc04ddaf3f050ecd6d9d Mon Sep 17 00:00:00 2001 From: midasdf Date: Fri, 23 Oct 2026 01:29:35 +0900 Subject: [PATCH 023/112] ROADMAP: the Chinese panel catalogs hold 998 rows, not 1,626 1,626 is the Traditional Chinese total translated-string count; the panel catalogs carry 998 source rows each (996 in Japanese), as docs/localization-parity.md measures. --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8b32cc87f..e3dc2792c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -120,7 +120,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). - 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, tracker/graph/text-panel controls, File-panel footers and credits. Japanese is 1,624 of - ≈ 4,200 interface strings (39%); the Chinese panel catalogs hold 1,626 rows each, and + ≈ 4,200 interface strings (39%); the Chinese panel catalogs hold 998 rows each, and Simplified Chinese is 46% with its 306 effect names. - 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 From efe7581f9395343af94871d0388eff95dbe5c308 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 23:47:45 -0700 Subject: [PATCH 024/112] 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 025/112] 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 026/112] 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 027/112] 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 028/112] 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 029/112] 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 d466e428b2ce115d0220771530e69960bf8a6c15 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 20:41:35 -0700 Subject: [PATCH 030/112] =?UTF-8?q?Scripts:=20File=20=E2=96=B8=20Scripts?= =?UTF-8?q?=20entries=20take=20keyboard=20shortcuts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit ▸ Keyboard Shortcuts now lists the scripts File ▸ Scripts lists, under File ▸ Scripts, and a key given to one runs it as its menu entry does. The key is bound to the script's file name (file.runScript {"name": ...}) and is saved with the preset. File ▸ Scripts and the macOS menu show it. The shortcut table lists the Scripts folder again when a script is installed or uninstalled, when the editor opens and when shortcuts.list runs. shortcuts.set and shortcuts.reset look a script up in File ▸ Scripts' list as it is now, and a script it doesn't list is an error instead of a key for Run Script File.... --- crates/engine/src/commands/scripts.rs | 62 ++++++++++++++++++++ crates/engine/src/commands/settings.rs | 18 ++++-- crates/engine/src/session_settings.rs | 14 ++++- crates/engine/src/shortcuts.rs | 35 ++++++++++- crates/ui-egui/src/menus.rs | 7 ++- crates/ui-egui/src/native_menu.rs | 5 +- crates/ui-egui/src/panels/shortcut_editor.rs | 2 + crates/ui-egui/tests/it/ui_scriptui.rs | 36 ++++++++++++ docs/plugins.md | 2 +- docs/preferences.md | 3 +- docs/scripting-parity.md | 2 +- 11 files changed, 172 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 24049459c..db4095493 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -203,6 +203,7 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result { let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?; let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR }; cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?; + s.scripts_changed(); // After Effects asks for a restart; our menus pick it up at once. s.toast(if panel { format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu") @@ -231,6 +232,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result { removed = true; } } + s.scripts_changed(); if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } } @@ -602,4 +604,64 @@ mod tests { assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed"); let _ = std::fs::remove_dir_all(&root); } + + /// Edit ▸ Keyboard Shortcuts lists the scripts File ▸ Scripts lists (not ScriptUI panels), + /// as soon as one is installed and until it is uninstalled; a key given to one is saved with + /// the preset. + #[test] + fn file_scripts_entries_take_keyboard_shortcuts() { + use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, binding_key}; + let (mut s, root) = session_in_tmp("shortcuts"); + let (script, panel) = (root.join("Hello.jsx"), root.join("Hello Panel.jsx")); + std::fs::write(&script, "hello").unwrap(); + std::fs::write(&panel, "panel").unwrap(); + let listed = |s: &Session, name: &str| { + s.shortcuts().find(&binding_key("file.runScript", &json!({"name": name}))).map(|b| (b.label.clone(), b.path.join(" ▸ "), b.defaults.clone())) + }; + assert_eq!(listed(&s, "Rename Layers.jsx"), Some(("Rename Layers.jsx".into(), "File ▸ Scripts".into(), vec![])), "the bundled samples"); + assert_eq!(listed(&s, "Hello.jsx"), None); + s.execute("file.installScript", json!({"path": script.to_string_lossy()})).unwrap(); + s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap(); + assert_eq!(listed(&s, "Hello.jsx"), Some(("Hello.jsx".into(), "File ▸ Scripts".into(), vec![]))); + assert_eq!(listed(&s, "Hello Panel.jsx"), None, "panels are in the Window menu"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Hello.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + let key = binding_key("file.runScript", &json!({"name": "Hello.jsx"})); + assert_eq!((&r["command"], &r["keys"]), (&json!(key), &json!(["Cmd+Alt+Shift+F7"])), "{r}"); + assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"name": "Hello.jsx"})), Some("Cmd+Alt+Shift+F7")); + let saved = Keymaps::from_json(&std::fs::read_to_string(root.join("settings").join(SHORTCUTS_FILE)).unwrap()); + assert_eq!(saved.active_preset().and_then(|p| p.overrides.get(&key)), Some(&vec!["Cmd+Alt+Shift+F7".to_string()])); + s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap(); + assert_eq!(listed(&s, "Hello.jsx"), None); + let _ = std::fs::remove_dir_all(&root); + } + + /// `shortcuts.list` and `shortcuts.set` see a script copied into the Scripts folder by hand + /// after the shortcut table was built; a script the folder doesn't hold (never there, or + /// removed by hand) can't take a key, and Run Script File… keeps its own keys. + #[test] + fn shortcut_commands_find_scripts_copied_into_the_folder() { + let (mut s, root) = session_in_tmp("copied"); + assert!(s.shortcuts().find("file.runScript").is_some(), "the table is built"); + std::fs::create_dir_all(root.join("settings").join(SCRIPTS_DIR)).unwrap(); + std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx"), "copied").unwrap(); + let list = s.execute("shortcuts.list", json!({"query": "copied.jsx"})).unwrap(); + assert_eq!(list["count"], 1, "{list}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Copied.jsx"}"#, "{r}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Missing.jsx"}, "keys": "Cmd+Alt+Shift+F8"})); + assert!(bad_params(&r), "{r:?}"); + // The binding-key form finds a script copied in since the last lookup, too. + std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx"), "second").unwrap(); + let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F10"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Second.jsx"}"#, "{r}"); + // Removed by hand: no longer in the folder. + std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx")).unwrap(); + std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx")).unwrap(); + let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F11"})); + assert!(bad_params(&r), "{r:?}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F9"})); + assert!(bad_params(&r), "{r:?}"); + assert_eq!(s.shortcuts().shortcut_of("file.runScript", &Value::Null), None, "Run Script File… has no key"); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/crates/engine/src/commands/settings.rs b/crates/engine/src/commands/settings.rs index a6621c3f4..272903c69 100644 --- a/crates/engine/src/commands/settings.rs +++ b/crates/engine/src/commands/settings.rs @@ -73,13 +73,21 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result { // ---------------------------------------------------------------- shortcuts -/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). -fn bindable(s: &Session, p: &Value, cmd: &str) -> Result { +/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). A +/// File ▸ Scripts entry (`file.runScript {"name": …}`) is looked up in File ▸ Scripts' list as it +/// is now (the Scripts folder and the bundled scripts), and a script it doesn't list is an error, +/// not Run Script File…. +fn bindable(s: &mut Session, p: &Value, cmd: &str) -> Result { let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?; let params = p.get("params").cloned().unwrap_or(Value::Null); - let t = s.shortcuts(); let key = binding_key(c, ¶ms); - t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) + let script = (c == "file.runScript" && params.get("name").is_some()) || c.starts_with("file.runScript {"); + if script { + s.scripts_changed(); + } + let t = s.shortcuts(); + let found = if script { t.find(&key) } else { t.find(&key).or_else(|| t.find(c)) }; + found.cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) } fn keys_param(p: &Value, cmd: &str) -> Result> { @@ -93,6 +101,8 @@ fn keys_param(p: &Value, cmd: &str) -> Result> { } fn sc_list(s: &mut Session, p: &Value) -> Result { + // Scripts copied into the Scripts folder since the table was built are listed too. + s.scripts_changed(); let q = str_p(p, "query").unwrap_or("").to_lowercase(); let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false); let keys_q = str_p(p, "keys").and_then(normalize); diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index a56d1849b..ed4d0da74 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -171,9 +171,19 @@ impl Session { self.shortcut_table = std::sync::OnceLock::new(); } - /// The active shortcut preset resolved against every bindable command. + /// The scripts in the Scripts folder changed: list File ▸ Scripts' entries again in the + /// shortcut table. + pub fn scripts_changed(&mut self) { + self.shortcut_table = std::sync::OnceLock::new(); + } + + /// The active shortcut preset resolved against every bindable command, File ▸ Scripts' + /// entries included. pub fn shortcuts(&self) -> &ShortcutTable { - self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands)) + self.shortcut_table.get_or_init(|| { + let scripts: Vec = crate::commands::scripts::scripts(self).into_iter().filter(|e| !e.panel).map(|e| e.name).collect(); + ShortcutTable::build_with_scripts(&self.keymaps, &self.ui_commands, &scripts) + }) } /// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel. diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 3d4ab3661..ac453a635 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -1,6 +1,7 @@ //! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts). //! -//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key +//! Every menu entry, registry command and frontend command, and every script File ▸ Scripts +//! lists, is *bindable*: it has a binding key //! (the command id, plus its bound parameters for entries such as `app.settings //! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in //! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom @@ -142,6 +143,27 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { out } +/// The File ▸ Scripts entries of the scripts `names` (file names, as the menu lists them), with +/// no default keys. A key is bound to the script's name, so it stays with that script when +/// scripts are added to or removed from the folder. +pub fn script_bindables(names: &[String]) -> Vec { + names + .iter() + .map(|n| { + let params = json!({"name": n}); + Bindable { + key: binding_key("file.runScript", ¶ms), + command: "file.runScript".into(), + params, + label: n.clone(), + path: vec!["File".into(), "Scripts".into()], + scope: APP_SCOPE.into(), + defaults: vec![], + } + }) + .collect() +} + /// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it /// has no key. pub fn normalize(s: &str) -> Option { @@ -421,7 +443,16 @@ pub struct ShortcutTable { impl ShortcutTable { pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable { - let bindables = bindables(ui); + Self::build_with_scripts(maps, ui, &[]) + } + + /// [`ShortcutTable::build`] with the File ▸ Scripts entries of the scripts `scripts`, listed + /// after the other File ▸ Scripts entries. + pub fn build_with_scripts(maps: &Keymaps, ui: &[UiCommand], scripts: &[String]) -> ShortcutTable { + let mut bindables = bindables(ui); + let at = bindables.iter().rposition(|b| b.path.starts_with(&["File".to_string(), "Scripts".to_string()])).map_or(bindables.len(), |i| i + 1); + let new: Vec = script_bindables(scripts).into_iter().filter(|s| !bindables.iter().any(|b| b.key == s.key)).collect(); + bindables.splice(at..at, new); let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect(); ShortcutTable { bindables, keys } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ea9a6d91e..8f5df4d6c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2222,7 +2222,12 @@ fn menu_nodes( "recentPresets" => app.session.prefs.recent_presets.get(i).cloned(), _ => None, }; - let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str()))); + let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())); + // File ▸ Scripts entries show the key a script was given. + if let Some(s) = entry_shortcut(app, e) { + b = b.shortcut_text(shortcut_text(&s)); + } + let r = ui.add_enabled(entry_enabled(app, e), b); let r = match &tip { Some(t) => r.on_hover_text(t), None => r, diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 7d7046ed3..a843b818f 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -159,8 +159,9 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { label: e.label.clone(), command: e.command.clone(), params: e.params.clone(), - shortcut: None, - accelerator: None, + // File ▸ Scripts entries show the key a script was given. + shortcut: crate::menus::entry_shortcut(app, e), + accelerator: crate::menus::entry_shortcut(app, e).as_deref().and_then(accelerator), enabled: crate::menus::entry_enabled(app, e), checked: crate::menus::entry_checked(app, e), })); diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index 440ca6394..7c0de584e 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -49,6 +49,8 @@ const PANEL_COLOR: Color32 = Color32::from_rgb(0x3f, 0xa8, 0x6c); pub fn open(app: &mut EffectcraftApp) { app.dialog_state.shortcuts = EditorState::default(); + // Scripts copied into the Scripts folder since the table was built are listed too. + app.session.scripts_changed(); app.dialog = Some(Dialog::Shortcuts); } diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs index 31fb555d3..2020928f0 100644 --- a/crates/ui-egui/tests/it/ui_scriptui.rs +++ b/crates/ui-egui/tests/it/ui_scriptui.rs @@ -112,6 +112,42 @@ fn panels_open_at_quit_dock_again_at_launch() { assert_eq!(store.read(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE).as_deref(), Some(r#"{"open":[]}"#)); } +/// A script copied into the Scripts folder while the app runs is listed in Edit ▸ Keyboard +/// Shortcuts and takes a key there: File ▸ Scripts shows the key next to the script, and +/// pressing it runs the script as its menu entry does. +#[test] +fn a_key_given_to_a_script_runs_it() { + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + let store = std::sync::Arc::new(MemoryConfig::default()); + let mut s = effectcraft_host::session(); + s.config = Some(store.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(h.state().session.shortcuts().find(r#"file.runScript {"name":"Rename Layers.jsx"}"#).is_some(), "the shortcut table is built"); + store.write("Scripts/Add Comp.jsx", "app.project.items.addComp('From Key', 64, 64, 1, 1, 25);").unwrap(); + effectcraft_ui_egui::panels::shortcut_editor::open(h.state_mut()); + let script = json!({"name": "Add Comp.jsx"}); + let r = h.state_mut().session.execute("shortcuts.set", json!({"command": "file.runScript", "params": script, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Add Comp.jsx"}"#, "{r}"); + assert_eq!(r["keys"], json!(["Cmd+Alt+Shift+F7"]), "{r}"); + assert_eq!(r["conflicts"], json!([]), "{r}"); + h.state_mut().dialog = None; + let spec = effectcraft_ui_egui::native_menu::build(h.state()); + let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).expect("the File ▸ Scripts entry"); + assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F7")); + let (m, k, ..) = effectcraft_ui_egui::menus::bindings(&h.state().session) + .into_iter() + .find(|b| b.2 == "file.runScript" && b.3 == script) + .expect("the key is bound to the script"); + let made = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().filter(|i| i.name == "From Key").count(); + assert_eq!(made(&h), 0); + for pressed in [true, false] { + h.event(Event::Key { key: k, physical_key: Some(k), pressed, repeat: false, modifiers: m }); + h.step(); + } + assert_eq!(made(&h), 1, "the key runs the script"); +} + /// The bundled Ease Presets ScriptUI panel (Window ▸ Ease Presets.jsx) docks, and clicking a /// preset in its list eases the selected keyframes in one undo step. #[test] diff --git a/docs/plugins.md b/docs/plugins.md index a688a89b0..ef91b584b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -17,7 +17,7 @@ Guide): | 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/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 | +| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts under File ▸ Scripts (`file.runScript {"name": ""}`); a key given to one is saved with the preset, File ▸ Scripts shows it, and pressing it runs the script as its menu entry does | Same. After Effects binds a key to a script's place in the alphabetical list (`ExecuteScriptMenuItem01`…), and EffectCraft binds it to the script's file name. The Window menu entries of ScriptUI panels aren't offered | | 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 | | `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same | | Allow Scripts to Write Files and Access Network | Settings ▸ Scripting & Expressions | Same | diff --git a/docs/preferences.md b/docs/preferences.md index ce96da0bf..df8313350 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -189,7 +189,8 @@ Effect Controls uses compact, fixed 20 pt parameter rows, as After Effects does: Edit ▸ Keyboard Shortcuts (`Cmd+Alt+'`) opens the shortcut editor: an on-screen keyboard showing which keys are assigned for the held modifiers (purple: application-wide, green: panel-specific, both: split), modifier toggles, a searchable list of every command (all engine commands, menu -entries with bound parameters and the frontend's tool / panel commands), and a preset menu. +entries with bound parameters, the scripts File ▸ Scripts lists and the frontend's tool / panel +commands), and a preset menu. - **Presets.** "EffectCraft Default" is After Effects' default layout and is read-only; editing it creates a "Custom" copy. Custom presets store only their differences from the default and diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 7ef7e9080..0907ba828 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -26,7 +26,7 @@ our own scripts; no real-world script library has been run against both apps. | Startup / Shutdown folders | yes | not run | missing | 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 | +| Keyboard shortcuts for scripts | yes: the scripts of the Scripts folder take keys, by their place in the alphabetical list | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts; a key stays with the script's file name | same | `file_scripts_entries_take_keyboard_shortcuts`, `a_key_given_to_a_script_runs_it` ([plugins.md](plugins.md)); ScriptUI panels in the Window menu take no key | | Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` | | Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — | | Agent control (MCP, control channel, every command) | none | yes | beyond | [agents.md](agents.md), [control-protocol.md](control-protocol.md); `list_effects` omits parameter types and ranges (#441) | From 55e957a803d66506dd5c90e812e07da6ba28d100 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 15:51:08 -0700 Subject: [PATCH 031/112] Scripts: a key changed to another key updates the native menu The native menu's state key hashed only the number of shortcut bindings, so changing a binding from one key to another left the number the same and could keep the old accelerator on the item. The key now hashes each binding and its keys. --- crates/ui-egui/src/native_menu.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index a843b818f..4f52a7a1e 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -323,7 +323,12 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h)); s.state.open_comps.hash(&mut h); s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h); - s.shortcuts().keys.len().hash(&mut h); + // Each binding's keys: a key changed to another one gives the item a new accelerator. + let shortcuts = s.shortcuts(); + for b in &shortcuts.bindables { + b.key.hash(&mut h); + shortcuts.keys.get(&b.key).hash(&mut h); + } s.keymaps.active.hash(&mut h); let v = &app.ui.viewer; (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h); @@ -637,6 +642,24 @@ mod tests { assert!(spec2.items().iter().any(|i| i.command == "file.openRecent" && i.label == "Recent Test.ecproj")); } + /// A script's key changed to another key changes the key, so the File ▸ Scripts entry gets + /// the new accelerator even though the number of bindings stays the same. + #[test] + fn a_changed_shortcut_changes_the_state_key() { + let mut app = app(); + let script = json!({"name": "Rename Layers.jsx"}); + let b = app.session.shortcuts().find(&effectcraft_engine::shortcuts::binding_key("file.runScript", &script)).cloned().unwrap(); + app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F7".to_string()]).unwrap(); + app.session.shortcuts_changed(); + let k0 = state_key(&app); + app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F8".to_string()]).unwrap(); + app.session.shortcuts_changed(); + assert_ne!(state_key(&app), k0); + let spec = build(&app); + let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).unwrap(); + assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F8")); + } + /// #1630: a checked Window menu entry closes its panel; an unchecked one opens it. #[test] fn window_menu_entry_toggles_its_panel() { From 924b755b720d16dc1164bb255970735de9bd764e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 02:06:53 -0700 Subject: [PATCH 032/112] Docs: gaps.md no longer lists keyboard shortcuts for scripts as missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File ▸ Scripts entries take keys in Edit ▸ Keyboard Shortcuts, so G8 says so and keeps the Startup and Shutdown folders and .jsxbin as missing. --- docs/gaps.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index f8fcd64c2..cc8340d04 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -299,8 +299,9 @@ 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 - shortcuts for scripts and `.jsxbin`; `#include` / `#target` directives landed (#759, + and open again at the next launch. Each File ▸ Scripts entry can be given a key in Edit ▸ + Keyboard Shortcuts. Missing: the Startup and Shutdown script folders 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 by name and applied to pairs of keyframes. Modelled on a third-party panel, they are not core: a From 1599790ac9b302f90a4345f0fe3ba0f5ade780e6 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 09:43:47 +0000 Subject: [PATCH 033/112] Light Environment lights: default environment, Casts Shadows, Advanced 3D switch (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Environment light had no visible effect, and its Light Settings dialog had no Casts Shadows option — both halves of #2436. No effect had two causes: - Classic 3D (the default for new comps) skips Environment lights entirely, so creating one did nothing. Adding one (or changing a light's type to it) now switches the comp to Advanced 3D in the same undo step, with a toast, exactly as model and primitive layers already do; switching back to Classic 3D with an Environment light in the comp explains itself too. - In Advanced 3D, a light with no Source and no Environment Layer produced no environment at all. It now falls back to a flat default environment of the light's colour × intensity, as After Effects does, so the light always lights the scene per its parameters. Cast Shadows was missing because the dialog and the light's property groups shared one gate (Falloff and Shadows together) that excluded Environment. The two are now split: every light but Ambient gets Casts Shadows, Shadow Darkness and Shadow Diffusion (stored, edited and carried over on type changes), while Falloff stays on Parallel/Spot/Point only — an environment light shines from every direction at once and has no use for a falloff. Tests: the renderer lights a card from a sourceless Environment light (colour and intensity drive it, zero intensity lights nothing), the engine switches Classic comps to Advanced 3D on creation and type change (one undo step, toast, and the Classic 3D switch-back toast), Environment lights take and carry shadow options but no Falloff, and the dialog ticks Casts Shadows on a new Environment light. --- crates/engine/src/commands/model3d.rs | 16 +++-- crates/engine/src/commands/three_d.rs | 25 +++++-- crates/engine/src/tests_3d.rs | 71 ++++++++++++++++++++ crates/project/src/build.rs | 9 ++- crates/render/src/three_d/adv/scene.rs | 12 ++-- crates/render/src/three_d/adv/tests.rs | 39 +++++++++++ crates/ui-egui/src/panels/dialogs_3d.rs | 16 ++++- crates/ui-egui/tests/it/ui_light_settings.rs | 23 ++++++- docs/agents.md | 9 ++- docs/architecture.md | 7 +- 10 files changed, 199 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs index 28b7274ef..1994aef13 100644 --- a/crates/engine/src/commands/model3d.rs +++ b/crates/engine/src/commands/model3d.rs @@ -64,6 +64,12 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result { if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) { s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer"); } + if r == Renderer::Classic3D { + let env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: effectcraft_project::LightKind::Environment }); + if s.project.comp(cid).is_some_and(|c| c.layers.iter().any(env)) { + s.toast("Classic 3D doesn't light Environment light layers: they light with the Advanced 3D renderer"); + } + } Ok(json!({"renderer": renderer_id(r)})) } @@ -77,7 +83,7 @@ fn renderer_id(r: Renderer) -> &'static str { /// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a /// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether /// the renderer changed. -fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result { +pub(crate) fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let switched = c.renderer != Renderer::Advanced3D; c.renderer = Renderer::Advanced3D; @@ -86,10 +92,10 @@ fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Resu } /// Tell the user when [`use_advanced_3d`] changed the comp's renderer. -fn report_switch(s: &mut Session, cid: ItemId, switched: bool) { +pub(crate) fn report_switch(s: &mut Session, cid: ItemId, switched: bool, because: &str) { if switched { let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string(); - s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models")); + s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which {because}")); } } @@ -156,7 +162,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result { let id = insert_layer_at(proj, st, cid, l, index)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; - report_switch(s, cid, switched); + report_switch(s, cid, switched, "draws 3D models"); Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true})) } @@ -226,7 +232,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result { let id = insert_layer(proj, st, cid, l)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; - report_switch(s, cid, switched); + report_switch(s, cid, switched, "draws 3D models"); Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true})) } diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index e143690d3..6e1f2d936 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -12,6 +12,7 @@ use effectcraft_time::Tick; use serde_json::{Value, json}; use super::layer::{color_p, insert_layer}; +use super::model3d::{report_switch, use_advanced_3d}; use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, layer_mut, layer_p, merge_p, str_p, time_p}; use crate::{EngineError, Result, Session, cmd, query}; @@ -227,12 +228,17 @@ fn new_light(s: &mut Session, p: &Value) -> Result { }; let name = str_p(p, "name").unwrap_or("Light 1").to_string(); let t = time_p(s, p, Some(&comp)); - let id = s.edit("New Light", None, |proj, st| { + let env = kind == LightKind::Environment; + let (id, switched) = s.edit("New Light", None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Light { kind }, (comp.width, comp.height), None); apply_light(&mut l, p, t, "layer.newLight")?; - insert_layer(proj, st, cid, l) + let id = insert_layer(proj, st, cid, l)?; + // An Environment light's image-based light only exists in Advanced 3D, so adding one + // switches a Classic 3D comp to it, as model layers do (#2436). + Ok((id, env && use_advanced_3d(proj, cid)?)) })?; - Ok(json!({"layer": id.0})) + report_switch(s, cid, switched, "lights Environment lights"); + Ok(json!({"layer": id.0, "advanced3d": env})) } fn light_settings(s: &mut Session, p: &Value) -> Result { @@ -248,12 +254,14 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { ), None => None, }; - s.edit("Light Settings", None, |proj, _| { + let switched = s.edit("Light Settings", None, |proj, _| { let old = proj.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?; let mut l = old.clone(); + let mut env = false; if let Some(k) = kind && old.source != (LayerSource::Light { kind: k }) { + env = k == LightKind::Environment; // Light Type change: rebuild the light's groups, carrying over shared values. let fresh = build::layer(proj, &comp, &old.name, LayerSource::Light { kind: k }, (comp.width, comp.height), None); l.source = fresh.source.clone(); @@ -289,8 +297,11 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { } apply_light(&mut l, p, t, "layer.lightSettings")?; *layer_mut(proj, cid, lid)? = l; - Ok(()) + // Changing the type to Environment: its image-based light only exists in Advanced 3D, + // so the comp switches to it, as model layers do (#2436). + Ok(env && use_advanced_3d(proj, cid)?) })?; + report_switch(s, cid, switched, "lights Environment lights"); Ok(Value::Null) } @@ -681,7 +692,7 @@ pub fn specs() -> Vec { "Light...", ["Layer", "New"], Some("Cmd+Alt+Shift+L"), - "{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer, see layer.environment), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}", + "{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer or a flat default environment of its colour and intensity, see layer.environment; an Environment light switches a Classic 3D comp to Advanced 3D), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}", has_comp, new_light ), @@ -708,7 +719,7 @@ pub fn specs() -> Vec { "Light Settings...", [], None, - "{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}", + "{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment (changing to Environment switches a Classic 3D comp to Advanced 3D), color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}", has_comp, light_settings ), diff --git a/crates/engine/src/tests_3d.rs b/crates/engine/src/tests_3d.rs index 07a48af1c..1b5deceda 100644 --- a/crates/engine/src/tests_3d.rs +++ b/crates/engine/src/tests_3d.rs @@ -148,6 +148,77 @@ fn deleting_a_source_layer_clears_the_reference() { assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some()); } +/// An Environment light only lights Advanced 3D comps, so adding one (or changing a light's type +/// to Environment) switches a Classic 3D comp to it in the same undo step, with a toast, as +/// model layers do (#2436). +#[test] +fn environment_light_switches_classic_comps_to_advanced_3d() { + let toasts = |s: &mut Session| -> Vec { + s.drain_events().into_iter().filter_map(|e| if let crate::Event::Toast { message, .. } = e { Some(message) } else { None }).collect() + }; + let mut s = session(); + let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); + toasts(&mut s); + let r = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); + assert_eq!(r["advanced3d"], true); + assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D); + assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D")), "the switch is explained"); + // A second Environment light leaves the comp (and the user) alone. + s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); + assert!(toasts(&mut s).is_empty()); + // One undo step removes the light and the switch together. + s.undo(); + assert_eq!(s.active_comp().unwrap().layers.len(), 2, "the second light is gone, the first stays"); + assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D, "the first light's switch still stands"); + s.undo(); + assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); + assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).is_some()); + // Changing a light's type to Environment switches too, and undoes back. + let spot = s.execute("layer.newLight", json!({"kind": "Spot"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); + s.execute("layer.lightSettings", json!({"layer": spot, "kind": "Environment"})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D); + s.undo(); + assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); + assert_eq!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(spot)).unwrap().source, LayerSource::Light { kind: LightKind::Spot }); + // Switching back to Classic 3D with an Environment light in the comp explains itself. + s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); + toasts(&mut s); + s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap(); + assert!(toasts(&mut s).iter().any(|m| m.contains("Environment light")), "the Classic 3D limit is explained"); +} + +/// An Environment light takes Casts Shadows (with Shadow Darkness and Diffusion) like every +/// other light — the Light Settings dialog offers them for all types but Ambient — and keeps no +/// Falloff, which an environment light shining from every direction has no use for (#2436). +#[test] +fn environment_light_takes_shadow_options_but_no_falloff() { + let mut s = session(); + let env = s.execute("layer.newLight", json!({"kind": "Environment", "castsShadows": true, "shadowDarkness": 60, "shadowDiffusion": 12})).unwrap()["layer"] + .as_u64() + .unwrap(); + assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); + assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0); + assert_eq!(prop(&s, env, "lightOptions/shadowDiffusion").as_f64(), 12.0); + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap(); + assert!(l.props.prop("lightOptions/falloff").is_none(), "an environment light has no Falloff"); + // The shadow values carry over when the type changes, both ways. + s.execute("layer.lightSettings", json!({"layer": env, "kind": "Spot", "coneAngle": 70})).unwrap(); + assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); + assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0); + assert_eq!(prop(&s, env, "lightOptions/coneAngle").as_f64(), 70.0); + s.execute("layer.lightSettings", json!({"layer": env, "kind": "Environment"})).unwrap(); + assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); + assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "back to Environment: it has a Source again"); + s.undo(); + assert!( + s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/coneAngle").is_some(), + "back to Spot: it has a Cone Angle again" + ); +} + #[test] fn three_d_switch_undo_redo() { let mut s = session(); diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 935c03b0e..d01116816 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -654,10 +654,15 @@ pub fn light_options(ids: &mut Ids, kind: LightKind) -> PropGroup { } if kind == LightKind::Environment { // The environment image: a layer (footage, comp…) mapped as an equirectangular panorama; - // none = the comp's Environment Layer. + // none = the comp's Environment Layer, or a flat default environment of the light's + // colour × intensity when there is none. No Falloff: an environment light shines from + // every direction at once (#2436). return g .with(ids.prop("source", "Source", Value::Layer(None)).with_ui(ParamUi::Layer)) - .with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)); + .with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("castsShadows", "Casts Shadows", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(ids.prop("shadowDarkness", "Shadow Darkness", Value::Scalar(100.0)).with_ui(ParamUi::Percent)) + .with(ids.prop("shadowDiffusion", "Shadow Diffusion", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)); } if kind != LightKind::Ambient { g = g diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index a97272434..56a5e6605 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -935,10 +935,14 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: let (ls, amb, env) = lights(ctx, lin); b.s.lights = ls; b.s.ambient = amb; - if let Some((src, k, rot)) = env - && let Some((w, h, d)) = env_pixels(r, ctx, src, lin) - { - add_environment(&mut b, w, h, d, k, rot); + if let Some((src, k, rot)) = env { + match env_pixels(r, ctx, src, lin) { + Some((w, h, d)) => add_environment(&mut b, w, h, d, k, rot), + // No Source and no Environment Layer: a flat default environment, as After + // Effects does — the light's colour × intensity light the scene from every + // direction (#2436). + None => add_environment(&mut b, 8, 4, vec![[1.0, 1.0, 1.0, 1.0]; 32], k, rot), + } } if r.opts.shadows() { super::raster::shadow_maps(&mut b.s, r.opts.draft); diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 3f5ee4813..2fb22a6e4 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -337,6 +337,45 @@ fn cards_and_environment_light() { assert_eq!(lit.data[80 * 120 + 110][3], 0.0); } +/// An Environment light with no Source and no Environment Layer lights the scene with a flat +/// default environment of its colour × intensity, as After Effects does (#2436) — not nothing +/// at all. +#[test] +fn environment_light_without_a_source_lights_the_scene() { + let (mut p, cid) = project(120, 90); + let sid = p.add_item( + "S", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [0.5, 0.5, 0.5], width: 60, height: 40, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true); + let unlit = render(&p, cid); + let c = unlit.data[20 * 120 + 30]; + assert!((c[0] - 0.5).abs() < 0.01 && c[3] > 0.99, "unlit card keeps its colour: {c:?}"); + // No Source, no Environment Layer: the light still lights the card (a flat white + // environment at the light's colour × intensity; a card's Material Options shade it). + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {}); + let lit = render(&p, cid); + let c2 = lit.data[20 * 120 + 30]; + assert!((c2[0] - c[0]).abs() > 0.05 && c2[3] > 0.99, "the light has an effect: {c2:?} vs {c:?}"); + // The light's parameters drive it: half intensity darkens towards zero intensity's black. + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(50.0)); + let half = render(&p, cid); + let c3 = half.data[20 * 120 + 30]; + assert!(c3[0] < c2[0] - 0.02, "half intensity lights less: {c3:?} vs {c2:?}"); + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(0.0)); + let dark = render(&p, cid); + let c4 = dark.data[20 * 120 + 30]; + assert!(c4[0] < 0.02 && c4[3] > 0.99, "zero intensity lights nothing: {c4:?}"); + // And its colour tints the light red. + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(100.0)); + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0])); + let red = render(&p, cid); + let c5 = red.data[20 * 120 + 30]; + assert!(c5[0] > 0.15 && c5[1] < 0.05 && c5[2] < 0.05, "red environment: {c5:?}"); +} + #[test] fn depth_of_field_blurs_out_of_focus_surfaces() { let mut img = Image::new(64, 64); diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 01fd5b9e8..80e18ce30 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -44,12 +44,18 @@ pub struct LightDraft { const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"]; -/// Whether a light type has Falloff and Shadows options. Ambient and Environment lights come -/// from no one place, so they have neither. +/// Whether a light type has Falloff options: Ambient and Environment lights come from no one +/// place, so they have none — an environment light shines from every direction at once. fn falls_off(kind: LightKind) -> bool { !matches!(kind, LightKind::Ambient | LightKind::Environment) } +/// Whether a light type has Shadows options. Every light but Ambient can cast shadows, +/// Environment lights included (their image-based light occludes) — as in After Effects (#2436). +fn casts_shadows(kind: LightKind) -> bool { + kind != LightKind::Ambient +} + fn comp_w(app: &EffectcraftApp) -> f64 { app.session.active_comp().map(|c| c.width as f64).unwrap_or(1920.0) } @@ -306,6 +312,8 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px"))); ui.end_row(); } + } + if casts_shadows(d.kind) { ui.label(tr("Shadows")); ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); ui.end_row(); @@ -318,7 +326,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } }); ui.add_space(14.0); - if falls_off(d.kind) { + if casts_shadows(d.kind) { ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim)); ui.add_space(8.0); } @@ -343,6 +351,8 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { p["falloff"] = json!(d.falloff); p["radius"] = json!(d.radius); p["falloffDistance"] = json!(d.falloff_distance); + } + if casts_shadows(kind) { p["castsShadows"] = json!(d.casts_shadows); p["shadowDarkness"] = json!(d.shadow_darkness); p["shadowDiffusion"] = json!(d.shadow_diffusion); diff --git a/crates/ui-egui/tests/it/ui_light_settings.rs b/crates/ui-egui/tests/it/ui_light_settings.rs index fe1392be1..0183e0324 100644 --- a/crates/ui-egui/tests/it/ui_light_settings.rs +++ b/crates/ui-egui/tests/it/ui_light_settings.rs @@ -177,13 +177,30 @@ fn falloff_and_shadow_options_show_only_for_lights_that_have_them() { invoke(&mut h, "layer.newLight", json!({})); for k in LightKind::ALL { choose(&mut h, k); - let shown = !matches!(k, LightKind::Ambient | LightKind::Environment); - for label in ["Falloff", "Casts Shadows", "Shadow Darkness"] { - assert_eq!(h.query_by_label(label).is_some(), shown, "{label} for {}", k.label()); + let falloff = !matches!(k, LightKind::Ambient | LightKind::Environment); + let shadows = k != LightKind::Ambient; + assert_eq!(h.query_by_label("Falloff").is_some(), falloff, "Falloff for {}", k.label()); + for label in ["Casts Shadows", "Shadow Darkness"] { + assert_eq!(h.query_by_label(label).is_some(), shadows, "{label} for {}", k.label()); } } } +/// An Environment light created through the dialog takes a Casts Shadows tick, like the other +/// light types (only Ambient has none) — #2436. +#[test] +fn an_environment_light_created_with_casts_shadow_takes_it() { + let mut h = harness(); + invoke(&mut h, "layer.newLight", json!({})); + choose(&mut h, LightKind::Environment); + h.get_by_label("Casts Shadows").click(); + ok(&mut h); + let id = last_layer(&h); + assert_eq!(kind(&h, id), LightKind::Environment); + assert!(layer(&h, id).props.prop("lightOptions/castsShadows").unwrap().value.as_bool()); + assert!(layer(&h, id).props.prop("lightOptions/falloff").is_none(), "but no Falloff"); +} + #[test] fn changing_an_environment_light_to_spot_undoes_to_its_source_and_rotation() { let mut h = harness(); diff --git a/docs/agents.md b/docs/agents.md index aae8be12c..b14c135fc 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -212,8 +212,13 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built or call `save_project {"path": …}` after each milestone. - **Environment light.** `layer.newLight` also accepts `"kind":"Environment"`: an equirectangular image that metallic surfaces reflect. Set its `lightOptions/source` to a footage, comp or solid - layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`) - ([#261](https://github.com/storytold/effectcraft/issues/261)). + layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`), + or a flat default environment of the light's colour × intensity when there is none — either + way the light brightens the scene, and it has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + like the other lights. It only lights Advanced 3D comps, so creating one (or changing a light's + type to Environment) switches a Classic 3D comp to Advanced 3D + ([#261](https://github.com/storytold/effectcraft/issues/261), + [#2436](https://github.com/storytold/effectcraft/issues/2436)). ### Cookbook (from end-to-end QA) diff --git a/docs/architecture.md b/docs/architecture.md index d3dd4db69..9b8964505 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,9 +158,12 @@ comp to Advanced 3D in the same undo step, as in After Effects. Shading is physi GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after Karis 2013): parallel, spot and point lights with After Effects' falloffs, ambient lights, image-based light from an Environment light whose Source (or the comp's Environment Layer, Layer ▸ -Environment Layer) is an equirectangular image (cosine irradiance map; roughness-indexed mip +Environment Layer, or a flat default environment of the light's colour × intensity when +there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps -(spot: perspective, parallel: orthographic over the scene, point: six cube faces). Cards keep +(spot: perspective, parallel: orthographic over the scene, point: six cube faces). An +Environment light only lights Advanced 3D comps, so adding one (or changing a light's type to +it) switches a Classic 3D comp to Advanced 3D, as model layers do. Cards keep Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular); models and primitives add Base Color, Metallic, Roughness, Emissive. Rasterisation runs at 2×2 supersampling with a reversed-Z depth buffer; opaque triangles resolve visibility first, the From 863f0e49c1f22c5ff8e87d0432bc2c1e8054ea05 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 13:05:16 +0000 Subject: [PATCH 034/112] Render the Environment light's shadow from its image (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Casts Shadows option Environment lights got is only half a fix if nothing shades for it: an image lights the scene from every direction, and what occludes that light is the image itself — its dominant bright region, the sun in a sky map. - dominant_direction finds it: the radiance-weighted mean texel direction (solid-angle weighted, rotation applied). A uniform image cancels to zero — a flat default environment shines from everywhere at once and casts no shadow, which a uniform environment test pins. - The shadow is one orthographic map over the scene from that direction, after the lights' maps, like a parallel light's: PCF widened by Shadow Diffusion, darkened by Shadow Darkness, cleared when nothing casts, the image is directionless, or the texel budget drops it. - It darkens the environment's own diffuse and specular light on Accepts Shadows surfaces and counts in Cast Shadows Only. CPU and GPU (WGSL) do the same; Globals carries the environment's sun direction and shadow settings (40 → 44 floats, preflight updated). A bright-disc environment turned to shine from behind the camera now shadows a sphere onto the floor behind it, Cast Shadows off lights it evenly again, and the GPU scene's sky gradient takes a shadow-casting Environment light. Also: the renderer-switch toasts read "...renderer, which draws 3D models", the dialog's Casts Shadows checkbox gets an auto id (dialog.light.casts) the tests click, and the docs' Environment notes cover the shadow. --- crates/engine/src/commands/model3d.rs | 8 +- crates/engine/src/commands/three_d.rs | 19 ++- crates/engine/src/tests_3d.rs | 44 +++---- crates/gpu/src/adv3d.rs | 31 +++-- crates/gpu/src/shaders/advanced3d.wgsl | 24 +++- crates/gpu/src/tests_adv3d.rs | 32 ++++- crates/project/src/build.rs | 3 +- crates/render/src/three_d/adv/raster.rs | 37 +++++- crates/render/src/three_d/adv/scene.rs | 118 +++++++++++++++++-- crates/render/src/three_d/adv/shade.rs | 21 +++- crates/render/src/three_d/adv/tests.rs | 89 +++++++++++++- crates/ui-egui/src/panels/dialogs_3d.rs | 12 +- crates/ui-egui/tests/it/ui_light_settings.rs | 17 +-- docs/agents.md | 8 +- docs/architecture.md | 8 +- 15 files changed, 384 insertions(+), 87 deletions(-) diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs index 1994aef13..2cc4d34e6 100644 --- a/crates/engine/src/commands/model3d.rs +++ b/crates/engine/src/commands/model3d.rs @@ -92,10 +92,10 @@ pub(crate) fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: Item } /// Tell the user when [`use_advanced_3d`] changed the comp's renderer. -pub(crate) fn report_switch(s: &mut Session, cid: ItemId, switched: bool, because: &str) { +pub(crate) fn report_switch(s: &mut Session, cid: ItemId, switched: bool, why: &str) { if switched { let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string(); - s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which {because}")); + s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, {why}")); } } @@ -162,7 +162,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result { let id = insert_layer_at(proj, st, cid, l, index)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; - report_switch(s, cid, switched, "draws 3D models"); + report_switch(s, cid, switched, "which draws 3D models"); Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true})) } @@ -232,7 +232,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result { let id = insert_layer(proj, st, cid, l)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; - report_switch(s, cid, switched, "draws 3D models"); + report_switch(s, cid, switched, "which draws 3D models"); Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true})) } diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index 6e1f2d936..c5a46b6c3 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -228,16 +228,16 @@ fn new_light(s: &mut Session, p: &Value) -> Result { }; let name = str_p(p, "name").unwrap_or("Light 1").to_string(); let t = time_p(s, p, Some(&comp)); + // Only the Advanced 3D renderer lights with Environment lights, so adding one switches a + // Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). let env = kind == LightKind::Environment; let (id, switched) = s.edit("New Light", None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Light { kind }, (comp.width, comp.height), None); apply_light(&mut l, p, t, "layer.newLight")?; let id = insert_layer(proj, st, cid, l)?; - // An Environment light's image-based light only exists in Advanced 3D, so adding one - // switches a Classic 3D comp to it, as model layers do (#2436). - Ok((id, env && use_advanced_3d(proj, cid)?)) + Ok((id, if env { use_advanced_3d(proj, cid)? } else { false })) })?; - report_switch(s, cid, switched, "lights Environment lights"); + report_switch(s, cid, switched, "which Environment lights need to light the scene"); Ok(json!({"layer": id.0, "advanced3d": env})) } @@ -257,11 +257,9 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { let switched = s.edit("Light Settings", None, |proj, _| { let old = proj.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?; let mut l = old.clone(); - let mut env = false; if let Some(k) = kind && old.source != (LayerSource::Light { kind: k }) { - env = k == LightKind::Environment; // Light Type change: rebuild the light's groups, carrying over shared values. let fresh = build::layer(proj, &comp, &old.name, LayerSource::Light { kind: k }, (comp.width, comp.height), None); l.source = fresh.source.clone(); @@ -296,12 +294,13 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { l.name = n.clone(); } apply_light(&mut l, p, t, "layer.lightSettings")?; + // The resulting kind (kept or changed to): an Environment light in a Classic 3D comp + // switches it, like adding one does. + let env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment }); *layer_mut(proj, cid, lid)? = l; - // Changing the type to Environment: its image-based light only exists in Advanced 3D, - // so the comp switches to it, as model layers do (#2436). - Ok(env && use_advanced_3d(proj, cid)?) + Ok(if env { use_advanced_3d(proj, cid)? } else { false }) })?; - report_switch(s, cid, switched, "lights Environment lights"); + report_switch(s, cid, switched, "which Environment lights need to light the scene"); Ok(Value::Null) } diff --git a/crates/engine/src/tests_3d.rs b/crates/engine/src/tests_3d.rs index 1b5deceda..1cef04014 100644 --- a/crates/engine/src/tests_3d.rs +++ b/crates/engine/src/tests_3d.rs @@ -1,6 +1,6 @@ //! 3D commands: cameras, lights, views, camera tools (with undo/redo), and the 3D demo comp. -use effectcraft_project::{AutoOrient, LayerSource, LightKind}; +use effectcraft_project::{AutoOrient, LayerSource, LightKind, Renderer}; use effectcraft_render::three_d::View3D; use serde_json::json; @@ -148,41 +148,43 @@ fn deleting_a_source_layer_clears_the_reference() { assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some()); } -/// An Environment light only lights Advanced 3D comps, so adding one (or changing a light's type -/// to Environment) switches a Classic 3D comp to it in the same undo step, with a toast, as -/// model layers do (#2436). +/// #2436: only the Advanced 3D renderer lights with Environment lights, and new comps are +/// Classic 3D, so one had no visible effect. Adding one (or changing a light into one) switches +/// the comp to Advanced 3D (in the same undo step, with a toast), as After Effects does. #[test] -fn environment_light_switches_classic_comps_to_advanced_3d() { +fn environment_lights_switch_classic_comps_to_advanced_3d() { let toasts = |s: &mut Session| -> Vec { s.drain_events().into_iter().filter_map(|e| if let crate::Event::Toast { message, .. } = e { Some(message) } else { None }).collect() }; let mut s = session(); let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); - assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); toasts(&mut s); let r = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); assert_eq!(r["advanced3d"], true); - assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D); - assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D")), "the switch is explained"); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D); + // As with Composition Settings: the text layer gets Geometry Options. + assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).unwrap().props.sub("geometryOptions").is_some()); + assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D"))); // A second Environment light leaves the comp (and the user) alone. s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); assert!(toasts(&mut s).is_empty()); - // One undo step removes the light and the switch together. + // Undoing the creations takes the lights and the switch back together. s.undo(); - assert_eq!(s.active_comp().unwrap().layers.len(), 2, "the second light is gone, the first stays"); - assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D, "the first light's switch still stands"); s.undo(); - assert_eq!(s.active_comp().unwrap().layers.len(), 1); - assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); - assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).is_some()); - // Changing a light's type to Environment switches too, and undoes back. + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + assert_eq!(layer_count(&s), 1); + // Changing a light to Environment switches too, and undoes with the type. let spot = s.execute("layer.newLight", json!({"kind": "Spot"})).unwrap()["layer"].as_u64().unwrap(); - assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); s.execute("layer.lightSettings", json!({"layer": spot, "kind": "Environment"})).unwrap(); - assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D); s.undo(); - assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); assert_eq!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(spot)).unwrap().source, LayerSource::Light { kind: LightKind::Spot }); + // Other light types never switch it. + s.execute("layer.newLight", json!({"kind": "Point"})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); // Switching back to Classic 3D with an Environment light in the comp explains itself. s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); toasts(&mut s); @@ -202,8 +204,10 @@ fn environment_light_takes_shadow_options_but_no_falloff() { assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0); assert_eq!(prop(&s, env, "lightOptions/shadowDiffusion").as_f64(), 12.0); - let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap(); - assert!(l.props.prop("lightOptions/falloff").is_none(), "an environment light has no Falloff"); + assert!( + s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/falloff").is_none(), + "an environment light has no Falloff" + ); // The shadow values carry over when the type changes, both ways. s.execute("layer.lightSettings", json!({"layer": env, "kind": "Spot", "coneAngle": 70})).unwrap(); assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index 24c5a3b35..f526c6653 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -77,7 +77,7 @@ fn buffer_bytes(count: u64, stride: u64, limit: u64) -> Option { /// Checking these before serialization bounds host packing as well as the GPU uploads. fn packed_bytes(counts: [u64; 6], uniform_limit: u64, storage_limit: u64, buffer_limit: u64) -> Option<[u64; 7]> { let mut sizes = [0; 7]; - sizes[0] = buffer_bytes(40, 4, uniform_limit.min(buffer_limit))?; + sizes[0] = buffer_bytes(44, 4, uniform_limit.min(buffer_limit))?; for (index, (count, stride)) in counts.into_iter().zip([96, 16, 16, 80, 80, 4]).enumerate() { let size = count.checked_mul(stride)?.max(16); sizes[index + 1] = buffer_bytes(size, 1, storage_limit.min(buffer_limit))?; @@ -164,11 +164,11 @@ mod capability_tests { #[test] fn packed_arrays_preflight_exact_strides_and_empty_padding() { - assert_eq!(packed_bytes([0; 6], 160, 16, 160), Some([160, 16, 16, 16, 16, 16, 16])); - assert_eq!(packed_bytes([2; 6], 160, 192, 192), Some([160, 192, 32, 32, 160, 160, 16])); - assert!(packed_bytes([0; 6], 159, 16, 160).is_none()); - assert!(packed_bytes([0; 6], 160, 15, 160).is_none()); - assert!(packed_bytes([2; 6], 160, 192, 191).is_none()); + assert_eq!(packed_bytes([0; 6], 176, 16, 176), Some([176, 16, 16, 16, 16, 16, 16])); + assert_eq!(packed_bytes([2; 6], 176, 192, 192), Some([176, 192, 32, 32, 160, 160, 16])); + assert!(packed_bytes([0; 6], 175, 16, 176).is_none()); + assert!(packed_bytes([0; 6], 176, 15, 176).is_none()); + assert!(packed_bytes([2; 6], 176, 192, 191).is_none()); for index in 0..6 { let mut counts = [0; 6]; counts[index] = u64::MAX; @@ -203,7 +203,7 @@ mod capability_tests { }; for (scene, counts) in [(Scene::default(), [0; 6]), (scene, [1; 6])] { let actual = pack(&scene).map(|bytes| bytes.len() as u64); - assert_eq!(Some(actual), packed_bytes(counts, 160, 160, 160)); + assert_eq!(Some(actual), packed_bytes(counts, 176, 176, 176)); } } } @@ -315,10 +315,23 @@ fn pack(s: &Scene) -> [Vec; 7] { globals.extend([s.eye[0], s.eye[1], s.eye[2], s.ortho as u32 as f32]); globals.extend([s.cam_fwd[0], s.cam_fwd[1], s.cam_fwd[2], s.lights.len() as f32]); globals.extend([s.ambient[0], s.ambient[1], s.ambient[2], s.env.is_some() as u32 as f32]); - let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo { radiance: 0, mips: 1, irradiance: 0, intensity: 0.0, rotation: 0.0 }); + let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo { + radiance: 0, + mips: 1, + irradiance: 0, + intensity: 0.0, + rotation: 0.0, + sun: [0.0; 3], + shadow: -1, + shadow_darkness: 0.0, + shadow_diffusion: 0.0, + }); globals.extend([e.radiance as f32, e.mips as f32, e.irradiance as f32, e.intensity]); let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]); - globals.extend([e.rotation, lit as u32 as f32, 0.0, 0.0]); + // env2: rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness. + globals.extend([e.rotation, lit as u32 as f32, e.shadow as f32, e.shadow_darkness]); + // The environment's dominant direction (its shadow's source), for the shadow bias slope. + globals.extend([e.sun[0], e.sun[1], e.sun[2], 0.0]); let mats = s.materials.iter().flat_map(|m| { let flags = (m.double_sided as u32) | (m.unlit as u32) << 1 diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 4eea78c8c..47c3a65e1 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -14,8 +14,10 @@ struct Globals { ambient: vec4, // radiance first texture, mip count, irradiance texture, intensity env: vec4, - // rotation, any lighting (0/1), -, - + // rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness env2: vec4, + // the environment's dominant direction (its shadow's source) + env_sun: vec4, }; @group(0) @binding(0) var g: Globals; @@ -318,6 +320,16 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, return 1.0 - darkness * (1.0 - f); } +// The Environment light's shadow from its dominant direction (shade::env_shadow). +fn env_shadow(p: vec3, ng: vec3, n: vec3) -> f32 { + if (g.env2.z < 0.0) { + return 1.0; + } + let n_l = clamp(dot(n, g.env_sun.xyz), 0.0, 1.0); + let f = shadow_factor(u32(g.env2.z + 0.5) * SHADOW_STRIDE, p, ng, n_l); + return 1.0 - g.env2.w * (1.0 - f); +} + fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_inner: f32, cos_outer: f32, dir: vec3, to_l: vec3, dist: f32) -> f32 { var k = 1.0; if (falloff == 1u && kind != 0u) { @@ -404,6 +416,10 @@ fn shadow_only_amount(unlit: bool, normal: vec3, front: bool, pos: vec3 0.5) { + amount = max(amount, 1.0 - env_shadow(pos, n, n)); + } return amount; } @@ -543,7 +559,11 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut { let f0 = mix(vec3(0.04 * spec_k), rgb, metallic); let ab = env_brdf(n_v, rough); let fs = f0 * ab.x + vec3(ab.y); - acc = acc + (irr * rgb * (vec3(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao; + var esh = 1.0; + if (receives_shadows) { + esh = env_shadow(i.pos, ng, n); + } + acc = acc + (irr * rgb * (vec3(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao * esh; } let c = acc + emissive; out.color = vec4(c * alpha, alpha); diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs index 21422aa6b..3ca3c8130 100644 --- a/crates/gpu/src/tests_adv3d.rs +++ b/crates/gpu/src/tests_adv3d.rs @@ -5,7 +5,7 @@ //! edges may be covered by the other neighbour. Interior pixels agree within 2/255; at most //! 1.5 % of pixels (silhouettes) may differ more. Skipped without an adapter. -use effectcraft_keyframe::Value; +use effectcraft_keyframe::{Gradient, Value}; use effectcraft_project::build; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid}; use effectcraft_render::three_d::adv::{self, Scene}; @@ -38,7 +38,8 @@ fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) { } /// Primitives with metal/dielectric materials, a shadow-casting spot light and a point light, -/// an environment light, a semi-transparent textured card. +/// a shadow-casting Environment light over a sky-gradient environment layer, a semi-transparent +/// textured card. pub(crate) fn scene_project() -> (Project, ItemId) { let mut p = Project::default(); let mut c = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); @@ -73,12 +74,29 @@ pub(crate) fn scene_project() -> (Project, ItemId) { set(l, "transform/position", Value::Vec3([120.0, 80.0, -60.0])); set(l, "transform/rotationY", Value::Scalar(30.0)); }); - let env = p.add_item("E", Default::default(), None, ItemKind::Solid(Solid { color: [0.6, 0.7, 1.0], width: 32, height: 16, pixel_aspect: 1.0 })); - add(&mut p, cid, LayerSource::Solid { item: env }, |l| { - l.environment = true; + // The Environment Layer: a sky-to-ground gradient (its dominant region, the bright sky, + // shadows the scene from above). + add(&mut p, cid, LayerSource::Shape, |l| { l.switches.three_d = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + let rect = build::shape_rect(ids, [240.0, 160.0], [0.0, 0.0], 0.0); + let gfill = build::shape_gradient_fill( + ids, + false, + [0.0, -80.0], + [0.0, 80.0], + Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.04, 0.05, 0.08, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }, + ); + let g = build::shape_group(ids, "Sky", vec![rect, gfill]); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + l.environment = true; + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/intensity", Value::Scalar(50.0)); + set(l, "lightOptions/castsShadows", Value::Bool(true)); + set(l, "lightOptions/shadowDiffusion", Value::Scalar(6.0)); }); - add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| set(l, "lightOptions/intensity", Value::Scalar(50.0))); add(&mut p, cid, LayerSource::Light { kind: LightKind::Spot }, |l| { set(l, "transform/position", Value::Vec3([40.0, -120.0, -200.0])); set(l, "transform/poi", Value::Vec3([120.0, 100.0, 0.0])); @@ -135,6 +153,8 @@ fn gpu_matches_cpu_lit_scene() { let (p, cid) = scene_project(); let s = scene(&p, cid); assert!(!s.shadows.is_empty() && s.env.is_some() && s.opaque_count < s.indices.len() as u32); + // The Environment light casts its shadow (the sky gradient's dominant direction). + assert!(s.env.is_some_and(|e| e.shadow >= 0 && e.sun != [0.0; 3]), "the environment shadow map is built"); compare(&s, &g); } diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index d01116816..d7f0ab191 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -656,7 +656,8 @@ pub fn light_options(ids: &mut Ids, kind: LightKind) -> PropGroup { // The environment image: a layer (footage, comp…) mapped as an equirectangular panorama; // none = the comp's Environment Layer, or a flat default environment of the light's // colour × intensity when there is none. No Falloff: an environment light shines from - // every direction at once (#2436). + // every direction at once. Environment lights cast shadows too (After Effects' Light + // Settings shows Cast Shadows for them; not for Ambient) (#2436). return g .with(ids.prop("source", "Source", Value::Layer(None)).with_ui(ParamUi::Layer)) .with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 395057935..8c1572d73 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -500,11 +500,19 @@ pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32]) sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() } -/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`). +/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`), and of +/// the Environment light when it casts shadows and its image has a dominant direction (marked +/// `env.shadow == 0`, `env.sun != [0; 3]`): one map from that direction, like a parallel light. pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { let casters: Vec = (0..s.indices.len() / 3) .filter(|&t| s.materials.get(s.vertices[s.indices[3 * t] as usize].material as usize).is_some_and(|m| m.casts_shadows)) .collect(); + // The Environment light casts a shadow when marked and its image has a dominant direction; + // with nothing to cast one, its mark is cleared either way. + let env_shadow = !casters.is_empty() && s.env.is_some_and(|e| e.shadow == 0 && e.sun != [0.0; 3]); + if !env_shadow && let Some(e) = &mut s.env { + e.shadow = -1; + } if casters.is_empty() { for l in &mut s.lights { l.shadow = -1; @@ -536,6 +544,8 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { 1 } }) + // The Environment light's map is a single orthographic one. + .chain(std::iter::once(if env_shadow { 1 } else { 0 })) .collect(); let sizes = shadow_sizes(s.width, s.height, draft, &kinds); let mut maps: Vec = vec![]; @@ -583,6 +593,31 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { } s.lights[li].shadow = first; } + // The Environment light's shadow: one orthographic map over the scene from its dominant + // direction (the light travels from the bright region into the scene), after the lights'. + if env_shadow && let Some(e) = s.env { + match sizes.get(s.lights.len()).copied().flatten() { + Some(size) => { + let first = maps.len() as i32; + let soft = 1.0 + (e.shadow_diffusion * 0.5).min(24.0); + let sun = e.sun.map(|v| v as f64); + let dir = [-sun[0], -sun[1], -sun[2]]; + let eye = [centre[0] + sun[0] * radius * 2.0, centre[1] + sun[1] * radius * 2.0, centre[2] + sun[2] * radius * 2.0]; + let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }; + let focal = (size as f64 * 0.5 / radius) as f32; + maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft }); + if let Some(e) = &mut s.env { + e.shadow = first; + } + } + // The texel budget dropped it. + None => { + if let Some(e) = &mut s.env { + e.shadow = -1; + } + } + } + } let texels: Vec> = maps.par_iter().map(|m| shadow_raster(s, m, &casters)).collect(); for (m, t) in maps.iter_mut().zip(texels) { m.offset = s.shadow_texels.len() as u32; diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 56a5e6605..121254fd2 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -172,6 +172,16 @@ pub struct EnvInfo { pub intensity: f32, /// Radians about the vertical axis. pub rotation: f32, + /// Unit direction from the scene towards the environment's dominant (radiance-weighted) + /// bright region — the direction its shadow is cast from, like a parallel light's + /// `to_light`. `[0; 3]` when the image is directionless (uniform): no shadow then. + pub sun: [f32; 3], + /// Shadow map index into [`Scene::shadows`]: 0 marked (assigned by `raster::shadow_maps`), + /// -1 casts no shadow. Gated by the light's Cast Shadows. + pub shadow: i32, + pub shadow_darkness: f32, + /// The light's Shadow Diffusion (the penumbra width the shadow map's PCF widens to). + pub shadow_diffusion: f32, } #[derive(Clone, Debug, Default)] @@ -670,9 +680,20 @@ fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { } } -/// Evaluated lights: (scene lights, ambient sum, environment light (layer, intensity × colour, -/// rotation)). -fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option<(Option, [f32; 3], f32)>) { +/// The evaluated Environment light: its image source, intensity × colour, rotation, and +/// shadow settings (After Effects' Light Settings shows Cast Shadows for Environment lights). +#[derive(Clone, Copy)] +struct EnvLight { + src: Option, + color: [f32; 3], + rot: f32, + casts_shadows: bool, + shadow_darkness: f64, + shadow_diffusion: f64, +} + +/// Evaluated lights: (scene lights, ambient sum, environment light). +fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option) { let mut out = vec![]; let mut ambient = [0.0f32; 3]; let mut env = None; @@ -693,7 +714,14 @@ fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option<(Option { @@ -783,8 +811,47 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec out } +/// The environment image's dominant direction: the radiance-weighted mean texel direction +/// (solid-angle weighted, rotation applied), or `[0; 3]` when the image is directionless +/// (a uniform environment cancels to zero). The mean of a single bright spot on black points +/// straight at it; a gradient points somewhere between its ends. +fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f32; 3] { + use super::shade::equirect_dir; + let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32); + let mut weight = 0.0f32; + for (i, p) in d.iter().enumerate() { + let (tx, ty) = ((i % w as usize) as u32, (i / w as usize) as u32); + let (u, v) = ((tx as f32 + 0.5) / w as f32, (ty as f32 + 0.5) / h as f32); + // Luminance × solid angle (∝ sin θ). + let lum = (p[0] + p[1] + p[2]) * (v * std::f32::consts::PI).sin(); + let dir = equirect_dir(u, v, rot); + x += dir[0] * lum; + y += dir[1] * lum; + z += dir[2] * lum; + weight += lum.abs(); + } + // Under 1 % directional, the image is uniform to the eye: no dominant region to shadow from. + let len = (x * x + y * y + z * z).sqrt(); + if weight <= 1e-9 || len < weight * 0.01 { + return [0.0; 3]; + } + [x / len, y / len, z / len] +} + /// Prefilter an environment image into the scene's textures. -pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4]>, intensity: [f32; 3], rotation: f32) { +pub(crate) fn add_environment( + b: &mut Builder, + w: u32, + h: u32, + data: Vec<[f32; 4]>, + intensity: [f32; 3], + rotation: f32, + casts_shadows: bool, + shadow_darkness: f64, + shadow_diffusion: f64, +) { + // The shadow direction from the full-resolution image, before the mip chain averages it. + let sun = dominant_direction(w, h, &data, rotation); // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { @@ -820,7 +887,18 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4 } } } - b.s.env = Some(EnvInfo { radiance: first, mips: levels, irradiance: ii, intensity: k, rotation }); + b.s.env = Some(EnvInfo { + radiance: first, + mips: levels, + irradiance: ii, + intensity: k, + rotation, + sun, + // Marked; `raster::shadow_maps` assigns the map (a directionless image casts none). + shadow: if casts_shadows { 0 } else { -1 }, + shadow_darkness: shadow_darkness as f32, + shadow_diffusion: shadow_diffusion as f32, + }); } /// The environment layer's pixels as linear straight RGBA. @@ -935,13 +1013,24 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: let (ls, amb, env) = lights(ctx, lin); b.s.lights = ls; b.s.ambient = amb; - if let Some((src, k, rot)) = env { - match env_pixels(r, ctx, src, lin) { - Some((w, h, d)) => add_environment(&mut b, w, h, d, k, rot), - // No Source and no Environment Layer: a flat default environment, as After - // Effects does — the light's colour × intensity light the scene from every - // direction (#2436). - None => add_environment(&mut b, 8, 4, vec![[1.0, 1.0, 1.0, 1.0]; 32], k, rot), + if let Some(env) = env { + match env_pixels(r, ctx, env.src, lin) { + Some((w, h, d)) => add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion), + // No Source and no Environment Layer: a flat default environment of the light's + // colour × intensity — After Effects "uses a default environment map" for an + // Environment light — so the light always lights the scene per its parameters. + // A uniform environment has no dominant region, so it casts no shadow (#2436). + None => add_environment( + &mut b, + 8, + 4, + vec![[1.0, 1.0, 1.0, 1.0]; 32], + env.color, + env.rot, + env.casts_shadows, + env.shadow_darkness, + env.shadow_diffusion, + ), } } if r.opts.shadows() { @@ -951,6 +1040,9 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: for l in &mut b.s.lights { l.shadow = -1; } + if let Some(e) = &mut b.s.env { + e.shadow = -1; + } } } // Camera matrices. diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index c7c1a40f1..af4653f6a 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -351,6 +351,19 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) - 1.0 - l.shadow_darkness * (1.0 - f) } +/// The visibility of the Environment light's dominant direction at `p` (1 = unshadowed), +/// darkened by its Shadow Darkness. A directionless image (or a non-casting light) has no map +/// and shades unshadowed. +pub fn env_shadow(s: &Scene, e: &EnvInfo, p: [f32; 3], ng: [f32; 3], n: [f32; 3]) -> f32 { + if e.shadow < 0 || e.sun == [0.0; 3] { + return 1.0; + } + let Some(m) = s.shadows.get(e.shadow as usize) else { return 1.0 }; + let n_l = dot(n, e.sun).clamp(0.0, 1.0); + let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l); + 1.0 - e.shadow_darkness * (1.0 - f) +} + /// Unit direction from `p` to light `l`, and the distance (infinite for a parallel light). fn to_light(l: &Light, p: [f32; 3]) -> ([f32; 3], f32) { if l.kind == 0 { @@ -498,9 +511,11 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> { let (irr, spec) = env_light(s, e, n, r, rough); let f0 = [mix(0.04 * m.specular_k, rgb[0], metallic), mix(0.04 * m.specular_k, rgb[1], metallic), mix(0.04 * m.specular_k, rgb[2], metallic)]; let (a, b) = env_brdf(n_v, rough); + // The Environment light's shadow darkens its own diffuse and specular. + let sh = if m.receives_shadows { env_shadow(s, e, f.pos, ng, n) } else { 1.0 }; for c in 0..3 { let fs = f0[c] * a + b; - out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao; + out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao * sh; } } let c = add(out, emissive); @@ -526,5 +541,9 @@ fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] { } amount = amount.max(1.0 - light_shadow(s, l, f.pos, n, n_l)); } + // The Environment light's shadow counts too (it reaches every surface: no falloff). + if let Some(e) = &s.env { + amount = amount.max(1.0 - env_shadow(s, e, f.pos, n, n)); + } [0.0, 0.0, 0.0, alpha * amount] } diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 2fb22a6e4..26ac78cda 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -338,8 +338,8 @@ fn cards_and_environment_light() { } /// An Environment light with no Source and no Environment Layer lights the scene with a flat -/// default environment of its colour × intensity, as After Effects does (#2436) — not nothing -/// at all. +/// default environment of its colour × intensity — After Effects "uses a default environment +/// map" for an Environment light — not nothing at all (#2436). #[test] fn environment_light_without_a_source_lights_the_scene() { let (mut p, cid) = project(120, 90); @@ -376,6 +376,91 @@ fn environment_light_without_a_source_lights_the_scene() { assert!(c5[0] > 0.15 && c5[1] < 0.05 && c5[2] < 0.05, "red environment: {c5:?}"); } +/// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant +/// (brightest) region shadows the scene. A bright disc turned to shine from behind the camera +/// (rotation 180°) shadows a sphere onto the plane behind it; Cast Shadows off lights it evenly. +#[test] +fn environment_lights_cast_shadows() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + // The Environment Layer: a bright disc at the image's centre on black. + add(&mut p, cid, LayerSource::Shape, |l| { + l.switches.three_d = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + let rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0); + let gfill = build::shape_gradient_fill( + ids, + true, + [0.0, 0.0], + [0.0, 75.0], + effectcraft_keyframe::Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }, + ); + let g = build::shape_group(ids, "Sky", vec![rect, gfill]); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + l.environment = true; + }); + // Turned 180°, the disc shines from behind the camera, straight at the floor. + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/rotation", Value::Scalar(180.0)); + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let img = render(&p, cid); + let (mid, side) = (img.data[75 * 200 + 100], img.data[75 * 200 + 20]); + assert!(mid[3] > 0.99 && side[3] > 0.99); + assert!(mid[0] < side[0] * 0.5, "the disc shadows the sphere onto the floor: {mid:?} vs {side:?}"); + // Cast Shadows off: the floor is lit everywhere. + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let img = render(&p, cid); + let mid = img.data[75 * 200 + 100]; + assert!(mid[0] > side[0] * 0.8, "no shadow without Cast Shadows: {mid:?} vs {side:?}"); +} + +/// #2436: a uniform environment has no dominant region, so even with Cast Shadows on it casts +/// nothing — the render is the same as with the option off. +#[test] +fn a_uniform_environment_casts_no_shadow() { + let (mut p, cid) = project(120, 90); + let card = p.add_item( + "S", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: card }, |l| l.switches.three_d = true); + let env = p.add_item( + "E", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 64, height: 32, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: env }, |l| { + l.environment = true; + l.switches.three_d = true; + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let casting = render(&p, cid); + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let off = render(&p, cid); + assert!(casting.data[20 * 120 + 30][0] > 0.3, "the card is lit"); + assert_eq!(casting.data, off.data, "a directionless image casts no shadow"); +} + #[test] fn depth_of_field_blurs_out_of_focus_surfaces() { let mut img = Image::new(64, 64); diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 80e18ce30..2a9590872 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -44,14 +44,15 @@ pub struct LightDraft { const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"]; -/// Whether a light type has Falloff options: Ambient and Environment lights come from no one -/// place, so they have none — an environment light shines from every direction at once. +/// Whether a light type has Falloff options (distance attenuation): image-based Environment +/// lights and Ambient come from no one place, so they have none. fn falls_off(kind: LightKind) -> bool { !matches!(kind, LightKind::Ambient | LightKind::Environment) } -/// Whether a light type has Shadows options. Every light but Ambient can cast shadows, -/// Environment lights included (their image-based light occludes) — as in After Effects (#2436). +/// Whether a light type has Shadows options in the dialog. After Effects' Light Settings shows +/// Cast Shadows (with Shadow Darkness and Diffusion) for every light but Ambient — an +/// Environment light's shadow comes from its image, not a falloff. fn casts_shadows(kind: LightKind) -> bool { kind != LightKind::Ambient } @@ -315,7 +316,8 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } if casts_shadows(d.kind) { ui.label(tr("Shadows")); - ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); + let cb = ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); + app.auto.add_response("dialog.light.casts", &cb, "Casts Shadows"); ui.end_row(); ui.label(tr("Shadow Darkness")); ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %"))); diff --git a/crates/ui-egui/tests/it/ui_light_settings.rs b/crates/ui-egui/tests/it/ui_light_settings.rs index 0183e0324..aac090802 100644 --- a/crates/ui-egui/tests/it/ui_light_settings.rs +++ b/crates/ui-egui/tests/it/ui_light_settings.rs @@ -177,28 +177,31 @@ fn falloff_and_shadow_options_show_only_for_lights_that_have_them() { invoke(&mut h, "layer.newLight", json!({})); for k in LightKind::ALL { choose(&mut h, k); + // Falloff is distance attenuation: image-based Environment lights and Ambient have none. let falloff = !matches!(k, LightKind::Ambient | LightKind::Environment); - let shadows = k != LightKind::Ambient; assert_eq!(h.query_by_label("Falloff").is_some(), falloff, "Falloff for {}", k.label()); + // Cast Shadows shows for every light but Ambient, Environment included (#2436). + let shadows = k != LightKind::Ambient; for label in ["Casts Shadows", "Shadow Darkness"] { assert_eq!(h.query_by_label(label).is_some(), shadows, "{label} for {}", k.label()); } } } -/// An Environment light created through the dialog takes a Casts Shadows tick, like the other -/// light types (only Ambient has none) — #2436. +/// #2436: an Environment light created through the dialog keeps its Cast Shadows, which the +/// engine stores on the layer. #[test] -fn an_environment_light_created_with_casts_shadow_takes_it() { +fn an_environment_light_takes_cast_shadows_from_the_dialog() { let mut h = harness(); invoke(&mut h, "layer.newLight", json!({})); choose(&mut h, LightKind::Environment); - h.get_by_label("Casts Shadows").click(); + click_id(&mut h, "dialog.light.casts"); ok(&mut h); let id = last_layer(&h); assert_eq!(kind(&h, id), LightKind::Environment); - assert!(layer(&h, id).props.prop("lightOptions/castsShadows").unwrap().value.as_bool()); - assert!(layer(&h, id).props.prop("lightOptions/falloff").is_none(), "but no Falloff"); + let l = layer(&h, id); + assert!(l.props.prop("lightOptions/castsShadows").unwrap().value.as_bool(), "the dialog's checkbox reached the layer"); + assert!(l.props.prop("lightOptions/shadowDarkness").is_some() && l.props.prop("lightOptions/falloff").is_none()); } #[test] diff --git a/docs/agents.md b/docs/agents.md index b14c135fc..1ce30f076 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -214,9 +214,11 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built image that metallic surfaces reflect. Set its `lightOptions/source` to a footage, comp or solid layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`), or a flat default environment of the light's colour × intensity when there is none — either - way the light brightens the scene, and it has `castsShadows`/`shadowDarkness`/`shadowDiffusion` - like the other lights. It only lights Advanced 3D comps, so creating one (or changing a light's - type to Environment) switches a Classic 3D comp to Advanced 3D + way the light brightens the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + like the other lights: Cast Shadows shades the scene from the image's dominant bright region + (a flat or otherwise uniform environment has none, so it casts no shadow). It only lights + Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a + Classic 3D comp to Advanced 3D ([#261](https://github.com/storytold/effectcraft/issues/261), [#2436](https://github.com/storytold/effectcraft/issues/2436)). diff --git a/docs/architecture.md b/docs/architecture.md index 9b8964505..f8b2c2bb5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -161,9 +161,11 @@ light from an Environment light whose Source (or the comp's Environment Layer, L Environment Layer, or a flat default environment of the light's colour × intensity when there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps -(spot: perspective, parallel: orthographic over the scene, point: six cube faces). An -Environment light only lights Advanced 3D comps, so adding one (or changing a light's type to -it) switches a Classic 3D comp to Advanced 3D, as model layers do. Cards keep +(spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: +one orthographic from the image's dominant bright region — none when the image is +directionless, as a flat environment is). An Environment light only lights Advanced 3D comps, +so adding one (or changing a light's type to it) switches a Classic 3D comp to Advanced 3D, +as model layers do. Cards keep Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular); models and primitives add Base Color, Metallic, Roughness, Emissive. Rasterisation runs at 2×2 supersampling with a reversed-Z depth buffer; opaque triangles resolve visibility first, the From 05390988873499c83372590e84180c350891baec Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 13:40:53 +0000 Subject: [PATCH 035/112] =?UTF-8?q?Environment=20lights:=20review=20fixes?= =?UTF-8?q?=20=E2=80=94=20edit=20keeps=20Classic=203D,=20NaN=20sun=20guard?= =?UTF-8?q?ed=20(#2436)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small fixes from the review of the shadow commit: - light_settings switched a Classic 3D comp to Advanced 3D whenever the edited light was an Environment light (the resulting kind), so editing the intensity of one in a comp the user had switched back to Classic 3D dragged the renderer back. The switch now happens only on the non-Environment to Environment transition (the sources before and after), matching the docs; ordinary edits leave the renderer to the user. - dominant_direction's thresholds let NaN through (every NaN comparison is false): a NaN or infinite texel produced a NaN sun direction and NaN shading with it. A non-finite weight or length now returns no direction — garbage casts no shadow. - add_environment walked the full-resolution image for the dominant direction even when the light casts no shadows, where nothing reads it. Only a shadow-casting light pays for the pass now. Tests: editing an existing Environment light in a Classic 3D comp keeps the renderer and stays toast-silent; a NaN and an infinite environment image yield no sun direction. --- crates/engine/src/commands/three_d.rs | 9 +++++---- crates/engine/src/tests_3d.rs | 6 +++++- crates/render/src/three_d/adv/scene.rs | 15 +++++++++------ crates/render/src/three_d/adv/tests.rs | 13 ++++++++++++- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index c5a46b6c3..ed2df85a4 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -294,11 +294,12 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { l.name = n.clone(); } apply_light(&mut l, p, t, "layer.lightSettings")?; - // The resulting kind (kept or changed to): an Environment light in a Classic 3D comp - // switches it, like adding one does. - let env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment }); + // A light that becomes an Environment light switches a Classic 3D comp to Advanced 3D, + // like adding one does; editing one that already is leaves the renderer to the user. + let was_env = matches!(old.source, LayerSource::Light { kind: LightKind::Environment }); + let is_env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment }); *layer_mut(proj, cid, lid)? = l; - Ok(if env { use_advanced_3d(proj, cid)? } else { false }) + Ok(if !was_env && is_env { use_advanced_3d(proj, cid)? } else { false }) })?; report_switch(s, cid, switched, "which Environment lights need to light the scene"); Ok(Value::Null) diff --git a/crates/engine/src/tests_3d.rs b/crates/engine/src/tests_3d.rs index 1cef04014..748f8a32f 100644 --- a/crates/engine/src/tests_3d.rs +++ b/crates/engine/src/tests_3d.rs @@ -186,10 +186,14 @@ fn environment_lights_switch_classic_comps_to_advanced_3d() { s.execute("layer.newLight", json!({"kind": "Point"})).unwrap(); assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); // Switching back to Classic 3D with an Environment light in the comp explains itself. - s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); + let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap(); toasts(&mut s); s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap(); assert!(toasts(&mut s).iter().any(|m| m.contains("Environment light")), "the Classic 3D limit is explained"); + // Editing that Environment light (no type change) leaves the renderer the user chose. + s.execute("layer.lightSettings", json!({"layer": env, "intensity": 80})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D, "an ordinary edit doesn't re-switch"); + assert!(toasts(&mut s).is_empty(), "and doesn't toast either"); } /// An Environment light takes Casts Shadows (with Shadow Darkness and Diffusion) like every diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 121254fd2..46264fcb1 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -174,7 +174,8 @@ pub struct EnvInfo { pub rotation: f32, /// Unit direction from the scene towards the environment's dominant (radiance-weighted) /// bright region — the direction its shadow is cast from, like a parallel light's - /// `to_light`. `[0; 3]` when the image is directionless (uniform): no shadow then. + /// `to_light`. `[0; 3]` when the image is directionless (uniform) or the light casts no + /// shadow (nothing reads it then): no shadow. pub sun: [f32; 3], /// Shadow map index into [`Scene::shadows`]: 0 marked (assigned by `raster::shadow_maps`), /// -1 casts no shadow. Gated by the light's Cast Shadows. @@ -815,7 +816,7 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec /// (solid-angle weighted, rotation applied), or `[0; 3]` when the image is directionless /// (a uniform environment cancels to zero). The mean of a single bright spot on black points /// straight at it; a gradient points somewhere between its ends. -fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f32; 3] { +pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f32; 3] { use super::shade::equirect_dir; let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32); let mut weight = 0.0f32; @@ -830,9 +831,10 @@ fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f32; 3] { z += dir[2] * lum; weight += lum.abs(); } - // Under 1 % directional, the image is uniform to the eye: no dominant region to shadow from. + // Under 1 % directional, the image is uniform to the eye: no dominant region to shadow + // from. A non-finite sum (a NaN or infinite texel) is garbage, not a direction: none then. let len = (x * x + y * y + z * z).sqrt(); - if weight <= 1e-9 || len < weight * 0.01 { + if !weight.is_finite() || !len.is_finite() || weight <= 1e-9 || len < weight * 0.01 { return [0.0; 3]; } [x / len, y / len, z / len] @@ -850,8 +852,9 @@ pub(crate) fn add_environment( shadow_darkness: f64, shadow_diffusion: f64, ) { - // The shadow direction from the full-resolution image, before the mip chain averages it. - let sun = dominant_direction(w, h, &data, rotation); + // The shadow direction from the full-resolution image, before the mip chain averages it — + // only a shadow-casting light reads it, so only one pays for the pass. + let sun = if casts_shadows { dominant_direction(w, h, &data, rotation) } else { [0.0; 3] }; // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 26ac78cda..7463839a2 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -2,7 +2,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, build}; use effectcraft_time::{FrameRate, Tick}; -use super::scene::{Material, Scene, TexInfo, Vertex, irradiance_map}; +use super::scene::{Material, Scene, TexInfo, Vertex, dominant_direction, irradiance_map}; use super::shade::{self, PI}; use super::*; use crate::{NoFootage, RenderOpts, Renderer}; @@ -67,6 +67,17 @@ fn constant_environment_has_constant_irradiance() { assert!(irr[16][0] > 0.9 && irr[15 * 32 + 16][0] < 0.1, "{:?} {:?}", irr[16], irr[15 * 32 + 16]); } +/// A NaN or infinite texel poisons the sums (every NaN comparison is false, so the thresholds +/// alone would pass it through as a NaN direction); an infinite one even where sin θ = 0 turns +/// into NaN. Either way the sum is garbage, not a direction: no sun, so no shadow (#2436). +#[test] +fn a_non_finite_environment_has_no_sun_direction() { + let nan = vec![[f32::NAN; 4]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &nan, 0.0), [0.0; 3]); + let inf = vec![[f32::INFINITY, 0.0, 0.0, 1.0]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &inf, 0.0), [0.0; 3]); +} + #[test] fn equirect_round_trip_and_env_brdf() { for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] { From 95e6ca9a2d456b88e8db97ac79d48c4200f99379 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 19:15:29 +0000 Subject: [PATCH 036/112] Test that an Environment light visibly lights the scene (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end regression tests for the issue's report, through the real command path (what the Light Settings dialog runs) and the real viewer: adding an Environment light to a Classic 3D comp switches it to Advanced 3D and changes the viewer's pixels — a red light at full intensity, the default white light, and the same on the GPU compositor (skips without an adapter, like ui_gpu). --- crates/ui-egui/tests/it/main.rs | 2 + crates/ui-egui/tests/it/ui_env_light_gpu.rs | 79 +++++++++++++++++++ crates/ui-egui/tests/it/ui_env_light_repro.rs | 75 ++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 crates/ui-egui/tests/it/ui_env_light_gpu.rs create mode 100644 crates/ui-egui/tests/it/ui_env_light_repro.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 6b4c08912..724b64b08 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -20,6 +20,8 @@ mod ui_drag_drop; mod ui_effect_controls; mod ui_effect_drop_highlight; mod ui_effect_names; +mod ui_env_light_gpu; +mod ui_env_light_repro; mod ui_font_search; mod ui_form_fit; mod ui_frame_step; diff --git a/crates/ui-egui/tests/it/ui_env_light_gpu.rs b/crates/ui-egui/tests/it/ui_env_light_gpu.rs new file mode 100644 index 000000000..171cc5728 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_env_light_gpu.rs @@ -0,0 +1,79 @@ +//! The Environment light through the app's GPU compositor (egui_kittest with wgpu): adding one +//! visibly changes the viewer's GPU frames (#2436). Skips without a GPU adapter (CI). + +use effectcraft_engine::Session; +use effectcraft_engine::project::Renderer; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::Harness; +use serde_json::json; + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + for _ in 0..4 { + h.step(); + } +} + +/// egui_kittest's wgpu setup on the adapter the headless compositor picked, so the viewer's GPU +/// compositor attaches (see ui_gpu.rs). +fn same_adapter(name: String) -> eframe::egui_wgpu::WgpuSetup { + let mut setup = egui_kittest::wgpu::default_wgpu_setup(); + if let eframe::egui_wgpu::WgpuSetup::CreateNew(c) = &mut setup { + let fallback = c.native_adapter_selector.take(); + c.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| { + let named = |a: &&eframe::wgpu::Adapter| format!("{} ({:?})", a.get_info().name, a.get_info().backend) == name; + match (adapters.iter().find(named), &fallback) { + (Some(a), _) => Ok(a.clone()), + (None, Some(f)) => f(adapters, surface), + (None, None) => adapters.first().cloned().ok_or_else(|| "No adapter found".to_owned()), + } + })); + } + setup +} + +/// A red Environment light at full intensity lights the scene on the GPU compositor too: the +/// viewer's GPU frames change when the light is added. +#[test] +fn environment_light_lights_the_scene_on_the_gpu() { + let Some(probe) = effectcraft_gpu::Gpu::headless() else { + eprintln!("no GPU adapter: skipping"); + return; + }; + let adapter = effectcraft_engine::render::Accelerator::name(&probe); + eprintln!("headless compositor adapter: {adapter}"); + let gl = adapter.ends_with("(Gl)"); + drop(probe); + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("comp.open", json!({"comp": "3D Showcase"})).unwrap(); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + s.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap(); + let mut h = Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .with_pixels_per_point(1.0) + .wgpu_setup(same_adapter(adapter)) + .build_eframe(|_| EffectcraftApp::new(s)); + settle(&mut h); + if gl && h.state().gpu_adapter().is_none() { + eprintln!("egui-wgpu's GL device has WebGL2 limits: the viewer stays on the CPU, skipping"); + return; + } + assert!(h.state().gpu_adapter().is_some(), "GPU compositor on egui-wgpu's device"); + let before = h.state_mut().viewer_pixels().expect("GPU frame before adding the light").pixels.clone(); + + h.state_mut().session.execute("layer.newLight", json!({"kind": "Environment", "color": [1.0, 0.0, 0.0], "intensity": 100.0})).unwrap(); + assert_eq!(h.state().session.active_comp().unwrap().renderer, Renderer::Advanced3D); + settle(&mut h); + let after = h.state_mut().viewer_pixels().expect("GPU frame after adding the light").pixels.clone(); + + assert_eq!(before.len(), after.len()); + let diff = before.iter().zip(&after).filter(|(a, b)| a != b).count(); + assert!(diff > 500, "a red Environment light at 100 % changed only {diff} GPU pixels"); +} diff --git a/crates/ui-egui/tests/it/ui_env_light_repro.rs b/crates/ui-egui/tests/it/ui_env_light_repro.rs new file mode 100644 index 000000000..78a26160f --- /dev/null +++ b/crates/ui-egui/tests/it/ui_env_light_repro.rs @@ -0,0 +1,75 @@ +//! Reproduction of issue #2436's report: an Environment light added through the real command +//! path (what the Light Settings dialog runs) must visibly light the scene in the viewer. + +use effectcraft_engine::Session; +use effectcraft_engine::project::Renderer; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::Harness; +use serde_json::json; + +fn app() -> EffectcraftApp { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("comp.open", json!({"comp": "3D Showcase"})).unwrap(); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + EffectcraftApp::new(s) +} + +/// Step the UI until background frame renders have landed. +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + for _ in 0..4 { + h.step(); + } +} + +fn changed_pixels(before: &[egui::Color32], after: &[egui::Color32]) -> usize { + before.iter().zip(after).filter(|(a, b)| a != b).count() +} + +/// An Environment light (red, full intensity, no source: the flat default environment) added to +/// a Classic 3D comp switches it to Advanced 3D and visibly changes the viewer's pixels. +#[test] +fn environment_light_lights_the_scene() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.state_mut().session.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap(); + h.run_steps(3); + settle(&mut h); + let before = h.state_mut().viewer_pixels().expect("frame before adding the light").pixels.clone(); + + h.state_mut().session.execute("layer.newLight", json!({"kind": "Environment", "color": [1.0, 0.0, 0.0], "intensity": 100.0})).unwrap(); + + assert_eq!(h.state().session.active_comp().unwrap().renderer, Renderer::Advanced3D, "the comp switched to Advanced 3D"); + settle(&mut h); + let after = h.state_mut().viewer_pixels().expect("frame after adding the light").pixels.clone(); + assert_eq!(before.len(), after.len()); + let diff = changed_pixels(&before, &after); + assert!(diff > 500, "a red Environment light at 100 % changed only {diff} pixels"); +} + +/// The literal action from the issue: default white Environment light, defaults everywhere — +/// even then the lighting model changes (Advanced 3D IBL instead of unlit/Classic), so pixels +/// must change somewhere in the comp frame. +#[test] +fn default_environment_light_changes_the_scene() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.state_mut().session.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap(); + h.run_steps(3); + settle(&mut h); + let before = h.state_mut().viewer_pixels().expect("frame before adding the light").pixels.clone(); + + h.state_mut().session.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); + + assert_eq!(h.state().session.active_comp().unwrap().renderer, Renderer::Advanced3D); + settle(&mut h); + let after = h.state_mut().viewer_pixels().expect("frame after adding the light").pixels.clone(); + assert_eq!(before.len(), after.len()); + let diff = changed_pixels(&before, &after); + assert!(diff > 500, "a default white Environment light changed only {diff} pixels"); +} From aff46dabb646849a71b56bd3ba6564a2c923817d Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 20:25:00 +0000 Subject: [PATCH 037/112] Cast shadows from the Environment light's default environment (#2436) After Effects' default environment map casts shadows out of the box: an Environment light with Cast Shadows on shadows the scene with no Source set. The flat default cancelled its dominant direction to zero, so the option did nothing until a Source image arrived. Give the default a soft studio gradient (bright above and toward the initial camera, dark below and behind), level-normalised so the light's colour x intensity still sets the overall brightness; its dominant region drives the existing shadow pipeline, CPU and GPU alike. --- crates/render/src/three_d/adv/scene.rs | 58 +++++++++++++++++++------- crates/render/src/three_d/adv/tests.rs | 54 ++++++++++++++++++++++++ docs/agents.md | 8 ++-- docs/architecture.md | 7 ++-- 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 46264fcb1..3d63db500 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -927,6 +927,41 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Optio Some((img.width, img.height, data)) } +/// The default environment when the light has no Source and no Environment Layer: a soft studio +/// gradient, bright above and toward the initial camera, dark below and behind it — After +/// Effects "uses a default environment map" for an Environment light too, and its default +/// casts shadows out of the box. The solid-angle-weighted mean is 1, so the light's colour × +/// intensity still sets the overall level, like the flat default it replaces; the light's +/// Rotation turns the key around the scene (#2436). +pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) { + use super::shade::{dot, equirect_dir}; + // The key region's centre: three quarters up, two thirds toward the viewer (the default + // camera looks +z from -z). + let key = [0.0f32, -0.75, -0.66]; + let (w, h) = (8u32, 4u32); + let mut data = Vec::with_capacity((w * h) as usize); + let (mut mean, mut weight) = (0.0f32, 0.0f32); + for y in 0..h { + let v = (y as f32 + 0.5) / h as f32; + let sa = (v * std::f32::consts::PI).sin(); + for x in 0..w { + let u = (x as f32 + 0.5) / w as f32; + let l = 0.5 + 0.9 * dot(equirect_dir(u, v, 0.0), key).max(0.0); + data.push([l, l, l, 1.0]); + mean += l * sa; + weight += sa; + } + } + // Keep the flat default's level: scale the gradient to a weighted mean of exactly 1. + let k = if mean.is_finite() && mean > 1e-6 { weight / mean } else { 1.0 }; + for p in &mut data { + for c in 0..3 { + p[c] *= k; + } + } + (w, h, data) +} + /// Supersampling factor: 2×2 unless draft or very large. fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 { if r.opts.draft || (out.0 as u64 * out.1 as u64) > 8_000_000 { 1 } else { 2 } @@ -1019,21 +1054,14 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: if let Some(env) = env { match env_pixels(r, ctx, env.src, lin) { Some((w, h, d)) => add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion), - // No Source and no Environment Layer: a flat default environment of the light's - // colour × intensity — After Effects "uses a default environment map" for an - // Environment light — so the light always lights the scene per its parameters. - // A uniform environment has no dominant region, so it casts no shadow (#2436). - None => add_environment( - &mut b, - 8, - 4, - vec![[1.0, 1.0, 1.0, 1.0]; 32], - env.color, - env.rot, - env.casts_shadows, - env.shadow_darkness, - env.shadow_diffusion, - ), + // No Source and no Environment Layer: the default environment, a soft studio + // gradient of the light's colour × intensity — After Effects "uses a default + // environment map" for an Environment light — so the light always lights the + // scene per its parameters, and its dominant region casts shadows (#2436). + None => { + let (w, h, d) = default_environment(); + add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion) + } } } if r.opts.shadows() { diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 7463839a2..d9e749d31 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -472,6 +472,60 @@ fn a_uniform_environment_casts_no_shadow() { assert_eq!(casting.data, off.data, "a directionless image casts no shadow"); } +/// #2436: the default environment (no Source, no Environment Layer) is level-preserving and +/// directional — its dominant region points up and toward the initial camera, so a +/// shadow-casting Environment light casts shadows out of the box, like After Effects' default. +#[test] +fn default_environment_keeps_the_level_and_points_up() { + let (w, h, d) = scene::default_environment(); + assert_eq!((w, h), (8, 4)); + let (mut mean, mut weight) = (0.0f32, 0.0f32); + for (i, p) in d.iter().enumerate() { + let v = ((i / w as usize) as f32 + 0.5) / h as f32; + let sa = (v * PI).sin(); + mean += (p[0] + p[1] + p[2]) / 3.0 * sa; + weight += sa; + } + assert!((mean / weight - 1.0).abs() < 0.01, "level-preserving mean: {}", mean / weight); + let sun = dominant_direction(w, h, &d, 0.0); + assert!(sun[1] < -0.5, "the key is up: {sun:?}"); + assert!(sun[2] < -0.3, "the key is toward the camera: {sun:?}"); + assert!(sun[0].abs() < 0.1, "symmetric left-right: {sun:?}"); +} + +/// #2436: with no Source and no Environment Layer, the Environment light's default environment +/// casts shadows like After Effects' default environment map — a sphere shadows the plane behind +/// it; Cast Shadows off lights the floor evenly. +#[test] +fn the_default_environment_casts_shadows() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 120.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 60.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let img = render(&p, cid); + let (mid, side) = (img.data[120 * 200 + 100], img.data[120 * 200 + 30]); + assert!(mid[3] > 0.99 && side[3] > 0.99, "the floor covers both: {mid:?} {side:?}"); + assert!(mid[0] < side[0] * 0.5, "the default environment shadows the sphere onto the floor: {mid:?} vs {side:?}"); + // Cast Shadows off: the floor is lit everywhere. + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let img = render(&p, cid); + let mid = img.data[120 * 200 + 100]; + assert!(mid[0] > side[0] * 0.8, "no shadow without Cast Shadows: {mid:?} vs {side:?}"); +} + #[test] fn depth_of_field_blurs_out_of_focus_surfaces() { let mut img = Image::new(64, 64); diff --git a/docs/agents.md b/docs/agents.md index 1ce30f076..d3b3adbef 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -213,10 +213,12 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built - **Environment light.** `layer.newLight` also accepts `"kind":"Environment"`: an equirectangular image that metallic surfaces reflect. Set its `lightOptions/source` to a footage, comp or solid layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`), - or a flat default environment of the light's colour × intensity when there is none — either - way the light brightens the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + or a default studio environment — bright above and toward the initial camera, dark below and + behind — of the light's colour × intensity when there is none; either way the light brightens + the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` like the other lights: Cast Shadows shades the scene from the image's dominant bright region - (a flat or otherwise uniform environment has none, so it casts no shadow). It only lights + (the default environment's key, or a source image's brightest area; only a uniform image has + none, so it casts no shadow). It only lights Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a Classic 3D comp to Advanced 3D ([#261](https://github.com/storytold/effectcraft/issues/261), diff --git a/docs/architecture.md b/docs/architecture.md index f8b2c2bb5..37e7d11d9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,12 +158,13 @@ comp to Advanced 3D in the same undo step, as in After Effects. Shading is physi GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after Karis 2013): parallel, spot and point lights with After Effects' falloffs, ambient lights, image-based light from an Environment light whose Source (or the comp's Environment Layer, Layer ▸ -Environment Layer, or a flat default environment of the light's colour × intensity when +Environment Layer, or a default studio environment — bright above and toward the initial +camera, dark below and behind — of the light's colour × intensity when there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps (spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: -one orthographic from the image's dominant bright region — none when the image is -directionless, as a flat environment is). An Environment light only lights Advanced 3D comps, +one orthographic from the image's dominant bright region — none only when the image is +directionless, as a uniform one is). An Environment light only lights Advanced 3D comps, so adding one (or changing a light's type to it) switches a Classic 3D comp to Advanced 3D, as model layers do. Cards keep Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular); From 743119d6e5241139639079700713cc673acd0995 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 23:36:19 +0000 Subject: [PATCH 038/112] Give the Environment light After Effects' look (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default studio environment's level-preserving mean of 1 acted like a 100% white Ambient light: with only the Environment light on, a white floor rendered at full white (95% of the frame saturated) while its shadow core went to pitch black — a fog over the scene, nothing like After Effects' default. Three changes bring the look in line, CPU and GPU alike: - The default environment is a dim studio now: a solid-angle-weighted mean of 0.3 (and a stronger key gradient), so a white floor reads a mid gray and the light's colour x intensity still scales the level. - The shadow occludes only the image's directional (key) fraction of the light — |sum of dir x radiance| / sum of radiance, computed beside the dominant direction and carried in the scene — so the fill keeps shining inside the shadow: an area-light shadow, partial like After Effects' (a lone bright spot on black still shadows almost fully; a uniform image not at all). - The environment shadow map's penumbra has a floor — eight output pixels, or four map texels where those are coarser — because the map covers the whole scene bounding sphere rather than the view, and its texels would otherwise stair-step the edge (all the more in a draft render's flat 512 budget). Shadow Diffusion widens it further, as before. Tests: the key fraction tracks directionality; the default keeps its dim level, its direction and its key; the default and a bright key both shadow the floor (the default partial, the key deep) with Cast Shadows off lit everywhere; and the full look — a mid-gray lit floor (no fog), a partial core, and a soft edge ramp in quality and draft renders alike. --- crates/gpu/src/adv3d.rs | 6 +- crates/gpu/src/shaders/advanced3d.wgsl | 6 +- crates/render/src/three_d/adv/raster.rs | 11 +- crates/render/src/three_d/adv/scene.rs | 48 ++++--- crates/render/src/three_d/adv/shade.rs | 10 +- crates/render/src/three_d/adv/tests.rs | 172 +++++++++++++++++++----- docs/agents.md | 8 +- docs/architecture.md | 10 +- 8 files changed, 205 insertions(+), 66 deletions(-) diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index f526c6653..c96e57d2d 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -322,6 +322,7 @@ fn pack(s: &Scene) -> [Vec; 7] { intensity: 0.0, rotation: 0.0, sun: [0.0; 3], + key: 0.0, shadow: -1, shadow_darkness: 0.0, shadow_diffusion: 0.0, @@ -330,8 +331,9 @@ fn pack(s: &Scene) -> [Vec; 7] { let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]); // env2: rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness. globals.extend([e.rotation, lit as u32 as f32, e.shadow as f32, e.shadow_darkness]); - // The environment's dominant direction (its shadow's source), for the shadow bias slope. - globals.extend([e.sun[0], e.sun[1], e.sun[2], 0.0]); + // The environment's dominant direction (its shadow's source), for the shadow bias slope, + // and its key fraction — how much of its light the shadow can remove (shade::env_shadow). + globals.extend([e.sun[0], e.sun[1], e.sun[2], e.key]); let mats = s.materials.iter().flat_map(|m| { let flags = (m.double_sided as u32) | (m.unlit as u32) << 1 diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 47c3a65e1..c1a6af4e9 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -320,14 +320,16 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, return 1.0 - darkness * (1.0 - f); } -// The Environment light's shadow from its dominant direction (shade::env_shadow). +// The Environment light's shadow from its dominant direction (shade::env_shadow). Only the +// image's key fraction (g.env_sun.w) of the light is occluded — the fill keeps shining — so +// the shadow stays partial the way After Effects renders an area-light shadow. fn env_shadow(p: vec3, ng: vec3, n: vec3) -> f32 { if (g.env2.z < 0.0) { return 1.0; } let n_l = clamp(dot(n, g.env_sun.xyz), 0.0, 1.0); let f = shadow_factor(u32(g.env2.z + 0.5) * SHADOW_STRIDE, p, ng, n_l); - return 1.0 - g.env2.w * (1.0 - f); + return 1.0 - g.env2.w * g.env_sun.w * (1.0 - f); } fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_inner: f32, cos_outer: f32, dir: vec3, to_l: vec3, dist: f32) -> f32 { diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 8c1572d73..91bf45307 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -599,13 +599,20 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { match sizes.get(s.lights.len()).copied().flatten() { Some(size) => { let first = maps.len() as i32; - let soft = 1.0 + (e.shadow_diffusion * 0.5).min(24.0); + // After Effects' environment-light shadows are soft out of the box, and this + // map covers the whole scene bounding sphere — not the view — so its texels + // can be coarse (all the more in a draft render's flat 512 budget). A + // penumbra of at least eight output pixels, or four map texels where those + // are coarser, keeps the edge smooth at any map resolution; Shadow Diffusion + // widens it further, as for the other lights. + let texel = 2.0 * radius / size as f64; + let soft = (4.0 * texel).max(8.0) + (e.shadow_diffusion as f64 * 0.5).min(24.0); let sun = e.sun.map(|v| v as f64); let dir = [-sun[0], -sun[1], -sun[2]]; let eye = [centre[0] + sun[0] * radius * 2.0, centre[1] + sun[1] * radius * 2.0, centre[2] + sun[2] * radius * 2.0]; let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }; let focal = (size as f64 * 0.5 / radius) as f32; - maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft }); + maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft as f32 }); if let Some(e) = &mut s.env { e.shadow = first; } diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 3d63db500..8a120f0d9 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -177,6 +177,13 @@ pub struct EnvInfo { /// `to_light`. `[0; 3]` when the image is directionless (uniform) or the light casts no /// shadow (nothing reads it then): no shadow. pub sun: [f32; 3], + /// The image's directional (key) fraction — `|Σ dir·radiance| / Σ radiance` over the + /// sphere, 0 for a uniform image and near 1 for a single bright spot. An Environment + /// light's shadow occludes only this much of its light: the key lobe is blocked where + /// the map says so, the remaining fill keeps shining, so the shadow stays partial the + /// way After Effects renders an area-light shadow (a broad environment like the default + /// studio shadows softly, a lone bright spot on black almost fully). + pub key: f32, /// Shadow map index into [`Scene::shadows`]: 0 marked (assigned by `raster::shadow_maps`), /// -1 casts no shadow. Gated by the light's Cast Shadows. pub shadow: i32, @@ -812,11 +819,14 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec out } -/// The environment image's dominant direction: the radiance-weighted mean texel direction -/// (solid-angle weighted, rotation applied), or `[0; 3]` when the image is directionless -/// (a uniform environment cancels to zero). The mean of a single bright spot on black points -/// straight at it; a gradient points somewhere between its ends. -pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f32; 3] { +/// The environment image's dominant direction and key strength: the radiance-weighted mean +/// texel direction (solid-angle weighted, rotation applied), or `([0; 3], 0.0)` when the image +/// is directionless (a uniform environment cancels to zero). The mean of a single bright spot +/// on black points straight at it; a gradient points somewhere between its ends. The returned +/// key fraction is the mean vector's length over the radiance weight — how directional the +/// image is, 0 (uniform) to ~1 (a lone spot) — and is the most of its light an Environment +/// light's shadow can remove. +pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> ([f32; 3], f32) { use super::shade::equirect_dir; let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32); let mut weight = 0.0f32; @@ -835,9 +845,9 @@ pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f // from. A non-finite sum (a NaN or infinite texel) is garbage, not a direction: none then. let len = (x * x + y * y + z * z).sqrt(); if !weight.is_finite() || !len.is_finite() || weight <= 1e-9 || len < weight * 0.01 { - return [0.0; 3]; + return ([0.0; 3], 0.0); } - [x / len, y / len, z / len] + ([x / len, y / len, z / len], (len / weight).clamp(0.0, 1.0)) } /// Prefilter an environment image into the scene's textures. @@ -852,9 +862,9 @@ pub(crate) fn add_environment( shadow_darkness: f64, shadow_diffusion: f64, ) { - // The shadow direction from the full-resolution image, before the mip chain averages it — - // only a shadow-casting light reads it, so only one pays for the pass. - let sun = if casts_shadows { dominant_direction(w, h, &data, rotation) } else { [0.0; 3] }; + // The shadow direction and key strength from the full-resolution image, before the mip + // chain averages it — only a shadow-casting light reads them, so only one pays for the pass. + let (sun, key) = if casts_shadows { dominant_direction(w, h, &data, rotation) } else { ([0.0; 3], 0.0) }; // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { @@ -897,6 +907,7 @@ pub(crate) fn add_environment( intensity: k, rotation, sun, + key, // Marked; `raster::shadow_maps` assigns the map (a directionless image casts none). shadow: if casts_shadows { 0 } else { -1 }, shadow_darkness: shadow_darkness as f32, @@ -927,12 +938,13 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Optio Some((img.width, img.height, data)) } -/// The default environment when the light has no Source and no Environment Layer: a soft studio -/// gradient, bright above and toward the initial camera, dark below and behind it — After +/// The default environment when the light has no Source and no Environment Layer: a dim studio +/// gradient, bright above and toward the initial camera, dark below and behind — After /// Effects "uses a default environment map" for an Environment light too, and its default -/// casts shadows out of the box. The solid-angle-weighted mean is 1, so the light's colour × -/// intensity still sets the overall level, like the flat default it replaces; the light's -/// Rotation turns the key around the scene (#2436). +/// casts shadows out of the box. The solid-angle-weighted mean is 0.3, a soft studio ambience +/// (a mean of 1 would act like a 100 % white Ambient light and wash the whole scene out, which +/// is not what After Effects' default looks like); the light's colour × intensity still scales +/// the overall level, and its Rotation turns the key around the scene (#2436). pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) { use super::shade::{dot, equirect_dir}; // The key region's centre: three quarters up, two thirds toward the viewer (the default @@ -946,14 +958,14 @@ pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) { let sa = (v * std::f32::consts::PI).sin(); for x in 0..w { let u = (x as f32 + 0.5) / w as f32; - let l = 0.5 + 0.9 * dot(equirect_dir(u, v, 0.0), key).max(0.0); + let l = 0.15 + 1.2 * dot(equirect_dir(u, v, 0.0), key).max(0.0); data.push([l, l, l, 1.0]); mean += l * sa; weight += sa; } } - // Keep the flat default's level: scale the gradient to a weighted mean of exactly 1. - let k = if mean.is_finite() && mean > 1e-6 { weight / mean } else { 1.0 }; + // A dim studio: scale the gradient to a weighted mean of exactly 0.3. + let k = if mean.is_finite() && mean > 1e-6 { 0.3 * weight / mean } else { 1.0 }; for p in &mut data { for c in 0..3 { p[c] *= k; diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index af4653f6a..232756a9d 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -351,8 +351,12 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) - 1.0 - l.shadow_darkness * (1.0 - f) } -/// The visibility of the Environment light's dominant direction at `p` (1 = unshadowed), -/// darkened by its Shadow Darkness. A directionless image (or a non-casting light) has no map +/// The visibility of the Environment light at `p` (1 = unshadowed), darkened by its Shadow +/// Darkness — but only by the image's key fraction: the shadow map occludes the dominant +/// (key) lobe, while the environment's fill keeps shining on the shadowed surface, so an +/// Environment light's shadow stays partial the way After Effects renders an area-light +/// shadow (a broad environment like the default studio shadows softly, a lone bright spot +/// on black almost fully). A directionless image (or a non-casting light) has no map /// and shades unshadowed. pub fn env_shadow(s: &Scene, e: &EnvInfo, p: [f32; 3], ng: [f32; 3], n: [f32; 3]) -> f32 { if e.shadow < 0 || e.sun == [0.0; 3] { @@ -361,7 +365,7 @@ pub fn env_shadow(s: &Scene, e: &EnvInfo, p: [f32; 3], ng: [f32; 3], n: [f32; 3] let Some(m) = s.shadows.get(e.shadow as usize) else { return 1.0 }; let n_l = dot(n, e.sun).clamp(0.0, 1.0); let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l); - 1.0 - e.shadow_darkness * (1.0 - f) + 1.0 - e.shadow_darkness * e.key * (1.0 - f) } /// Unit direction from `p` to light `l`, and the distance (infinite for a parallel light). diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index d9e749d31..5a0e35ac8 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -69,13 +69,30 @@ fn constant_environment_has_constant_irradiance() { /// A NaN or infinite texel poisons the sums (every NaN comparison is false, so the thresholds /// alone would pass it through as a NaN direction); an infinite one even where sin θ = 0 turns -/// into NaN. Either way the sum is garbage, not a direction: no sun, so no shadow (#2436). +/// into NaN. Either way the sum is garbage, not a direction: no sun and no key, so no shadow +/// (#2436). #[test] fn a_non_finite_environment_has_no_sun_direction() { let nan = vec![[f32::NAN; 4]; 8 * 4]; - assert_eq!(dominant_direction(8, 4, &nan, 0.0), [0.0; 3]); + assert_eq!(dominant_direction(8, 4, &nan, 0.0), ([0.0; 3], 0.0)); let inf = vec![[f32::INFINITY, 0.0, 0.0, 1.0]; 8 * 4]; - assert_eq!(dominant_direction(8, 4, &inf, 0.0), [0.0; 3]); + assert_eq!(dominant_direction(8, 4, &inf, 0.0), ([0.0; 3], 0.0)); +} + +/// The key fraction: a uniform image is directionless (key 0), a lone bright spot on black is +/// almost fully directional, and a broad gradient sits in between — the soft studio default +/// shadows visibly but keeps most of its light as fill (#2436). +#[test] +fn environment_key_strength_tracks_directionality() { + let uniform = vec![[0.5f32, 0.5, 0.5, 1.0]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &uniform, 0.0).1, 0.0); + let mut spot = vec![[0.0f32; 4]; 64 * 32]; + spot[16 * 64 + 32] = [1.0, 1.0, 1.0, 1.0]; + let (_, key) = dominant_direction(64, 32, &spot, 0.0); + assert!(key > 0.95, "a lone spot is almost fully directional: {key}"); + let (w, h, d) = scene::default_environment(); + let (_, key) = dominant_direction(w, h, &d, 0.0); + assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}"); } #[test] @@ -388,8 +405,9 @@ fn environment_light_without_a_source_lights_the_scene() { } /// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant -/// (brightest) region shadows the scene. A bright disc turned to shine from behind the camera -/// (rotation 180°) shadows a sphere onto the plane behind it; Cast Shadows off lights it evenly. +/// (brightest) region shadows the scene. A bright disc above the image's centre, turned to +/// shine from up and behind the camera (rotation 180°), shadows a sphere onto the floor below +/// it; Cast Shadows off lights it evenly. #[test] fn environment_lights_cast_shadows() { let (mut p, cid) = project(200, 150); @@ -397,7 +415,7 @@ fn environment_lights_cast_shadows() { add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { set(l, "geometryOptions/width", Value::Scalar(400.0)); set(l, "geometryOptions/height", Value::Scalar(400.0)); - set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0])); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0])); set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); }); add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { @@ -405,38 +423,53 @@ fn environment_lights_cast_shadows() { set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); set(l, "materialOptions/castsShadows", Value::Enum(2)); }); - // The Environment Layer: a bright disc at the image's centre on black. + // The Environment Layer: a tight bright key above the image's centre, on black — a + // full-comp rect keeps the layer buffer full-size, the radial gradient is the key. add(&mut p, cid, LayerSource::Shape, |l| { l.switches.three_d = true; let mut next = 10_000u64; let ids = &mut build::Ids(&mut next); let rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0); - let gfill = build::shape_gradient_fill( + let key = build::shape_gradient_fill( ids, true, - [0.0, 0.0], - [0.0, 75.0], + [0.0, -30.0], + [0.0, 5.0], effectcraft_keyframe::Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }, ); - let g = build::shape_group(ids, "Sky", vec![rect, gfill]); + let g = build::shape_group(ids, "Key", vec![rect, key]); l.props.sub_mut("contents").unwrap().children.push(g.into()); l.environment = true; }); - // Turned 180°, the disc shines from behind the camera, straight at the floor. + // Turned 180°, the key shines from up and behind the camera onto the sphere and floor. add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { set(l, "lightOptions/rotation", Value::Scalar(180.0)); set(l, "lightOptions/castsShadows", Value::Bool(true)); }); + // The shadow lands below the sphere on the floor: its core is the darkest floor pixel + // below the sphere; the lit floor is the sample to the left. The frame is sRGB-encoded, + // so the ratios below are gamma-compressed (a linear halving reads as ~0.73). + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0; let img = render(&p, cid); - let (mid, side) = (img.data[75 * 200 + 100], img.data[75 * 200 + 20]); - assert!(mid[3] > 0.99 && side[3] > 0.99); - assert!(mid[0] < side[0] * 0.5, "the disc shadows the sphere onto the floor: {mid:?} vs {side:?}"); - // Cast Shadows off: the floor is lit everywhere. + let side = luma(&img, 25, 75); + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 100..150 { + for x in 60..140 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + assert!(side > 0.1, "the floor is lit: {side}"); + assert!(core < side * 0.65, "the key shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})"); + // Cast Shadows off: the floor is lit everywhere, core included. let comp = p.comp_mut(cid).unwrap(); set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); let img = render(&p, cid); - let mid = img.data[75 * 200 + 100]; - assert!(mid[0] > side[0] * 0.8, "no shadow without Cast Shadows: {mid:?} vs {side:?}"); + let mid = luma(&img, cx, cy); + assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}"); } /// #2436: a uniform environment has no dominant region, so even with Cast Shadows on it casts @@ -472,9 +505,11 @@ fn a_uniform_environment_casts_no_shadow() { assert_eq!(casting.data, off.data, "a directionless image casts no shadow"); } -/// #2436: the default environment (no Source, no Environment Layer) is level-preserving and -/// directional — its dominant region points up and toward the initial camera, so a -/// shadow-casting Environment light casts shadows out of the box, like After Effects' default. +/// #2436: the default environment (no Source, no Environment Layer) is a dim studio — a mean +/// of 0.3, not the 1.0 that would act like a 100 % white Ambient light and wash the scene out +/// (After Effects' default does not) — and directional: its dominant region points up and +/// toward the initial camera, so a shadow-casting Environment light casts soft, partial +/// shadows out of the box, like After Effects' default. #[test] fn default_environment_keeps_the_level_and_points_up() { let (w, h, d) = scene::default_environment(); @@ -486,16 +521,21 @@ fn default_environment_keeps_the_level_and_points_up() { mean += (p[0] + p[1] + p[2]) / 3.0 * sa; weight += sa; } - assert!((mean / weight - 1.0).abs() < 0.01, "level-preserving mean: {}", mean / weight); - let sun = dominant_direction(w, h, &d, 0.0); + assert!((mean / weight - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight); + let (sun, key) = dominant_direction(w, h, &d, 0.0); assert!(sun[1] < -0.5, "the key is up: {sun:?}"); assert!(sun[2] < -0.3, "the key is toward the camera: {sun:?}"); assert!(sun[0].abs() < 0.1, "symmetric left-right: {sun:?}"); + assert!((0.35..0.55).contains(&key), "a broad key with fill: {key}"); + // No texel clips past white at the default intensity (the fog of a mean-1 environment). + assert!(d.iter().all(|p| p[0] <= 1.0 && p[0] > 0.0), "0 < radiance <= 1"); } /// #2436: with no Source and no Environment Layer, the Environment light's default environment -/// casts shadows like After Effects' default environment map — a sphere shadows the plane behind -/// it; Cast Shadows off lights the floor evenly. +/// casts soft, partial shadows like After Effects' default environment map — a sphere shadows +/// the plane behind it, darkening part of the light while the fill keeps the floor visible +/// (an area-light shadow, not the pitch black a point light casts); Cast Shadows off lights +/// the floor evenly. #[test] fn the_default_environment_casts_shadows() { let (mut p, cid) = project(200, 150); @@ -508,22 +548,39 @@ fn the_default_environment_casts_shadows() { }); add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { set(l, "geometryOptions/radius", Value::Scalar(30.0)); - set(l, "transform/position", Value::Vec3([100.0, 60.0, 50.0])); + set(l, "transform/position", Value::Vec3([100.0, 45.0, 50.0])); set(l, "materialOptions/castsShadows", Value::Enum(2)); }); add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { set(l, "lightOptions/castsShadows", Value::Bool(true)); }); + // The default key (up and toward the camera) drops the shadow below the sphere: the core + // is the darkest floor pixel below it, the lit floor the sample to the left. + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0; let img = render(&p, cid); - let (mid, side) = (img.data[120 * 200 + 100], img.data[120 * 200 + 30]); - assert!(mid[3] > 0.99 && side[3] > 0.99, "the floor covers both: {mid:?} {side:?}"); - assert!(mid[0] < side[0] * 0.5, "the default environment shadows the sphere onto the floor: {mid:?} vs {side:?}"); - // Cast Shadows off: the floor is lit everywhere. + let side = luma(&img, 30, 100); + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 110..150 { + for x in 80..120 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + assert!(side > 0.1, "the floor is lit: {side}"); + // Partial: the key lobe is occluded (a clear darkening) but the fill keeps shining — + // not the pitch black a point light's shadow would cast at the same darkness. The frame + // is sRGB-encoded, so the ratios are gamma-compressed (a linear halving reads as ~0.73). + assert!(core < side * 0.8, "the default environment shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})"); + assert!(core > side * 0.45, "an area-light shadow, partial like After Effects': {core} vs {side}"); + // Cast Shadows off: the floor is lit everywhere, core included. let comp = p.comp_mut(cid).unwrap(); set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); let img = render(&p, cid); - let mid = img.data[120 * 200 + 100]; - assert!(mid[0] > side[0] * 0.8, "no shadow without Cast Shadows: {mid:?} vs {side:?}"); + let mid = luma(&img, cx, cy); + assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}"); } #[test] @@ -1236,3 +1293,54 @@ fn hard_edges_ramp_over_two_texels() { assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels"); } } + +// ---------------------------------------------------------------- the After Effects look (#2436) + +/// #2436: an Environment light with every option at its default renders like After Effects': +/// a dim studio that leaves the lit floor a mid gray instead of washing it out to white (the +/// fog of a mean-1 default), a partial shadow (the key lobe occluded, the fill still shining — +/// not the pitch black a point light casts), and a soft edge in both quality and draft renders +/// (the map's texel grid never shows through as stair-steps). +#[test] +fn environment_shadows_match_the_after_effects_look() { + let (mut p, cid) = project(800, 600); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(1200.0)); + set(l, "geometryOptions/height", Value::Scalar(900.0)); + set(l, "transform/position", Value::Vec3([400.0, 300.0, 300.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(80.0)); + set(l, "transform/position", Value::Vec3([400.0, 300.0, 120.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 800 + x][0] + img.data[y * 800 + x][1] + img.data[y * 800 + x][2]) / 3.0; + for (name, opts) in [("quality", RenderOpts::default()), ("draft", RenderOpts { draft: true, ..Default::default() })] { + let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO); + // The shadow core: the darkest pixel of the lower half (the key drops the shadow + // below the sphere); the lit floor is the mean of the band to its left. + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 420..580 { + for x in 300..500 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + let lit: f32 = (60..260).map(|x| luma(&img, x, cy)).sum::() / 200.0; + assert!(lit > 0.15 && lit < 0.85, "{name}: the lit floor is a mid gray, no white fog: {lit}"); + // The frame is sRGB-encoded, so the ratio is gamma-compressed (a linear halving + // reads as ~0.73): partial means clearly darker than lit, far from black. + assert!(core > lit * 0.45 && core < lit * 0.85, "{name}: a partial area-light shadow: core {core} vs lit {lit}"); + // The edge's 10-90 % ramp from the lit plateau (left) down into the shadow. + let first_below = |t: f32| (260..cx).position(|x| luma(&img, x, cy) < core + (lit - core) * t).map(|i| 260 + i); + let w = (first_below(0.1).unwrap() as f32) - (first_below(0.9).unwrap() as f32); + assert!(w >= 3.5, "{name}: the edge ramps over {w} px (After Effects' env shadows are soft)"); + } +} diff --git a/docs/agents.md b/docs/agents.md index d3b3adbef..a9d755616 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -214,11 +214,13 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built image that metallic surfaces reflect. Set its `lightOptions/source` to a footage, comp or solid layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`), or a default studio environment — bright above and toward the initial camera, dark below and - behind — of the light's colour × intensity when there is none; either way the light brightens - the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + behind, at a dim 0.3 mean level — of the light's colour × intensity when there is none; either + way the light brightens the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` like the other lights: Cast Shadows shades the scene from the image's dominant bright region (the default environment's key, or a source image's brightest area; only a uniform image has - none, so it casts no shadow). It only lights + none, so it casts no shadow) — occluding only that key's share of the light, so the shadow + stays partial like After Effects' area-light shadows, and soft by default (a penumbra of at + least 8 pixels or four map texels, plus Shadow Diffusion). It only lights Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a Classic 3D comp to Advanced 3D ([#261](https://github.com/storytold/effectcraft/issues/261), diff --git a/docs/architecture.md b/docs/architecture.md index 37e7d11d9..93645f9cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -159,12 +159,14 @@ GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after parallel, spot and point lights with After Effects' falloffs, ambient lights, image-based light from an Environment light whose Source (or the comp's Environment Layer, Layer ▸ Environment Layer, or a default studio environment — bright above and toward the initial -camera, dark below and behind — of the light's colour × intensity when -there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed mip -chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps +camera, dark below and behind, at a dim 0.3 mean level — of the light's colour × intensity +when there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed +mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps (spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: one orthographic from the image's dominant bright region — none only when the image is -directionless, as a uniform one is). An Environment light only lights Advanced 3D comps, +directionless, as a uniform one is — occluding only the image's directional key fraction of +the light, so the shadow stays partial like After Effects' area-light shadows, with a soft +default penumbra of at least eight pixels or four map texels). An Environment light only lights Advanced 3D comps, so adding one (or changing a light's type to it) switches a Classic 3D comp to Advanced 3D, as model layers do. Cards keep Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular); From eb34d10d21f8e25a7bfd2802bddbb24781afaeee Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 27 Oct 2026 12:33:46 +0000 Subject: [PATCH 039/112] Review fixes: a failed environment source lights nothing (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review points on the merge: - env_pixels returned None both for an unconfigured light (no Source, no Environment Layer) and for a failed source (a deleted layer, missing footage, a failed decode), so a selected source that failed silently swapped in the default studio environment — relighting and reshadowing the scene behind the user's back. It now tells the three apart (EnvPixels::Absent/Image/Failed): only the unconfigured light uses the default; a failed source contributes nothing, exactly like a layer whose source fails renders nothing. The repair the user can act on stays where it already lives — missing footage is flagged on the item, with the relink commands to fix it. - The docs claimed only a uniform image casts no shadow. A non-uniform one can be directionless too: opposing bright regions cancel, and anything under 1% directional is rejected. agents.md, architecture.md and the sun/dominant_direction doc comments now say an image without a sufficiently strong dominant direction. Tests: an unreadable Environment Layer (auto-picked), an unreadable selected Source and a deleted one all light nothing — pixel-equal to the unlit render, no studio default, no default-environment shadow — while an unconfigured light still lights; two equal bright spots at opposite azimuths cancel to no sun and no key, and tilting the balance re-emerges pointing at the brighter spot. --- crates/render/src/three_d/adv/scene.rs | 47 +++++++++---- crates/render/src/three_d/adv/tests.rs | 91 ++++++++++++++++++++++++++ docs/agents.md | 13 ++-- docs/architecture.md | 9 ++- 4 files changed, 141 insertions(+), 19 deletions(-) diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 8a120f0d9..ce572aaef 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -174,8 +174,8 @@ pub struct EnvInfo { pub rotation: f32, /// Unit direction from the scene towards the environment's dominant (radiance-weighted) /// bright region — the direction its shadow is cast from, like a parallel light's - /// `to_light`. `[0; 3]` when the image is directionless (uniform) or the light casts no - /// shadow (nothing reads it then): no shadow. + /// `to_light`. `[0; 3]` when the image is directionless (uniform, or opposing bright + /// regions that cancel) or the light casts no shadow (nothing reads it then): no shadow. pub sun: [f32; 3], /// The image's directional (key) fraction — `|Σ dir·radiance| / Σ radiance` over the /// sphere, 0 for a uniform image and near 1 for a single bright spot. An Environment @@ -821,7 +821,8 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec /// The environment image's dominant direction and key strength: the radiance-weighted mean /// texel direction (solid-angle weighted, rotation applied), or `([0; 3], 0.0)` when the image -/// is directionless (a uniform environment cancels to zero). The mean of a single bright spot +/// is directionless (a uniform environment cancels to zero, and opposing bright regions that +/// cancel leave no mean either). The mean of a single bright spot /// on black points straight at it; a gradient points somewhere between its ends. The returned /// key fraction is the mean vector's length over the radiance weight — how directional the /// image is, 0 (uniform) to ~1 (a lone spot) — and is the most of its light an Environment @@ -915,13 +916,30 @@ pub(crate) fn add_environment( }); } -/// The environment layer's pixels as linear straight RGBA. -fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Option<(u32, u32, Vec<[f32; 4]>)> { +/// The Environment light's image: `Absent` when the light has no Source and the comp has no +/// Environment Layer (the default studio environment applies — After Effects "uses a default +/// environment map" for an Environment light too), `Image` when the source renders, `Failed` +/// when a selected or found source produced no pixels (a deleted layer, missing footage, a +/// failed decode). A failed source must not silently become the default environment — that +/// would relight and reshadow the scene behind the user's back — so, like any layer whose +/// source fails renders nothing, it contributes nothing (#2436). +enum EnvPixels { + Absent, + Image((u32, u32, Vec<[f32; 4]>)), + Failed, +} + +fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> EnvPixels { let layer = match src { - Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id))?, - None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time))?, + Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id)), + None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time)), }; - let buf = r.blend_layer_buf(ctx, layer)?; + let Some(layer) = layer else { + // A set Source that resolves to no layer (deleted) failed; an unset one with no + // Environment Layer is simply unconfigured. + return if src.is_some() { EnvPixels::Failed } else { EnvPixels::Absent }; + }; + let Some(buf) = r.blend_layer_buf(ctx, layer) else { return EnvPixels::Failed }; let img = &buf.img; let data = img .data @@ -935,7 +953,7 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Optio if lin { [s[0], s[1], s[2], 1.0] } else { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), 1.0] } }) .collect(); - Some((img.width, img.height, data)) + EnvPixels::Image((img.width, img.height, data)) } /// The default environment when the light has no Source and no Environment Layer: a dim studio @@ -1065,15 +1083,22 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: b.s.ambient = amb; if let Some(env) = env { match env_pixels(r, ctx, env.src, lin) { - Some((w, h, d)) => add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion), + EnvPixels::Image((w, h, d)) => { + add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion) + } // No Source and no Environment Layer: the default environment, a soft studio // gradient of the light's colour × intensity — After Effects "uses a default // environment map" for an Environment light — so the light always lights the // scene per its parameters, and its dominant region casts shadows (#2436). - None => { + EnvPixels::Absent => { let (w, h, d) = default_environment(); add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion) } + // A selected or found source that produced no pixels (deleted layer, missing + // footage, failed decode): no environment at all — the light contributes + // nothing, exactly like a layer whose source failed renders nothing, never a + // silent swap to the default studio's lighting and shadows (#2436). + EnvPixels::Failed => {} } } if r.opts.shadows() { diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 5a0e35ac8..fd3c615ea 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -95,6 +95,33 @@ fn environment_key_strength_tracks_directionality() { assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}"); } +/// #2436 review: a non-uniform image can be directionless too — two equal bright spots at +/// opposite azimuths pull the radiance-weighted mean in opposite directions and cancel, +/// leaving no dominant region to shadow from (under 1 % directional): no sun, no key, so no +/// shadow, just like a uniform image. Tilt the balance and a direction re-emerges, pointing +/// at the brighter spot. +#[test] +fn opposing_bright_regions_cancel_to_no_shadow() { + let (w, h) = (64u32, 32u32); + let mut opposed = vec![[0.0f32; 4]; (w * h) as usize]; + // Rows 15 and 16 mirror about the equator (v = 0.484375 / 0.515625), and columns 16 and 48 + // sit half a width apart (u = 0.2578125 / 0.7578125): the two texel-centre directions are + // exactly opposite — equal solid-angle weight, opposite pull. + let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize); + opposed[a] = [1.0, 1.0, 1.0, 1.0]; + opposed[b] = [1.0, 1.0, 1.0, 1.0]; + assert_eq!(dominant_direction(w, h, &opposed, 0.0), ([0.0; 3], 0.0)); + // One side twice as bright: the mean points at the brighter spot with its share of the + // weight (key = 1/3 — two parts against one). + let mut tilted = opposed.clone(); + tilted[a] = [2.0, 2.0, 2.0, 1.0]; + let (sun, key) = dominant_direction(w, h, &tilted, 0.0); + assert!(sun != [0.0; 3] && key > 0.3, "the brighter side wins: {sun:?} key {key}"); + let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999, "the sun points at the brighter spot: {sun:?} vs {want:?}"); +} + #[test] fn equirect_round_trip_and_env_brdf() { for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] { @@ -404,6 +431,70 @@ fn environment_light_without_a_source_lights_the_scene() { assert!(c5[0] > 0.15 && c5[1] < 0.05 && c5[2] < 0.05, "red environment: {c5:?}"); } +/// #2436 review: a selected or auto-picked environment source that fails to render (missing +/// footage, a deleted layer) must not silently swap in the default studio environment — the +/// light contributes nothing, exactly like a layer whose source failed renders nothing. Only a +/// light with no Source at all (and no Environment Layer in the comp) uses the default. +#[test] +fn a_failed_environment_source_lights_nothing() { + let (mut p, cid) = project(120, 90); + let sid = p.add_item( + "S", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true); + let unlit = render(&p, cid); + // The Environment Layer's footage is unreadable (missing; NoFootage serves no frames). + let fid = p.add_item( + "F", + Default::default(), + None, + ItemKind::Footage(effectcraft_project::Footage { + path: "missing.exr".into(), + kind: effectcraft_project::FootageKind::Still, + width: 64, + height: 32, + pixel_aspect: 1.0, + duration: Tick::from_seconds_f64(1.0), + has_video: true, + missing: true, + ..Default::default() + }), + ); + add(&mut p, cid, LayerSource::Footage { item: fid }, |l| { + l.switches.three_d = true; + l.environment = true; + }); + let env_layer = p.comp(cid).unwrap().layers.iter().find(|l| l.environment).unwrap().id; + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + fn light(p: &mut Project, cid: ItemId) -> &mut effectcraft_project::Layer { + p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.is_light()).unwrap() + } + // Auto-picked Environment Layer, unreadable: the light is off — no studio default, no + // default-environment shadow either. + let failed = render(&p, cid); + assert_eq!(failed.data, unlit.data, "an unreadable Environment Layer lights nothing"); + // The light's Source set to that unreadable layer: same. + set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(env_layer.0))); + let failed = render(&p, cid); + assert_eq!(failed.data, unlit.data, "an unreadable selected Source lights nothing"); + // The Source pointing at a deleted layer: same. + set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(999_999))); + let failed = render(&p, cid); + assert_eq!(failed.data, unlit.data, "a deleted selected Source lights nothing"); + // Contrast: with no Source and no Environment Layer, the default studio still lights it. + let comp = p.comp_mut(cid).unwrap(); + comp.layers.retain(|l| l.is_light() || !l.environment); + set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/source", Value::Layer(None)); + let lit = render(&p, cid); + let (c, c2) = (unlit.data[20 * 120 + 30], lit.data[20 * 120 + 30]); + assert!(c2[3] > 0.99 && (c2[0] - c[0]).abs() > 0.05, "the unconfigured light still lights: {c2:?} vs {c:?}"); +} + /// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant /// (brightest) region shadows the scene. A bright disc above the image's centre, turned to /// shine from up and behind the camera (rotation 180°), shadows a sphere onto the floor below diff --git a/docs/agents.md b/docs/agents.md index a9d755616..42e16c3ca 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -215,12 +215,15 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`), or a default studio environment — bright above and toward the initial camera, dark below and behind, at a dim 0.3 mean level — of the light's colour × intensity when there is none; either - way the light brightens the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + way the light brightens the scene. A selected Source or Environment Layer that fails to + render — a deleted layer, missing footage — lights nothing instead: the light never silently + falls back to the studio default. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` like the other lights: Cast Shadows shades the scene from the image's dominant bright region - (the default environment's key, or a source image's brightest area; only a uniform image has - none, so it casts no shadow) — occluding only that key's share of the light, so the shadow - stays partial like After Effects' area-light shadows, and soft by default (a penumbra of at - least 8 pixels or four map texels, plus Shadow Diffusion). It only lights + (the default environment's key, or a source image's brightest area; an image without a + sufficiently strong dominant direction has none — a uniform one, or opposing bright regions + that cancel — so it casts no shadow) — occluding only that key's share of the light, so the + shadow stays partial like After Effects' area-light shadows, and soft by default (a penumbra + of at least 8 pixels or four map texels, plus Shadow Diffusion). It only lights Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a Classic 3D comp to Advanced 3D ([#261](https://github.com/storytold/effectcraft/issues/261), diff --git a/docs/architecture.md b/docs/architecture.md index 93645f9cf..76f4c6a84 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -163,10 +163,13 @@ camera, dark below and behind, at a dim 0.3 mean level — of the light's colour when there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps (spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: -one orthographic from the image's dominant bright region — none only when the image is -directionless, as a uniform one is — occluding only the image's directional key fraction of +one orthographic from the image's dominant bright region — none only when the image lacks a +sufficiently strong dominant direction (a uniform one, or opposing bright regions that +cancel) — occluding only the image's directional key fraction of the light, so the shadow stays partial like After Effects' area-light shadows, with a soft -default penumbra of at least eight pixels or four map texels). An Environment light only lights Advanced 3D comps, +default penumbra of at least eight pixels or four map texels). A Source or Environment Layer +that fails to render — a deleted layer, missing footage — lights nothing: no silent fallback +to the default studio environment. An Environment light only lights Advanced 3D comps, so adding one (or changing a light's type to it) switches a Classic 3D comp to Advanced 3D, as model layers do. Cards keep Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular); From a8cba23edc11a192c6a708f5e0a54a0caae8841e Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 27 Oct 2026 14:11:55 +0000 Subject: [PATCH 040/112] Review fixes: the environment shadow follows the tinted radiance (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dominant_direction weighted texels by their raw channel sum before the light's colour tinted the radiance, so equally bright red and blue regions on opposite sides cancelled to no sun and no key — while a red Environment light's tint removed the blue contribution and left the scene lit directionally red, with no shadow for its Cast Shadows to show. The tint now weights the direction sum (a white light weights [1; 3], a coloured one its normalised colour), so the sun and key are derived from the radiance the light actually emits; add_environment computes the tint once, before the direction pass, and reuses it for the texel tinting. An achromatic image is unaffected (its weights scale uniformly), so the default studio and white-lit scenes keep their shadows exactly. Tests: opposed red and blue texels cancel to a white light but a red tint points the sun straight at the red one (a blue tint, at the blue one); end to end, a red Environment light over an image with equally bright red and blue regions on exactly antipodal positions casts the shadow a white light over the same image does not — and the shadow falls opposite the red region, the side the tinted radiance comes from. --- crates/render/src/three_d/adv/scene.rs | 34 ++++--- crates/render/src/three_d/adv/tests.rs | 122 +++++++++++++++++++++++-- docs/agents.md | 5 +- docs/architecture.md | 4 +- 4 files changed, 142 insertions(+), 23 deletions(-) diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index ce572aaef..dfd35b048 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -819,23 +819,27 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec out } -/// The environment image's dominant direction and key strength: the radiance-weighted mean -/// texel direction (solid-angle weighted, rotation applied), or `([0; 3], 0.0)` when the image -/// is directionless (a uniform environment cancels to zero, and opposing bright regions that -/// cancel leave no mean either). The mean of a single bright spot +/// The environment image's dominant direction and key strength: the mean texel direction +/// weighted by the radiance the light actually emits — solid-angle weighted, rotation applied, +/// each texel's channels scaled by `tint` (the light's colour, `[1; 3]` for a white one) — or +/// `([0; 3], 0.0)` when that radiance is directionless (a uniform environment cancels to zero, +/// and opposing bright regions that cancel leave no mean either — though a chromatic one need +/// not stay directionless once tinted: a red light over equally bright red and blue opposite +/// regions removes the blue pull and points at the red one). The mean of a single bright spot /// on black points straight at it; a gradient points somewhere between its ends. The returned /// key fraction is the mean vector's length over the radiance weight — how directional the /// image is, 0 (uniform) to ~1 (a lone spot) — and is the most of its light an Environment /// light's shadow can remove. -pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> ([f32; 3], f32) { +pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], tint: [f32; 3], rot: f32) -> ([f32; 3], f32) { use super::shade::equirect_dir; let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32); let mut weight = 0.0f32; for (i, p) in d.iter().enumerate() { let (tx, ty) = ((i % w as usize) as u32, (i / w as usize) as u32); let (u, v) = ((tx as f32 + 0.5) / w as f32, (ty as f32 + 0.5) / h as f32); - // Luminance × solid angle (∝ sin θ). - let lum = (p[0] + p[1] + p[2]) * (v * std::f32::consts::PI).sin(); + // Tinted radiance × solid angle (∝ sin θ): the shadow must follow the light the + // environment emits, not the raw texels. + let lum = (p[0] * tint[0] + p[1] * tint[1] + p[2] * tint[2]) * (v * std::f32::consts::PI).sin(); let dir = equirect_dir(u, v, rot); x += dir[0] * lum; y += dir[1] * lum; @@ -863,9 +867,16 @@ pub(crate) fn add_environment( shadow_darkness: f64, shadow_diffusion: f64, ) { + // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels. + // A white (or off) light leaves them as they are. + let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0; + let tint = if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) { intensity.map(|c| c / k) } else { [1.0, 1.0, 1.0] }; // The shadow direction and key strength from the full-resolution image, before the mip - // chain averages it — only a shadow-casting light reads them, so only one pays for the pass. - let (sun, key) = if casts_shadows { dominant_direction(w, h, &data, rotation) } else { ([0.0; 3], 0.0) }; + // chain averages it — only a shadow-casting light reads them, so only one pays for the + // pass — and weighted by the tint, so the shadow follows the radiance the light emits: + // opposing chromatic regions that cancel untinted need not cancel once the colour removes + // one of them (a red light over equally bright red and blue sides shadows from the red). + let (sun, key) = if casts_shadows { dominant_direction(w, h, &data, tint, rotation) } else { ([0.0; 3], 0.0) }; // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { @@ -888,10 +899,7 @@ pub(crate) fn add_environment( let (sw, sh, sd) = small.unwrap_or((w, h, d)); let irr = irradiance_map(sw, sh, &sd, 32, 16); let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32; - // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels. - let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0; - if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) { - let tint = intensity.map(|c| c / k); + if tint != [1.0, 1.0, 1.0] { for t in first..=ii { let ti = b.s.textures[t as usize]; for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] { diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index fd3c615ea..3f97615f9 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -74,9 +74,9 @@ fn constant_environment_has_constant_irradiance() { #[test] fn a_non_finite_environment_has_no_sun_direction() { let nan = vec![[f32::NAN; 4]; 8 * 4]; - assert_eq!(dominant_direction(8, 4, &nan, 0.0), ([0.0; 3], 0.0)); + assert_eq!(dominant_direction(8, 4, &nan, [1.0; 3], 0.0), ([0.0; 3], 0.0)); let inf = vec![[f32::INFINITY, 0.0, 0.0, 1.0]; 8 * 4]; - assert_eq!(dominant_direction(8, 4, &inf, 0.0), ([0.0; 3], 0.0)); + assert_eq!(dominant_direction(8, 4, &inf, [1.0; 3], 0.0), ([0.0; 3], 0.0)); } /// The key fraction: a uniform image is directionless (key 0), a lone bright spot on black is @@ -85,13 +85,13 @@ fn a_non_finite_environment_has_no_sun_direction() { #[test] fn environment_key_strength_tracks_directionality() { let uniform = vec![[0.5f32, 0.5, 0.5, 1.0]; 8 * 4]; - assert_eq!(dominant_direction(8, 4, &uniform, 0.0).1, 0.0); + assert_eq!(dominant_direction(8, 4, &uniform, [1.0; 3], 0.0).1, 0.0); let mut spot = vec![[0.0f32; 4]; 64 * 32]; spot[16 * 64 + 32] = [1.0, 1.0, 1.0, 1.0]; - let (_, key) = dominant_direction(64, 32, &spot, 0.0); + let (_, key) = dominant_direction(64, 32, &spot, [1.0; 3], 0.0); assert!(key > 0.95, "a lone spot is almost fully directional: {key}"); let (w, h, d) = scene::default_environment(); - let (_, key) = dominant_direction(w, h, &d, 0.0); + let (_, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0); assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}"); } @@ -110,18 +110,47 @@ fn opposing_bright_regions_cancel_to_no_shadow() { let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize); opposed[a] = [1.0, 1.0, 1.0, 1.0]; opposed[b] = [1.0, 1.0, 1.0, 1.0]; - assert_eq!(dominant_direction(w, h, &opposed, 0.0), ([0.0; 3], 0.0)); + assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0)); // One side twice as bright: the mean points at the brighter spot with its share of the // weight (key = 1/3 — two parts against one). let mut tilted = opposed.clone(); tilted[a] = [2.0, 2.0, 2.0, 1.0]; - let (sun, key) = dominant_direction(w, h, &tilted, 0.0); + let (sun, key) = dominant_direction(w, h, &tilted, [1.0; 3], 0.0); assert!(sun != [0.0; 3] && key > 0.3, "the brighter side wins: {sun:?} key {key}"); let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0); let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; assert!(dot > 0.999, "the sun points at the brighter spot: {sun:?} vs {want:?}"); } +/// #2436 review: the sun follows the tinted radiance, not the raw texels — equally bright red +/// and blue regions on exactly opposite texels cancel to a white light (no shadow), but the +/// red light's tint removes the blue side's pull, so the mean points straight at the red one +/// (a blue light, at the blue one): the shadow lands where the emitted light comes from. +#[test] +fn a_coloured_light_shadows_from_the_region_it_tints() { + let (w, h) = (64u32, 32u32); + let mut opposed = vec![[0.0f32; 4]; (w * h) as usize]; + // Rows 15 and 16 mirror about the equator, columns 16 and 48 half a width apart: the two + // texel-centre directions are exactly opposite — equal solid-angle weight, opposite pull. + let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize); + opposed[a] = [1.0, 0.0, 0.0, 1.0]; + opposed[b] = [0.0, 0.0, 1.0, 1.0]; + // To a white light the channel sums are equal: they cancel, no shadow. + assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0)); + // A red light's tint zeroes the blue texel's weight: the red one becomes a lone spot. + // (Any positive scale of the tint — the normalised [3, 0, 0] included — points the same + // way; only the channel ratios matter.) + let (sun, key) = dominant_direction(w, h, &opposed, [1.0, 0.0, 0.0], 0.0); + let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999 && key > 0.95, "the red side wins: {sun:?} key {key}"); + // A blue light points at the blue one instead. + let (sun, _) = dominant_direction(w, h, &opposed, [0.0, 0.0, 1.0], 0.0); + let want = shade::equirect_dir(48.5 / 64.0, 16.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999, "the blue side wins: {sun:?}"); +} + #[test] fn equirect_round_trip_and_env_brdf() { for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] { @@ -596,6 +625,83 @@ fn a_uniform_environment_casts_no_shadow() { assert_eq!(casting.data, off.data, "a directionless image casts no shadow"); } +/// #2436 review: a chromatic environment under a coloured light — equally bright red and blue +/// regions on exactly opposite sides of the image cancel to a white light (it casts no +/// shadow), but the red Environment light's tint removes the blue region's pull, so the red +/// one becomes the key and casts the shadow the user asked for, from the red side. +#[test] +fn a_red_light_shadows_from_the_red_region_of_a_chromatic_environment() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + // The Environment Layer: a black background with a red square and an equally bright blue + // one at exactly antipodal image positions (the blue is the point mirror of the red through + // the antipodal map x -> x + w/2, y -> h - y: centres (14.5, 44.5) and (114.5, 105.5) in a + // 200x150 image, 20x20 each) — equal solid-angle weight, opposite pull. The red sits up, + // left and toward the camera; the blue down, right and away from it. + add(&mut p, cid, LayerSource::Shape, |l| { + l.environment = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + // Contents stack like the AE timeline — the first group is on top — so the background + // goes in last, under the red and blue squares (and its full-comp rect keeps the + // layer buffer full-size: a shape layer's buffer otherwise crops to its content). + let red_rect = build::shape_rect(ids, [20.0, 20.0], [-85.5, -30.5], 0.0); + let red_fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]); + let red = build::shape_group(ids, "Red", vec![red_rect, red_fill]); + let blue_rect = build::shape_rect(ids, [20.0, 20.0], [14.5, 30.5], 0.0); + let blue_fill = build::shape_fill(ids, [0.0, 0.0, 1.0, 1.0]); + let blue = build::shape_group(ids, "Blue", vec![blue_rect, blue_fill]); + let bg_rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0); + let bg_fill = build::shape_fill(ids, [0.0, 0.0, 0.0, 1.0]); + let bg = build::shape_group(ids, "BG", vec![bg_rect, bg_fill]); + for g in [red, blue, bg] { + l.props.sub_mut("contents").unwrap().children.push(g.into()); + } + }); + // A white light over the chromatic image: the regions cancel — Cast Shadows changes + // nothing, exactly like a directionless image. + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let white_casting = render(&p, cid); + let comp = p.comp_mut(cid).unwrap(); + set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/castsShadows", Value::Bool(false)); + let white_off = render(&p, cid); + assert_eq!(white_casting.data, white_off.data, "a white light sees no dominant region in the opposed red and blue"); + // The red light: the tint removes the blue region, the red one shadows the sphere onto the + // floor below and right of it (the shadow falls opposite the red region). + let comp = p.comp_mut(cid).unwrap(); + let light = comp.layers.iter_mut().find(|l| l.is_light()).unwrap(); + set(light, "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0])); + set(light, "lightOptions/castsShadows", Value::Bool(true)); + let img = render(&p, cid); + let chan = |img: &crate::Image, x: usize, y: usize| img.data[y * 200 + x][0]; + let side = chan(&img, 25, 75); + let (mut core, mut cx) = (f32::INFINITY, 0usize); + for y in 100..150 { + for x in 60..170 { + if chan(&img, x, y) < core { + core = chan(&img, x, y); + cx = x; + } + } + } + assert!(side > 0.1, "the floor is lit red: {side}"); + assert!(core < side * 0.65, "the red region shadows the sphere onto the floor: {core} vs {side} at x {cx}"); + assert!(cx > 100, "the shadow is opposite the red region (right of the sphere): {cx}"); +} + /// #2436: the default environment (no Source, no Environment Layer) is a dim studio — a mean /// of 0.3, not the 1.0 that would act like a 100 % white Ambient light and wash the scene out /// (After Effects' default does not) — and directional: its dominant region points up and @@ -613,7 +719,7 @@ fn default_environment_keeps_the_level_and_points_up() { weight += sa; } assert!((mean / weight - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight); - let (sun, key) = dominant_direction(w, h, &d, 0.0); + let (sun, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0); assert!(sun[1] < -0.5, "the key is up: {sun:?}"); assert!(sun[2] < -0.3, "the key is toward the camera: {sun:?}"); assert!(sun[0].abs() < 0.1, "symmetric left-right: {sun:?}"); diff --git a/docs/agents.md b/docs/agents.md index 42e16c3ca..9effdf18a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -221,7 +221,10 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built like the other lights: Cast Shadows shades the scene from the image's dominant bright region (the default environment's key, or a source image's brightest area; an image without a sufficiently strong dominant direction has none — a uniform one, or opposing bright regions - that cancel — so it casts no shadow) — occluding only that key's share of the light, so the + that cancel — so it casts no shadow) — the region weighted by the light's colour, so a + coloured light shadows from the regions it actually lights (opposing red and blue regions + cancel to a white light, but a red light's shadow comes from the red one) — occluding only + that key's share of the light, so the shadow stays partial like After Effects' area-light shadows, and soft by default (a penumbra of at least 8 pixels or four map texels, plus Shadow Diffusion). It only lights Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a diff --git a/docs/architecture.md b/docs/architecture.md index 76f4c6a84..105eb8736 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -165,7 +165,9 @@ mip chain with the split-sum environment BRDF fit), and percentage-closer-filter (spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: one orthographic from the image's dominant bright region — none only when the image lacks a sufficiently strong dominant direction (a uniform one, or opposing bright regions that -cancel) — occluding only the image's directional key fraction of +cancel) — the region weighted by the light's colour, so a coloured light shadows from the +regions it actually lights (opposing red and blue regions cancel to a white light, but a red +light's shadow comes from the red one) — occluding only the image's directional key fraction of the light, so the shadow stays partial like After Effects' area-light shadows, with a soft default penumbra of at least eight pixels or four map texels). A Source or Environment Layer that fails to render — a deleted layer, missing footage — lights nothing: no silent fallback From 3f88c72a43a53275aaa87104fc9b1356cffbafe5 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 22:18:28 -0400 Subject: [PATCH 041/112] 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 042/112] 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 043/112] 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 044/112] 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 045/112] 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 046/112] 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 047/112] 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 048/112] 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 049/112] 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 050/112] 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 051/112] 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 052/112] 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 053/112] 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 054/112] 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 055/112] 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 056/112] 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 057/112] 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: Tue, 27 Oct 2026 19:54:42 -0400 Subject: [PATCH 058/112] Export: pin the H.264 movie bytes the FilmCraft encoder this branch uses writes (#1701) --- crates/export/tests/it/movie_bytes.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/export/tests/it/movie_bytes.rs b/crates/export/tests/it/movie_bytes.rs index 5f1f8eb6b..c761f274c 100644 --- a/crates/export/tests/it/movie_bytes.rs +++ b/crates/export/tests/it/movie_bytes.rs @@ -94,7 +94,7 @@ fn export_bytes(format: OutputFormat, name: &str) -> Vec { #[test] fn h264_movie_bytes_are_pinned() { let data = export_bytes(OutputFormat::H264, "/out/pinned.mp4"); - assert_eq!((data.len(), fnv1a(&data)), (104_161, 10_466_446_817_305_548_344), "H.264 MP4"); + assert_eq!((data.len(), fnv1a(&data)), (104_322, 11_051_847_956_767_423_392), "H.264 MP4"); } #[test] From e1d66a3c5c31c5a869969efadbd5754d619a8453 Mon Sep 17 00:00:00 2001 From: Eduardo Espinosa Date: Wed, 28 Oct 2026 22:20:23 -0400 Subject: [PATCH 059/112] perf: share unchanged layers between undo states Every undoable edit keeps the previous project for undo, so the comp it touches is shared and Arc::make_mut deep-copies it: every layer and every keyframe. An edit therefore costs time in proportion to the comp's size, and a script that sets many keyframes in a big comp goes quadratic (a 1,600-layer build with ~100k sampled keys took ~45 minutes). Comp::layers is now a Layers collection backed by Vec>: copying the stack copies pointers, and only the layer an edit changes is copied. The API mirrors Vec; serialized form is unchanged (a list of layers). Comp::layer_mut and the iter_mut().filter/find call sites use find_mut / iter_mut_where so they copy only the layers they actually change. - prop.addKey in a 400-layer comp: 1.36 ms -> 0.16 ms per key (flat in comp size) - the 1,600-layer script build: ~45 min -> 47 s, frames pixel-identical - regression tests: an edit leaves the other layers shared with undo; layers round-trip as a plain list Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VkpGcKfWyCudfSsQNiWAaG --- apps/effectcraft-cli/src/main.rs | 2 +- crates/engine/src/cmd/app_more.rs | 2 +- crates/engine/src/cmd/comp.rs | 2 +- crates/engine/src/cmd/comp_more.rs | 2 +- crates/engine/src/cmd/edit.rs | 4 +- crates/engine/src/cmd/layer/parent.rs | 2 +- crates/engine/src/cmd/layer/switches.rs | 8 +- crates/engine/src/cmd/layer/timing.rs | 6 +- crates/engine/src/cmd/layer_menu.rs | 16 +- crates/engine/src/cmd/layer_time.rs | 4 +- crates/engine/src/cmd/rig3d.rs | 4 +- crates/engine/src/cmd/three_d.rs | 2 +- crates/engine/src/home/demo.rs | 6 +- crates/engine/src/render_queue.rs | 2 +- crates/engine/src/system/perf.rs | 4 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/effects.rs | 2 +- crates/engine/src/tests/session.rs | 2 +- crates/engine/src/tests/shared_layers.rs | 42 ++++ crates/export/tests/it/movie_bytes.rs | 2 +- crates/expr/src/tests.rs | 2 +- crates/gpu/src/tests.rs | 2 +- crates/gpu/src/tests/adjust.rs | 2 +- crates/gpu/src/tests/adv3d.rs | 12 +- crates/gpu/src/tests/fx_noise.rs | 2 +- crates/gpu/src/tests/fx_time.rs | 2 +- crates/gpu/src/tests/fx_tone.rs | 2 +- crates/gpu/src/tests/three_d.rs | 6 +- crates/host/tests/it/footage_reload.rs | 6 +- crates/host/tests/it/vector_import.rs | 2 +- crates/interchange/src/tests.rs | 2 +- crates/project/src/comp.rs | 8 +- crates/project/src/layers.rs | 209 ++++++++++++++++++++ crates/project/src/lib.rs | 6 +- crates/project/src/styles.rs | 2 +- crates/psd/src/memory_tests.rs | 2 +- crates/psd/src/parse.rs | 2 +- crates/psd/src/tests.rs | 2 +- crates/psd/src/tests/opt.rs | 2 +- crates/render/src/audio.rs | 8 +- crates/render/src/disk_cache.rs | 2 +- crates/render/src/tests.rs | 26 +-- crates/render/src/tests/audio_fx.rs | 4 +- crates/render/src/tests/collapse.rs | 28 +-- crates/render/src/tests/guides.rs | 4 +- crates/render/src/tests/motion_tile.rs | 6 +- crates/render/src/tests/roi.rs | 2 +- crates/render/src/tests/styles.rs | 2 +- crates/render/src/three_d/compose.rs | 1 + crates/ui-egui/src/audio.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 4 +- crates/ui-egui/src/widgets.rs | 2 +- crates/ui-egui/tests/it/ui_drag_drop.rs | 2 +- crates/ui-egui/tests/it/ui_new_primitive.rs | 2 +- 54 files changed, 369 insertions(+), 114 deletions(-) create mode 100644 crates/engine/src/tests/shared_layers.rs create mode 100644 crates/project/src/layers.rs diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 6e5b8d6cc..073153c09 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -1189,7 +1189,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option Result { let ids: Vec = proj.items.keys().copied().filter(|c| *c != comp).collect(); for cid in ids { if let Some(c) = proj.comp_mut(cid) { - for l in c.layers.iter_mut().filter(|l| is_user(l)) { + for l in c.layers.iter_mut_where(|l| is_user(l)) { l.source = LayerSource::Comp { item: comp }; n += 1; } diff --git a/crates/engine/src/cmd/comp.rs b/crates/engine/src/cmd/comp.rs index 6648a7426..8bc0931f8 100644 --- a/crates/engine/src/cmd/comp.rs +++ b/crates/engine/src/cmd/comp.rs @@ -35,7 +35,7 @@ fn apply_settings(c: &mut Comp, p: &Value) { let dx = (c.width as f64 - ow as f64) * (a % 3) as f64 / 2.0; let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0; if dx != 0.0 || dy != 0.0 { - for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { + for l in c.layers.iter_mut_where(|l| l.parent.is_none()) { if let Some(tr) = l.transform_mut() { super::layer::offset_position(tr, dx, dy); } diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index d6de320ec..3982bc2d9 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -25,7 +25,7 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: & let (old_w, old_h) = (c.width, c.height); c.width = w; c.height = h; - for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { + for l in c.layers.iter_mut_where(|l| l.parent.is_none()) { if let Some(tr) = l.transform_mut() { super::layer::offset_position(tr, -x, -y); } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d0dbabb5b..f10a3706a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -90,7 +90,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result { fn relink_copies(comp: &mut Comp, map: &[(LayerId, LayerId)]) { let present: Vec = comp.layers.iter().map(|l| l.id).collect(); let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, c)| *c).or(present.contains(&id).then_some(id)); - for l in comp.layers.iter_mut().filter(|l| map.iter().any(|(_, c)| *c == l.id)) { + for l in comp.layers.iter_mut_where(|l| map.iter().any(|(_, c)| *c == l.id)) { l.parent = l.parent.and_then(link); l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m })); } @@ -675,7 +675,7 @@ fn label(s: &mut Session, p: &Value) -> Result { } } if let Some(comp) = proj.comp_mut(cid) { - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.label = lab; } } diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index fbaec14e4..f2b68ba7d 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -323,7 +323,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result { let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] }; s.edit("Parent", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.parent = parent; if let Some(f) = fixes.iter().find(|f| f.layer == l.id) { f.apply(l); diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs index 38689afbb..6e2a41892 100644 --- a/crates/engine/src/cmd/layer/switches.rs +++ b/crates/engine/src/cmd/layer/switches.rs @@ -19,7 +19,7 @@ pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result { let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false)); let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) }; let target = v.unwrap_or(!first); - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { set_switch_value(l, &name, target); last = target; } @@ -55,7 +55,7 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, alone &= on == (ids.contains(&l.id) == active); } let others = value.unwrap_or(alone == active); - for l in comp.layers.iter_mut().filter(|l| applies(l)) { + for l in comp.layers.iter_mut_where(|l| applies(l)) { let v = if ids.contains(&l.id) { active } else { others }; set_switch_value(l, name, v); } @@ -152,7 +152,7 @@ pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result { let text = str_p(p, "comment").ok_or_else(|| bad("layer.setComment", "missing `comment`"))?.to_string(); s.edit("Layer Comment", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.comment = text.clone(); } Ok(()) @@ -169,7 +169,7 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result { } s.edit("Blending Mode", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.blend_mode = match mode { Some(m) => m, None if step > 0 => l.blend_mode.next(), diff --git a/crates/engine/src/cmd/layer/timing.rs b/crates/engine/src/cmd/layer/timing.rs index 711eefee2..38ecf3a61 100644 --- a/crates/engine/src/cmd/layer/timing.rs +++ b/crates/engine/src/cmd/layer/timing.rs @@ -18,7 +18,7 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fd = comp.frame_duration(); let comp_dur = comp.duration; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { let shift = |l: &mut Layer, d: Tick| { l.start_time += d; l.in_point += d; @@ -159,14 +159,14 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result { for i in 1..n { if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) { let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n); - comp.layers[i - 1..run_end].rotate_left(1); + comp.layers.rotate_range_left(i - 1..run_end, 1); } } } else { for i in (0..n.saturating_sub(1)).rev() { if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) { let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1); - comp.layers[run_start..=i + 1].rotate_right(1); + comp.layers.rotate_range_right(run_start..=i + 1, 1); } } } diff --git a/crates/engine/src/cmd/layer_menu.rs b/crates/engine/src/cmd/layer_menu.rs index 33d17bb4f..be11e6b74 100644 --- a/crates/engine/src/cmd/layer_menu.rs +++ b/crates/engine/src/cmd/layer_menu.rs @@ -61,7 +61,7 @@ fn quality(s: &mut Session, p: &Value) -> Result { q => return Err(bad("layer.quality", format!("quality must be best|draft|wireframe, not `{q}`"))), }; s.edit("Layer Quality", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.switches.quality = q; } Ok(()) @@ -84,7 +84,7 @@ fn sampling(s: &mut Session, p: &Value) -> Result { q => q, }; s.edit("Layer Sampling", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.switches.sampling = q; } Ok(()) @@ -101,7 +101,7 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result { m => return Err(bad("layer.frameBlending", format!("mode must be off|frameMix|pixelMotion, not `{m}`"))), }; s.edit("Frame Blending", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.switches.frame_blend = m; } Ok(()) @@ -116,7 +116,7 @@ fn hide_other_video(s: &mut Session, p: &Value) -> Result { s.edit("Hide Other Video", None, |proj, _| { // Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's // eye cell shows): the selection stays shown. - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| !ids.contains(&l.id)) { l.switches.video = false; } Ok(()) @@ -130,7 +130,7 @@ fn show_all_video(s: &mut Session, p: &Value) -> Result { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; // Track mattes stay hidden (their video switch is off by design). let mattes: Vec = c.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect(); - for l in c.layers.iter_mut().filter(|l| !mattes.contains(&l.id)) { + for l in c.layers.iter_mut_where(|l| !mattes.contains(&l.id)) { l.switches.video = true; } Ok(()) @@ -154,7 +154,7 @@ fn expressions(s: &mut Session, p: &Value) -> Result { let on = b_p(p, "enabled").unwrap_or(true); let n = s.edit(if on { "Enable Expressions" } else { "Disable Expressions" }, None, |proj, _| { let mut n = 0; - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.props.walk_mut(&mut |pr| { if let Some(e) = &mut pr.expr { e.enabled = on; @@ -586,7 +586,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result { let r = s.edit("Lock Markers", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let target = v.unwrap_or_else(|| !c.layers.iter().find(|l| ids.contains(&l.id)).is_some_and(|l| l.markers_locked)); - for l in c.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in c.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.markers_locked = target; } Ok(target) @@ -597,7 +597,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result { fn delete_all_markers(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; s.edit("Delete All Markers", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id) && !l.markers_locked) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id) && !l.markers_locked) { l.markers.clear(); } Ok(()) diff --git a/crates/engine/src/cmd/layer_time.rs b/crates/engine/src/cmd/layer_time.rs index d98bfdea7..370745e4e 100644 --- a/crates/engine/src/cmd/layer_time.rs +++ b/crates/engine/src/cmd/layer_time.rs @@ -34,7 +34,7 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { let fd = comp_rate.frame_duration(); s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { let old = l.stretch; if reverse { // Keep the in/out points; play the visible span backwards. @@ -109,7 +109,7 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result { let mut next = proj.next_id; let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut on = false; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id) && remappable(l)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id) && remappable(l)) { let has = l.props.get("timeRemap").is_some(); let target = want.unwrap_or(!has); on |= target; diff --git a/crates/engine/src/cmd/rig3d.rs b/crates/engine/src/cmd/rig3d.rs index f034fa641..8b64aa1e9 100644 --- a/crates/engine/src/cmd/rig3d.rs +++ b/crates/engine/src/cmd/rig3d.rs @@ -268,7 +268,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result { set_expr(&mut cam, "cameraOptions/zoom", ze); cam.auto_orient = AutoOrient::TowardsPointOfInterest; let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?; - comp.layers = vec![cam, nest]; + comp.layers = vec![cam, nest].into(); eye_comps.push(eid); } // The output comp: both eyes through 3D Glasses. @@ -291,7 +291,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result { fxg.children.push(g.into()); } let (lid, rid) = (left.id, right.id); - proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right]; + proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right].into(); st.project_selection = vec![oid]; Ok(json!({ "controls": ctl_id.0, diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs index e143690d3..846b13536 100644 --- a/crates/engine/src/cmd/three_d.rs +++ b/crates/engine/src/cmd/three_d.rs @@ -532,7 +532,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { }; s.edit("Auto-Orient", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { let rig = l.is_camera() || l.is_light(); let ok = match mode { AutoOrient::Off | AutoOrient::AlongPath => true, diff --git a/crates/engine/src/home/demo.rs b/crates/engine/src/home/demo.rs index 656bd7708..7503178fa 100644 --- a/crates/engine/src/home/demo.rs +++ b/crates/engine/src/home/demo.rs @@ -121,7 +121,7 @@ fn lower_third(p: &mut Project) -> Comp { [440.0, 914.0], ); anim(&mut title, "transform/opacity", keys(&[(0.4, Value::Scalar(0.0)), (0.9, Value::Scalar(100.0))])); - c.layers = vec![title, bar]; + c.layers = vec![title, bar].into(); c } @@ -270,7 +270,7 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp { ); anim(&mut caption, "transform/opacity", keys(&[(0.3, Value::Scalar(0.0)), (1.2, Value::Scalar(100.0))])); - c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor]; + c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor].into(); c } @@ -463,7 +463,7 @@ pub fn demo_project() -> Project { lower.out_point = t(9.5); // Stack: top first. - comp.layers = vec![lower, title, sub, line, burst, rings, null, bg]; + comp.layers = vec![lower, title, sub, line, burst, rings, null, bg].into(); for l in &mut comp.layers { if l.name == "Controller" { l.switches.video = false; diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 27e9a7dce..05e4eef23 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -676,7 +676,7 @@ impl Session { continue; } if let Some(c) = proj.comp_mut(cid) { - for l in c.layers.iter_mut().filter(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) { + for l in c.layers.iter_mut_where(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) { l.source = LayerSource::Footage { item: fid }; } } diff --git a/crates/engine/src/system/perf.rs b/crates/engine/src/system/perf.rs index ab1723d9f..929726c95 100644 --- a/crates/engine/src/system/perf.rs +++ b/crates/engine/src/system/perf.rs @@ -180,7 +180,7 @@ pub fn large_project(spec: &LargeSpec) -> Project { *n += 1; layers.push(l); } - comp.layers = layers; + comp.layers = layers.into(); }; let others = spec.comps.saturating_sub(1); let depth = spec.nest_depth.min(others); @@ -215,7 +215,7 @@ pub fn large_project(spec: &LargeSpec) -> Project { n += 1; layers.push(l); } - main.layers = layers; + main.layers = layers.into(); p.add_item("Main", crate::color::Label::None, None, ItemKind::Comp(Arc::new(main))); // `Main` must be the first comp `replace_project` opens: give it the smallest id. let main_id = p.items.values().find(|i| i.name == "Main").map(|i| i.id).expect("main"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 579d3d30d..8601a46a1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -17,6 +17,7 @@ mod history; mod import_project; mod key_metadata_order; mod key_time; +mod shared_layers; mod keylight; mod limits; mod lottie; diff --git a/crates/engine/src/tests/effects.rs b/crates/engine/src/tests/effects.rs index 1ea57f4cf..966c35fae 100644 --- a/crates/engine/src/tests/effects.rs +++ b/crates/engine/src/tests/effects.rs @@ -550,7 +550,7 @@ fn opening_an_old_project_upgrades_effect_instances() { effectcraft_project::ItemKind::Comp(c) => c, _ => panic!("not a composition"), }); - let l = comp.layers.iter_mut().find(|l| l.id == LayerId(a)).unwrap(); + let l = comp.layers.find_mut(|l| l.id == LayerId(a)).unwrap(); for n in &mut l.props.sub_mut("effects").unwrap().children { let Node::Group(g) = n else { continue }; if g.match_id == "ec.blur.gaussian" { diff --git a/crates/engine/src/tests/session.rs b/crates/engine/src/tests/session.rs index b3f0602b3..756333e34 100644 --- a/crates/engine/src/tests/session.rs +++ b/crates/engine/src/tests/session.rs @@ -86,7 +86,7 @@ fn imported_files_go_into_the_comp_where_they_were_dropped() { s.execute("file.import", params).unwrap(); let comp = s.active_comp().unwrap(); assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["a.png", "b.png", "Bg"]); - for l in &comp.layers[..2] { + for l in comp.layers.iter().take(2) { assert_eq!(l.props.prop("transform/position").map(|p| p.value.clone()), Some(effectcraft_keyframe::Value::Vec3([40.0, 50.0, 0.0]))); assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0)); } diff --git a/crates/engine/src/tests/shared_layers.rs b/crates/engine/src/tests/shared_layers.rs new file mode 100644 index 000000000..275fdd624 --- /dev/null +++ b/crates/engine/src/tests/shared_layers.rs @@ -0,0 +1,42 @@ +//! An edit copies only the layer it changes: the others stay shared with the undo state, so an +//! edit's cost doesn't grow with the size of the comp (scripts setting many keys went quadratic). + +use std::sync::Arc; + +use serde_json::json; + +use crate::Session; + +#[test] +fn a_key_on_one_layer_leaves_the_other_layers_shared_with_undo() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Big", "duration": 10, "frameRate": 30})).unwrap(); + let ids: Vec = (0..20).map(|i| s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap()["layer"].as_u64().unwrap()).collect(); + let cid = s.active_comp_id().unwrap(); + let before = s.project.clone(); + s.execute("prop.addKey", json!({"layer": ids[7], "path": "transform/position", "time": 1.0, "value": [5, 5]})).unwrap(); + let (old, new) = (before.comp(cid).unwrap(), s.project.comp(cid).unwrap()); + assert_eq!(old.layers.len(), new.layers.len()); + let edited = new.layers.iter().position(|l| l.id.0 == ids[7]).unwrap(); + for i in 0..new.layers.len() { + assert_eq!(new.layers.same_at(&old.layers, i), i != edited, "layer {i} shared: {}", new.layers.same_at(&old.layers, i)); + } + // Undo brings back the very same state, and the edited layer is the only one that differed. + s.execute("edit.undo", json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.project, &before)); +} + +#[test] +fn layers_save_and_load_as_a_plain_list() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Round trip", "duration": 5, "frameRate": 25})).unwrap(); + for i in 0..3 { + s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap(); + } + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let text = serde_json::to_value(&comp.layers).unwrap(); + assert_eq!(text.as_array().map(Vec::len), Some(3), "a JSON array of layers, as before"); + let back: effectcraft_project::Layers = serde_json::from_value(text).unwrap(); + assert_eq!(&back, &comp.layers); +} diff --git a/crates/export/tests/it/movie_bytes.rs b/crates/export/tests/it/movie_bytes.rs index 5f1f8eb6b..fd54c1ac4 100644 --- a/crates/export/tests/it/movie_bytes.rs +++ b/crates/export/tests/it/movie_bytes.rs @@ -61,7 +61,7 @@ fn project() -> (Project, ItemId) { } box_.blend_mode = BlendMode::Screen; - comp.layers = vec![box_, ring, solid]; + comp.layers = vec![box_, ring, solid].into(); let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into())); (p, cid) } diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 7d2790255..8b12a6437 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -76,7 +76,7 @@ fn fx() -> Fx { let ids = (a.id, b.id, title.id, shape.id); let c = p.comp_mut(cid).unwrap(); - c.layers = vec![a, b, title, shape]; + c.layers = vec![a, b, title, shape].into(); c.markers.push(Marker { time: secs(1.5), comment: "comp marker".into(), ..Default::default() }); let other = Comp::new(64, 32, FrameRate::FPS_24, Tick::from_seconds_f64(3.0)); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index fa318ef79..5b9c4a99d 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -498,7 +498,7 @@ fn layer_styles_collapsed_precomps_and_cpu_fallbacks() { b.blend_mode = BlendMode::Difference; (a, b) }; - s.p.comp_mut(inner_id).unwrap().layers = vec![b, a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![b, a].into(); let bg = s.footage(97, 61); s.push(bg); let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); diff --git a/crates/gpu/src/tests/adjust.rs b/crates/gpu/src/tests/adjust.rs index 5635e08e1..39438d43e 100644 --- a/crates/gpu/src/tests/adjust.rs +++ b/crates/gpu/src/tests/adjust.rs @@ -83,7 +83,7 @@ fn track_mattes_limit_gpu_adjustments_like_the_cpu() { set(&mut matte, "transform/rotation", Value::Scalar(10.0)); let mid = s.push(matte); let c = s.p.comp_mut(s.cid).unwrap(); - let adj = c.layers.iter_mut().find(|l| l.switches.adjustment).unwrap(); + let adj = c.layers.find_mut(|l| l.switches.adjustment).unwrap(); adj.track_matte = Some(TrackMatte { layer: mid, kind }); check(&format!("matted adjustment {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); } diff --git a/crates/gpu/src/tests/adv3d.rs b/crates/gpu/src/tests/adv3d.rs index 21422aa6b..87e32426d 100644 --- a/crates/gpu/src/tests/adv3d.rs +++ b/crates/gpu/src/tests/adv3d.rs @@ -181,7 +181,7 @@ fn gpu_matches_cpu_wide_penumbrae() { set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(30.0)); let s = scene(&p, cid); let (cpu, gt) = (adv::raster::render(&s), g.raster_3d(&s).expect("gpu raster")); - for l in &mut p.comp_mut(cid).unwrap().layers[..2] { + for l in p.comp_mut(cid).unwrap().layers.iter_mut().take(2) { set(l, "lightOptions/castsShadows", Value::Bool(false)); } let unshadowed = adv::raster::render(&scene(&p, cid)); @@ -251,7 +251,7 @@ pub(crate) fn dof_mb_project() -> (Project, ItemId) { c.enable_motion_blur = true; c.motion_blur_samples = 6; // The sphere moves 40 px per frame. - let sphere = c.layers.iter_mut().find(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap(); + let sphere = c.layers.find_mut(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap(); sphere.switches.motion_blur = true; sphere.props.prop_mut("transform/position").unwrap().keys = vec![ effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([80.0, 95.0, 0.0])), @@ -411,7 +411,7 @@ fn gpu_compositor_draws_advanced_3d_runs_on_the_device() { eprintln!("walk: {:.3} % beyond 1/255 (max {max:.4})", share * 100.0); assert!(share <= 0.01, "{:.3} % of pixels differ", share * 100.0); // A blend mode in the run takes the 2D compositing path (`SplitRun`, also on the GPU). - p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode = + p.comp_mut(cid).unwrap().layers.find_mut(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode = effectcraft_color::BlendMode::Screen; let (ctx, run) = run_of(&p, cid); assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_none()); @@ -532,12 +532,12 @@ fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() { }); } let c = p.comp_mut(cid).unwrap(); - let env = c.layers.iter_mut().find(|l| l.environment).unwrap(); + let env = c.layers.find_mut(|l| l.environment).unwrap(); env.props.sub_mut("effects").unwrap().children.push(ramp.into()); let ids: Vec<_> = c.layers.iter().filter(|l| l.switches.three_d && !l.is_light() && !l.environment && l.source.is_av()).map(|l| l.id).collect(); let top = c.layers[0].id; fn by_id(c: &mut Comp, id: effectcraft_project::LayerId) -> &mut effectcraft_project::Layer { - c.layers.iter_mut().find(|l| l.id == id).unwrap() + c.layers.find_mut(|l| l.id == id).unwrap() } match case { 0 => by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Screen, @@ -555,7 +555,7 @@ fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() { } _ => { // An environment background behind the run, and the 2D track matte. - c.layers.iter_mut().find(|l| l.environment).unwrap().environment_background = true; + c.layers.find_mut(|l| l.environment).unwrap().environment_background = true; by_id(c, ids[0]).track_matte = Some(effectcraft_project::TrackMatte { layer: top, kind: effectcraft_project::MatteKind::Alpha }); } } diff --git a/crates/gpu/src/tests/fx_noise.rs b/crates/gpu/src/tests/fx_noise.rs index 38a30aca5..7d13004ee 100644 --- a/crates/gpu/src/tests/fx_noise.rs +++ b/crates/gpu/src/tests/fx_noise.rs @@ -208,7 +208,7 @@ fn echo_and_posterize_time() { pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(50.0, 30.0))]; let rot = a.props.prop_mut("transform/rotation").unwrap(); rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))]; - s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a].into(); let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); s.effect(&mut l, id, &vals); set(&mut l, "transform/rotation", Value::Scalar(8.0)); diff --git a/crates/gpu/src/tests/fx_time.rs b/crates/gpu/src/tests/fx_time.rs index 0c94bb413..c71355736 100644 --- a/crates/gpu/src/tests/fx_time.rs +++ b/crates/gpu/src/tests/fx_time.rs @@ -40,7 +40,7 @@ fn moving(id: &str, vals: &[(&str, Value)], map: Option<&str>) { pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), v3(50.0, 30.0))]; let rot = a.props.prop_mut("transform/rotation").unwrap(); rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))]; - s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a].into(); let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); s.effect(&mut l, id, &vals); set(&mut l, "transform/rotation", Value::Scalar(8.0)); diff --git a/crates/gpu/src/tests/fx_tone.rs b/crates/gpu/src/tests/fx_tone.rs index 08212a12a..40bc9483f 100644 --- a/crates/gpu/src/tests/fx_tone.rs +++ b/crates/gpu/src/tests/fx_tone.rs @@ -164,7 +164,7 @@ fn color_stabilizer() { pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(22.0, 18.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(38.0, 24.0))]; let op = a.props.prop_mut("transform/opacity").unwrap(); op.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(40.0))]; - s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a].into(); let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); let vals = [ ("stabilize", e(mode)), diff --git a/crates/gpu/src/tests/three_d.rs b/crates/gpu/src/tests/three_d.rs index bd9455b9e..8073681e1 100644 --- a/crates/gpu/src/tests/three_d.rs +++ b/crates/gpu/src/tests/three_d.rs @@ -161,7 +161,7 @@ fn mattes_preserve_transparency_precomps_and_adjustment_splits() { let mid = s.push(m); let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; layers[1].track_matte = Some(TrackMatte { layer: mid, kind: MatteKind::Luma }); - if let Some(far) = layers.iter_mut().find(|l| l.switches.three_d && l.name == "Pattern") { + if let Some(far) = layers.find_mut(|l| l.switches.three_d && l.name == "Pattern") { far.preserve_transparency = true; } // Move the matte below the bottom so the 3D run stays one run. @@ -187,7 +187,7 @@ fn mattes_preserve_transparency_precomps_and_adjustment_splits() { a.switches.three_d = true; set(&mut a, "transform/rotationX", Value::Scalar(40.0)); let b = s.solid([0.2, 0.8, 0.4], 30, 20); - s.p.comp_mut(inner_id).unwrap().layers = vec![b, a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![b, a].into(); let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (80, 60), None); pre.switches.collapse = true; pre.switches.three_d = true; @@ -277,7 +277,7 @@ fn two_d_layer_with_a_three_d_track_matte() { let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; let fi = layers.iter().position(|l| l.id == fid).unwrap(); layers[fi].track_matte = Some(TrackMatte { layer: mid, kind }); - if let Some(ml) = layers.iter_mut().find(|l| l.id == mid) { + if let Some(ml) = layers.find_mut(|l| l.id == mid) { ml.switches.video = false; } check(&format!("3d matte {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); diff --git a/crates/host/tests/it/footage_reload.rs b/crates/host/tests/it/footage_reload.rs index faf2b09e3..3d47d0783 100644 --- a/crates/host/tests/it/footage_reload.rs +++ b/crates/host/tests/it/footage_reload.rs @@ -24,7 +24,7 @@ const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; /// A one-colour document; `extra` adds a hidden layer, so the file's size changes too. fn flat(rgba: [f32; 4], extra: bool) -> Vec { let mut d = WDoc::new(64, 48); - d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), rgba)]; + d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), rgba)].into(); if extra { let mut hidden = WLayer::solid("Hidden", Rect::new(0, 0, 8, 8), RED); hidden.hidden = true; @@ -86,7 +86,7 @@ fn reload_footage_shows_the_new_pixels() { fn reload_keeps_a_photoshop_layer_item_on_its_layer() { let path = tmp("layers.psd"); let mut d = WDoc::new(64, 48); - d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED), WLayer::solid("Patch", Rect::new(0, 0, 32, 48), BLUE)]; + d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED), WLayer::solid("Patch", Rect::new(0, 0, 32, 48), BLUE)].into(); std::fs::write(&path, write(&d)).unwrap(); let mut s = effectcraft_host::session(); let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap(); @@ -100,7 +100,7 @@ fn reload_keeps_a_photoshop_layer_item_on_its_layer() { WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED), WLayer::solid("Added", Rect::new(40, 0, 8, 8), RED), WLayer::solid("Patch", Rect::new(0, 0, 24, 48), GREEN), - ]; + ].into(); save(&path, &write(&d)); let footage_items: Vec = items.iter().filter(|i| matches!(s.project.item(**i).map(|x| &x.kind), Some(ItemKind::Footage(_)))).map(|i| i.0).collect(); s.execute_checked("file.reloadFootage", json!({"items": footage_items})).unwrap(); diff --git a/crates/host/tests/it/vector_import.rs b/crates/host/tests/it/vector_import.rs index 36d83dd1a..badd60b66 100644 --- a/crates/host/tests/it/vector_import.rs +++ b/crates/host/tests/it/vector_import.rs @@ -509,7 +509,7 @@ fn shapes_from_vector_layer_keeps_images_as_footage_layers() { assert_eq!(names, ["images.pdf Outlines", "Image", "Image", "images.pdf Outlines 1", "images.pdf"]); assert_eq!(c.layers[0].id.0, sid); assert_eq!(r["layers"].as_array().unwrap().len(), 2); - for l in &c.layers[1..3] { + for l in c.layers.iter().skip(1).take(2) { assert_eq!(l.parent.map(|p| p.0), Some(sid), "{}", l.name); assert!(matches!(l.source, effectcraft_project::LayerSource::Footage { .. })); } diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 3ad7fcaae..76fd542fc 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -411,7 +411,7 @@ fn ae_project() -> (Project, ItemId) { let mut ls = effectcraft_project::build::layer(&mut p, &c, "tone", LayerSource::Footage { item: s }, (0, 0), None); ls.out_point = t(3.0); ls.props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-3.0, -3.0]); - c.layers = vec![lt, lp, la, ls]; + c.layers = vec![lt, lp, la, ls].into(); let cid = comp_item(&mut p, "Main", c); (p, cid) } diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index 5a3c8650e..4b620b192 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -87,8 +87,8 @@ pub struct Comp { pub display_start: Tick, pub background: [f32; 3], pub work_area: (Tick, Tick), - /// Layer #1 first (top of the stack). - pub layers: Vec, + /// Layer #1 first (top of the stack). Each layer is shared between undo states until edited. + pub layers: super::Layers, #[serde(default)] pub markers: Vec, pub shutter_angle: f64, @@ -188,7 +188,7 @@ impl Comp { display_start: Tick::ZERO, background: [0.0, 0.0, 0.0], work_area: (Tick::ZERO, duration), - layers: vec![], + layers: super::Layers::new(), markers: vec![], shutter_angle: 180.0, shutter_phase: -90.0, @@ -239,7 +239,7 @@ impl Comp { } pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> { - self.layers.iter_mut().find(|l| l.id == id) + self.layers.find_mut(|l| l.id == id) } /// 1-based layer number (#) of a layer. diff --git a/crates/project/src/layers.rs b/crates/project/src/layers.rs new file mode 100644 index 000000000..6119a5753 --- /dev/null +++ b/crates/project/src/layers.rs @@ -0,0 +1,209 @@ +//! A comp's layer stack with structural sharing. +//! +//! Every undoable edit keeps the previous project for undo, so the comp it touches is shared and +//! `Arc::make_mut` copies it. With the layers stored inline that copy is the whole stack, every +//! layer and every keyframe, so an edit costs time in proportion to the comp's size and a script +//! that sets many keys in a big comp goes quadratic. Here each layer sits behind its own `Arc`: +//! copying the stack copies pointers, and only the layer an edit actually changes is copied. +//! +//! The API mirrors the `Vec` it replaces. Serialized form is identical (a list of layers). + +use std::ops::{Index, IndexMut}; +use std::sync::Arc; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::layer::Layer; + +/// Layer #1 first (top of the stack). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Layers(Vec>); + +impl Layers { + pub const fn new() -> Self { + Self(Vec::new()) + } + pub fn with_capacity(n: usize) -> Self { + Self(Vec::with_capacity(n)) + } + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn iter(&self) -> impl DoubleEndedIterator + ExactSizeIterator + Clone { + self.0.iter().map(|l| &**l) + } + /// Mutable access to every layer: each one that is still shared with an undo state is copied, + /// so prefer [`Layers::get_mut`] (or `iter` to find, then `get_mut`) when only some change. + pub fn iter_mut(&mut self) -> impl DoubleEndedIterator + ExactSizeIterator { + self.0.iter_mut().map(Arc::make_mut) + } + /// Mutable access to just the layers `keep` accepts. `keep` reads each layer without copying + /// it; only the matching ones are copied (if shared). Use this, not `iter_mut().filter(..)`. + pub fn iter_mut_where<'a>(&'a mut self, mut keep: impl FnMut(&Layer) -> bool + 'a) -> impl Iterator + 'a { + self.0.iter_mut().filter(move |l| keep(l)).map(Arc::make_mut) + } + /// The first layer `pred` accepts, copied only if it is that one. Use this, not `iter_mut().find(..)`. + pub fn find_mut(&mut self, mut pred: impl FnMut(&Layer) -> bool) -> Option<&mut Layer> { + self.0.iter_mut().find(|l| pred(l)).map(Arc::make_mut) + } + pub fn get(&self, i: usize) -> Option<&Layer> { + self.0.get(i).map(|l| &**l) + } + pub fn get_mut(&mut self, i: usize) -> Option<&mut Layer> { + self.0.get_mut(i).map(Arc::make_mut) + } + pub fn first(&self) -> Option<&Layer> { + self.get(0) + } + pub fn last(&self) -> Option<&Layer> { + self.0.last().map(|l| &**l) + } + pub fn first_mut(&mut self) -> Option<&mut Layer> { + self.get_mut(0) + } + pub fn last_mut(&mut self) -> Option<&mut Layer> { + self.0.last_mut().map(Arc::make_mut) + } + pub fn push(&mut self, l: Layer) { + self.0.push(Arc::new(l)); + } + pub fn insert(&mut self, i: usize, l: Layer) { + self.0.insert(i, Arc::new(l)); + } + pub fn remove(&mut self, i: usize) -> Layer { + Arc::unwrap_or_clone(self.0.remove(i)) + } + pub fn pop(&mut self) -> Option { + self.0.pop().map(Arc::unwrap_or_clone) + } + pub fn retain(&mut self, mut f: impl FnMut(&Layer) -> bool) { + self.0.retain(|l| f(l)); + } + pub fn swap(&mut self, a: usize, b: usize) { + self.0.swap(a, b); + } + pub fn truncate(&mut self, n: usize) { + self.0.truncate(n); + } + pub fn clear(&mut self) { + self.0.clear(); + } + pub fn reverse(&mut self) { + self.0.reverse(); + } + pub fn sort_by_key(&mut self, mut f: impl FnMut(&Layer) -> K) { + self.0.sort_by_key(|l| f(l)); + } + pub fn sort_by(&mut self, mut f: impl FnMut(&Layer, &Layer) -> std::cmp::Ordering) { + self.0.sort_by(|a, b| f(a, b)); + } + pub fn rotate_left(&mut self, n: usize) { + self.0.rotate_left(n); + } + pub fn rotate_right(&mut self, n: usize) { + self.0.rotate_right(n); + } + /// `layers[range].rotate_left(n)` (a slice of `Arc`s can't be handed out as a `&mut [Layer]`). + pub fn rotate_range_left(&mut self, r: impl std::slice::SliceIndex<[Arc], Output = [Arc]>, n: usize) { + if let Some(s) = self.0.get_mut(r) { + s.rotate_left(n); + } + } + pub fn rotate_range_right(&mut self, r: impl std::slice::SliceIndex<[Arc], Output = [Arc]>, n: usize) { + if let Some(s) = self.0.get_mut(r) { + s.rotate_right(n); + } + } + pub fn split_off(&mut self, at: usize) -> Layers { + Layers(self.0.split_off(at)) + } + pub fn append(&mut self, other: &mut Layers) { + self.0.append(&mut other.0); + } + /// Remove a range of layers and return them (owned). + pub fn drain(&mut self, r: impl std::ops::RangeBounds) -> std::vec::IntoIter { + self.0.drain(r).map(Arc::unwrap_or_clone).collect::>().into_iter() + } + /// Whether `self` and `other` hold the very same (unchanged) layer at `i`: a cheap "did this + /// layer change" test between two project states. + pub fn same_at(&self, other: &Layers, i: usize) -> bool { + matches!((self.0.get(i), other.0.get(i)), (Some(a), Some(b)) if Arc::ptr_eq(a, b)) + } + /// The shared layers themselves, for parallel iteration (`rayon` over `&[Arc]`). + pub fn as_arcs(&self) -> &[Arc] { + &self.0 + } + pub fn to_vec(&self) -> Vec { + self.iter().cloned().collect() + } +} + +impl Index for Layers { + type Output = Layer; + fn index(&self, i: usize) -> &Layer { + &self.0[i] + } +} + +impl IndexMut for Layers { + fn index_mut(&mut self, i: usize) -> &mut Layer { + Arc::make_mut(&mut self.0[i]) + } +} + +impl From> for Layers { + fn from(v: Vec) -> Self { + Self(v.into_iter().map(Arc::new).collect()) + } +} + +impl FromIterator for Layers { + fn from_iter>(it: I) -> Self { + Self(it.into_iter().map(Arc::new).collect()) + } +} + +impl Extend for Layers { + fn extend>(&mut self, it: I) { + self.0.extend(it.into_iter().map(Arc::new)); + } +} + +impl IntoIterator for Layers { + type Item = Layer; + type IntoIter = std::iter::Map>, fn(Arc) -> Layer>; + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter().map(Arc::unwrap_or_clone as fn(Arc) -> Layer) + } +} + +impl<'a> IntoIterator for &'a Layers { + type Item = &'a Layer; + type IntoIter = std::iter::Map>, fn(&'a Arc) -> &'a Layer>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter().map(|l| &**l) + } +} + +impl<'a> IntoIterator for &'a mut Layers { + type Item = &'a mut Layer; + type IntoIter = std::iter::Map>, fn(&'a mut Arc) -> &'a mut Layer>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter_mut().map(Arc::make_mut) + } +} + +impl Serialize for Layers { + fn serialize(&self, s: S) -> Result { + s.collect_seq(self.iter()) + } +} + +impl<'de> Deserialize<'de> for Layers { + fn deserialize>(d: D) -> Result { + Vec::::deserialize(d).map(Layers::from) + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 8e1409c37..2ad56d366 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1,8 +1,8 @@ //! The EffectCraft document model. //! //! `Project` → items (folders, compositions, footage, solids) → `Comp` → `Layer` → property tree -//! ([`props`]). Compositions sit behind `Arc` so undo snapshots share everything that did not -//! change. +//! ([`props`]). Compositions, and each layer within them ([`Layers`]), sit behind `Arc` so undo +//! snapshots share everything that did not change. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -33,11 +33,13 @@ mod comp; mod footage; mod item; mod layer; +mod layers; mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; pub use footage::{AlphaMode, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer}; pub use item::{Item, ItemId, ItemKind, LayerId}; +pub use layers::Layers; pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte}; pub use settings::{ APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView, diff --git a/crates/project/src/styles.rs b/crates/project/src/styles.rs index 83db5e581..2b2bdf92c 100644 --- a/crates/project/src/styles.rs +++ b/crates/project/src/styles.rs @@ -470,7 +470,7 @@ mod tests { let mut ids = Ids(&mut p.next_id); add_style(&mut a, &mut ids, "dropShadow", &gl, true); add_style(&mut b, &mut ids, "bevelEmboss", &gl, true); - comp.layers = vec![a, b]; + comp.layers = vec![a, b].into(); comp.layers[1].props.sub_mut(GROUP).unwrap().sub_mut(BLENDING).unwrap().get_mut("globalLightAngle").unwrap().value = Value::Scalar(45.0); assert!(needs_sync(&comp)); sync_comp(&mut comp); diff --git a/crates/psd/src/memory_tests.rs b/crates/psd/src/memory_tests.rs index 108046b66..b864aa42b 100644 --- a/crates/psd/src/memory_tests.rs +++ b/crates/psd/src/memory_tests.rs @@ -255,7 +255,7 @@ fn doc(depth: u16, mode: ColorMode, rle: bool) -> WDoc { let mut masked = WLayer::pixels("masked", Rect::new(10, 8, 12, 12), grad(12, 12)); masked.mask = Some(WMask { rect: Rect::new(12, 10, 12, 12), data: (0..144).map(|i| (i % 12) as f32 / 11.0).collect(), default_color: 255, disabled: false }); - d.layers = vec![bg, off, solid, masked]; + d.layers = vec![bg, off, solid, masked].into(); d } diff --git a/crates/psd/src/parse.rs b/crates/psd/src/parse.rs index d221b9044..6a83bb30b 100644 --- a/crates/psd/src/parse.rs +++ b/crates/psd/src/parse.rs @@ -184,7 +184,7 @@ impl Psd { r.skip(c.len)?; } } - self.layers = layers; + self.layers = layers.into(); Ok(()) } diff --git a/crates/psd/src/tests.rs b/crates/psd/src/tests.rs index 074976510..054aca2ed 100644 --- a/crates/psd/src/tests.rs +++ b/crates/psd/src/tests.rs @@ -25,7 +25,7 @@ fn doc_with_layers(depth: u16, mode: ColorMode, rle: bool) -> WDoc { // Mask: left half opaque, right half transparent; default (outside) black. let mrect = Rect::new(20, 10, 12, 12); masked.mask = Some(WMask { rect: mrect, data: (0..144).map(|i| if i % 12 < 6 { 1.0 } else { 0.0 }).collect(), default_color: 0, disabled: false }); - d.layers = vec![bg, red, masked]; + d.layers = vec![bg, red, masked].into(); d } diff --git a/crates/psd/src/tests/opt.rs b/crates/psd/src/tests/opt.rs index 908026d07..c0c07eeb6 100644 --- a/crates/psd/src/tests/opt.rs +++ b/crates/psd/src/tests/opt.rs @@ -407,7 +407,7 @@ fn base() -> Psd { let mut masked = WLayer::solid("Masked", Rect::new(20, 10, 12, 12), [0.0, 0.5, 1.0, 1.0]); let data = (0..144).map(|i| if i % 12 < 6 { 1.0 } else { 0.25 }).collect(); masked.mask = Some(WMask { rect: Rect::new(20, 10, 12, 12), data, default_color: 0, disabled: false }); - d.layers = vec![bg, red, masked]; + d.layers = vec![bg, red, masked].into(); Psd::parse(write(&d)).unwrap() } diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 66b54b6d0..839a71ca7 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -347,7 +347,7 @@ mod tests { pr.value = effectcraft_project::Value::Vec2([-6.0206, -96.0]); } let mut comp = comp; - comp.layers = vec![l1, l2]; + comp.layers = vec![l1, l2].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); assert!(comp_has_audio(&p, cid)); let rate = 1000; @@ -393,11 +393,11 @@ mod tests { let base = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); let tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None); let mut inner = base.clone(); - inner.layers = vec![tone]; + inner.layers = vec![tone].into(); let inner_id = p.add_item("Inner", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(inner.into())); let pre = build::layer(&mut p, &base, "pre", LayerSource::Comp { item: inner_id }, (64, 64), None); let mut outer = base.clone(); - outer.layers = vec![pre]; + outer.layers = vec![pre].into(); let outer_id = p.add_item("Outer", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(outer.into())); let rate = 8000; let t = Tick::from_seconds_f64(0.5); @@ -424,7 +424,7 @@ mod tests { let mut tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None); tone.stretch = stretch; let mut comp = base.clone(); - comp.layers = vec![tone]; + comp.layers = vec![tone].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); let n = 2000usize; let t = Tick::from_seconds_f64(0.5); diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 4fa2e1cb3..9c9e61f59 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -1113,7 +1113,7 @@ mod tests { let f = Footage { path: "/virtual.mov".into(), width: 16, height: 9, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() }; let fid = p.add_item("virtual.mov", effectcraft_color::Label::None, None, ItemKind::Footage(f)); let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); - comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)]; + comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)].into(); let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); let id = |mark: u64| move |path: &str| (path == "/virtual.mov").then_some((8, mark)); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 079274499..3caf20c30 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -313,7 +313,7 @@ fn particle_world_scene(pan: f64) -> (Project, ItemId) { let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); } - p.comp_mut(cid).unwrap().layers = vec![cam, l]; + p.comp_mut(cid).unwrap().layers = vec![cam, l].into(); (p, cid) } @@ -411,7 +411,7 @@ fn cache_scene() -> (Project, ItemId) { vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 70.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([120.0, 70.0, 0.0]))]; add_effect_200(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]); let c = p.comp_mut(cid).unwrap(); - c.layers = vec![text, shape, bg]; + c.layers = vec![text, shape, bg].into(); (p, cid) } @@ -651,7 +651,7 @@ fn edge_pinning_displaces_all_of_a_centred_shape_layer() { p.comp_mut(cid).unwrap().layers.push(l.clone()); let plain = render_frame(&p, cid, Tick::ZERO, 1.0); add_effect_200(&mut p, &mut l, id, &vals); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let warped = render_frame(&p, cid, Tick::ZERO, 1.0); // Alpha change per comp quadrant (TL, TR, BL, BR): the rectangle's edges move in each. let mut q = [0.0f32; 4]; @@ -685,7 +685,7 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() { p.comp_mut(cid).unwrap().layers.push(l.clone()); let plain = render_frame(&p, cid, Tick::ZERO, 1.0); add_effect_200(&mut p, &mut l, id, &vals); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let warped = render_frame(&p, cid, Tick::ZERO, 1.0); // Alpha change per comp quadrant (TL, TR, BL, BR): the top and bottom edges move in each. let mut q = [0.0f32; 4]; @@ -714,7 +714,7 @@ fn effects_on_text_reach_past_the_text_bounds() { let plain = render_frame(&p, cid, Tick::ZERO, 1.0); let painted = plain.data.iter().filter(|px| px[3] > 0.5).count(); add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let tiled = render_frame(&p, cid, Tick::ZERO, 1.0); let (mut lo, mut hi) = (u32::MAX, 0u32); for y in 0..100 { @@ -748,7 +748,7 @@ fn cc_smear_trails_text_past_its_bounds() { "ec.distort.ccsmear", &[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))], ); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0)); assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}"); } @@ -771,7 +771,7 @@ fn layer_parameters_see_a_whole_shape_layer() { let matte_id = shape.id; let mut s = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); add_effect_200(&mut p, &mut s, "ec.channel.setmatte", &[("takeMatteFromLayer", Value::Layer(Some(matte_id.0)))]); - p.comp_mut(cid).unwrap().layers = vec![s, shape]; + p.comp_mut(cid).unwrap().layers = vec![s, shape].into(); let f = render_frame(&p, cid, Tick::ZERO, 1.0); // The matte covers x 50..150, y 25..75: kept inside (all four quadrants), gone outside. for (x, y) in [(60, 30), (140, 30), (60, 70), (140, 70)] { @@ -861,7 +861,7 @@ fn self_inputs_share_source_semantics_across_effects_and_times() { "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(id))), ("blendWithLayerSource", Value::Enum(0)), ("blendWithOriginal", Value::Scalar(0.0))], ); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let c = p.comp(cid).unwrap(); let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO); assert!(crate::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 1).is_some()); @@ -879,7 +879,7 @@ fn timed_source_reads_preserve_other_layer_masks() { let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]); p.next_id = next; other.props.sub_mut("masks").unwrap().children.push(mask.into()); - p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()]; + p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()].into(); let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO); let renderer = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default()); let host = crate::FxHost { r: &renderer, ctx: &ctx, layer: &owner, origin: [0.0; 2], index: Default::default(), original: None }; @@ -900,7 +900,7 @@ fn layer_dependencies_remain_cacheable_and_invalidate_only_related_edits() { let unrelated = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100); add_effect_200(&mut p, &mut source, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(third.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(source.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); - p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated]; + p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated].into(); let key = |p: &Project, time: f64, limit| { let comp = p.comp(cid).unwrap(); let ctx = crate::EvalCtx::new(p, cid, comp, Tick::from_seconds_f64(time)); @@ -941,12 +941,12 @@ fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() { child.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(50.0))]; let mut nested = comp.clone(); - nested.layers = vec![child]; + nested.layers = vec![child].into(); let nested_id = p.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(nested.into())); let pre = build::layer(&mut p, &comp, "Precomp", LayerSource::Comp { item: nested_id }, (200, 100), None); let mut owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(pre.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); - p.comp_mut(cid).unwrap().layers = vec![owner, pre]; + p.comp_mut(cid).unwrap().layers = vec![owner, pre].into(); let key = |p: &Project, time| { let c = p.comp(cid).unwrap(); crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, time), &c.layers[0], 1.0, false, false).unwrap() @@ -1021,7 +1021,7 @@ fn scene_reader_dependency(pan: f64) -> (Project, ItemId) { let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); } - p.comp_mut(cid).unwrap().layers = vec![cam, owner, source]; + p.comp_mut(cid).unwrap().layers = vec![cam, owner, source].into(); (p, cid) } diff --git a/crates/render/src/tests/audio_fx.rs b/crates/render/src/tests/audio_fx.rs index aced8208e..0f74b1f7c 100644 --- a/crates/render/src/tests/audio_fx.rs +++ b/crates/render/src/tests/audio_fx.rs @@ -78,7 +78,7 @@ fn comp_with(id: Option<&str>, vals: &[(&str, Value)], solid: bool) -> (Project, l.props.sub_mut("effects").unwrap().children.push(g.into()); } let mut comp = comp; - comp.layers = vec![l]; + comp.layers = vec![l].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); (p, cid) } @@ -236,7 +236,7 @@ fn audio_spectrum_follows_time_through_the_layer_cache() { g.prop_mut("audioLayer").unwrap().value = Value::Layer(Some(a.id.0)); s.props.sub_mut("effects").unwrap().children.push(g.into()); let mut comp = comp; - comp.layers = vec![s, a]; + comp.layers = vec![s, a].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); let src = Signal(late_tone); diff --git a/crates/render/src/tests/collapse.rs b/crates/render/src/tests/collapse.rs index 797960033..6f2ad8441 100644 --- a/crates/render/src/tests/collapse.rs +++ b/crates/render/src/tests/collapse.rs @@ -53,17 +53,17 @@ fn scene(collapse: bool, flatten: bool) -> (Project, ItemId) { a.parent = Some(null.id); b.parent = Some(null.id); let mut c = outer.clone(); - c.layers = vec![null, b, a, bg]; + c.layers = vec![null, b, a, bg].into(); p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())) } else { let mut inner = inner; - inner.layers = vec![b, a]; + inner.layers = vec![b, a].into(); let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); xf(&mut pre); pre.switches.collapse = collapse; let mut c = outer.clone(); - c.layers = vec![pre, bg]; + c.layers = vec![pre, bg].into(); p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())) }; (p, cid) @@ -122,7 +122,7 @@ fn collapsed_3d_layers_join_the_parent_scene() { let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (60, 60), [100.0, 100.0, -200.0]); blue.switches.three_d = true; let mut inner = inner; - inner.layers = vec![blue]; + inner.layers = vec![blue].into(); let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (200, 200), None); pre.switches.three_d = true; @@ -130,7 +130,7 @@ fn collapsed_3d_layers_join_the_parent_scene() { let mut red = solid(&mut p, &outer, [1.0, 0.0, 0.0], (100, 100), [100.0, 100.0, 0.0]); red.switches.three_d = true; let mut c = outer; - c.layers = vec![red, pre]; + c.layers = vec![red, pre].into(); let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 100) }; @@ -168,11 +168,11 @@ fn scaled_square(cr: bool, via_parent: bool) -> Image { set(&mut null, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); set(&mut l, "transform/position", Value::Vec3([0.0, 0.0, 0.0])); l.parent = Some(null.id); - c.layers = vec![l, null]; + c.layers = vec![l, null].into(); } else { set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0])); set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); - c.layers = vec![l]; + c.layers = vec![l].into(); } let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0) @@ -201,7 +201,7 @@ fn wireframe_quality_draws_the_layer_bounds() { let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], (40, 40), [50.0, 50.0, 0.0]); l.switches.quality = Quality::Wireframe; let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); let img = render_frame(&p, cid, Tick::ZERO, 1.0); assert_eq!(img.get(50, 50)[3], 0.0, "no fill"); @@ -212,7 +212,7 @@ fn wireframe_quality_draws_the_layer_bounds() { let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [50.5, 50.0, 0.0]); l.switches.quality = Quality::Draft; let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); let img = render_frame(&p, cid, Tick::ZERO, 1.0); assert!(img.data.iter().all(|q| q[3] == 0.0 || q[3] == 1.0)); @@ -230,13 +230,13 @@ fn collapsed_precomp_of_another_size_renders_through_the_parent_camera() { let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (40, 40), [500.0, 300.0, 0.0]); blue.switches.three_d = true; let mut inner = inner; - inner.layers = vec![blue]; + inner.layers = vec![blue].into(); let iid = p.add_item("Scene", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Scene", LayerSource::Comp { item: iid }, (1000, 600), None); pre.switches.three_d = three_d_precomp; pre.switches.collapse = true; let mut c = outer.clone(); - c.layers = vec![pre]; + c.layers = vec![pre].into(); if camera { let cam = build::layer(&mut p, &outer, "Camera", LayerSource::Camera, (300, 200), None); c.layers.insert(0, cam); @@ -267,7 +267,7 @@ fn text_scaled_up_stays_sharp_without_the_switch() { set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); assert!(!l.switches.collapse); let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0) }; @@ -291,7 +291,7 @@ fn unscaled_text_rasterises_at_the_output_scale() { let mut comp = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (240, 160), None); l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HE".into(), size: 30.0, ..Default::default() })); - comp.layers = vec![l]; + comp.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); for i in 0..500 { let pos = [3.1 + i as f64 * 0.37, 7.3 + i as f64 * 0.29, 0.0]; @@ -327,7 +327,7 @@ fn effects_on_a_scaled_shape_follow_its_scale() { set(&mut l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0) }; diff --git a/crates/render/src/tests/guides.rs b/crates/render/src/tests/guides.rs index cb9851250..44f3fc7b0 100644 --- a/crates/render/src/tests/guides.rs +++ b/crates/render/src/tests/guides.rs @@ -40,7 +40,7 @@ fn scene(nest: Nest) -> (Project, ItemId, ItemId) { guide.switches.guide = true; let bg = solid(&mut p, &inner, [1.0, 0.0, 0.0], (100, 100), three_d); let mut inner = inner; - inner.layers = vec![guide, bg]; + inner.layers = vec![guide, bg].into(); let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); if matches!(nest, Nest::CollapsedAdvanced) { @@ -49,7 +49,7 @@ fn scene(nest: Nest) -> (Project, ItemId, ItemId) { let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); pre.switches.collapse = !matches!(nest, Nest::Precomp); pre.switches.three_d = three_d; - outer.layers = vec![pre]; + outer.layers = vec![pre].into(); let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); (p, iid, oid) } diff --git a/crates/render/src/tests/motion_tile.rs b/crates/render/src/tests/motion_tile.rs index 7d1645a02..28faca73c 100644 --- a/crates/render/src/tests/motion_tile.rs +++ b/crates/render/src/tests/motion_tile.rs @@ -58,7 +58,7 @@ fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) { tile_then_scale(&mut p, &mut adj, out, scale); let mut layers = vec![adj]; layers.extend(picture(&mut p, &comp)); - p.comp_mut(cid).unwrap().layers = layers; + p.comp_mut(cid).unwrap().layers = layers.into(); (p, cid) } @@ -68,7 +68,7 @@ fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) { let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); let iid = p.add_item("Picture", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); let layers = picture(&mut p, &inner); - p.comp_mut(iid).unwrap().layers = layers; + p.comp_mut(iid).unwrap().layers = layers.into(); let mut pre = build::layer(&mut p, &comp, "Picture", LayerSource::Comp { item: iid }, (W, H), None); tile_then_scale(&mut p, &mut pre, out, scale); p.comp_mut(cid).unwrap().layers.push(pre); @@ -151,7 +151,7 @@ fn adjustment_motion_tile_matches_the_after_effects_frames() { } adj.props.sub_mut("effects").unwrap().children.push(g.into()); } - p.comp_mut(cid).unwrap().layers = vec![adj, red]; + p.comp_mut(cid).unwrap().layers = vec![adj, red].into(); // Quarter resolution: comp pixel (x, y) is output pixel (x / 4, y / 4). let img = render_frame(&p, cid, Tick::ZERO, 0.25); let at = |x: f64, y: f64| img.get((x / 4.0) as i64, (y / 4.0) as i64); diff --git a/crates/render/src/tests/roi.rs b/crates/render/src/tests/roi.rs index 9182817cf..a9892d9c7 100644 --- a/crates/render/src/tests/roi.rs +++ b/crates/render/src/tests/roi.rs @@ -29,7 +29,7 @@ fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) { } layers.push(l); } - p.comp_mut(cid).unwrap().layers = layers; + p.comp_mut(cid).unwrap().layers = layers.into(); (p, cid) } diff --git a/crates/render/src/tests/styles.rs b/crates/render/src/tests/styles.rs index b59f009de..2450fcf64 100644 --- a/crates/render/src/tests/styles.rs +++ b/crates/render/src/tests/styles.rs @@ -44,7 +44,7 @@ fn scene(color: [f32; 3], bg: Option<[f32; 3]>, f: impl FnOnce(&mut Project, &Co if let Some(bg) = bg { layers.push(solid(&mut p, &comp, bg, 200, 100)); } - p.comp_mut(cid).unwrap().layers = layers; + p.comp_mut(cid).unwrap().layers = layers.into(); (p, cid) } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 8e932819a..ab7919079 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -469,6 +469,7 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, extra = ctx .comp .layers + .as_arcs() .par_iter() .filter(|l| l.switches.three_d && l.has_video() && l.is_active_at(ctx.time) && !in_run.contains(&l.id) && !l.switches.adjustment) .filter(|l| Material::of(ctx, l).casts_shadows != 0) diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 7972e6942..fbfe4b8af 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -532,7 +532,7 @@ mod tests { let item = p.add_item("long.flac", Label::SeaFoam, None, ItemKind::Footage(footage)); let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0)); let layer = build::layer(&mut p, &comp, "long.flac", LayerSource::Footage { item }, (0, 0), None); - comp.layers = vec![layer]; + comp.layers = vec![layer].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); let src = RenderSource { project: Arc::new(p), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a45d8e708..bae450455 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1485,8 +1485,8 @@ fn shift_range(ctx: &egui::Context, cid: ItemId, comp: &Comp, rows: &[Row], sele let anchor = ctx.data(|d| d.get_temp::(anchor_id(cid))).or_else(|| selected.first().copied())?; let a = comp.layers.iter().position(|l| l.id == anchor)?; let b = comp.layers.iter().position(|l| l.id == layer)?; - let span = comp.layers.get(a.min(b)..=a.max(b))?; - Some(span.iter().filter(|l| !l.switches.locked && rows.iter().any(|r| r.layer == l.id)).map(|l| l.id.0).collect()) + let span = comp.layers.iter().skip(a.min(b)).take(a.max(b) - a.min(b) + 1); + Some(span.filter(|l| !l.switches.locked && rows.iter().any(|r| r.layer == l.id)).map(|l| l.id.0).collect()) } /// Set on the frame the reorder drag is released. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 58383fe43..ac3368b2e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -602,7 +602,7 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String] pub fn layer_picker( ui: &mut Ui, rect: Rect, - layers: &[effectcraft_engine::project::Layer], + layers: &effectcraft_engine::project::Layers, current: Option, t: &Tokens, id: egui::Id, diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index e405bf777..8d17e8a6e 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -198,7 +198,7 @@ fn dragging_selected_project_items_adds_every_item_to_the_viewer() { let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); drag(&mut h, from, to, Modifiers::NONE); assert_eq!(stack(&h), ["Clip", "Other", "Top", "Middle", "Bottom"]); - for layer in &h.state().session.active_comp().unwrap().layers[..2] { + for layer in h.state().session.active_comp().unwrap().layers.iter().take(2) { let Some(KV::Vec3(p)) = layer.props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") }; let tol = 1.0 / last_fit(&h.ctx) as f64 + 1e-6; assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); diff --git a/crates/ui-egui/tests/it/ui_new_primitive.rs b/crates/ui-egui/tests/it/ui_new_primitive.rs index fbb3a34e5..c40aa23ed 100644 --- a/crates/ui-egui/tests/it/ui_new_primitive.rs +++ b/crates/ui-egui/tests/it/ui_new_primitive.rs @@ -70,7 +70,7 @@ fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: PrimitiveKind) { } fn layers(h: &Harness<'_, EffectcraftApp>) -> Vec { - h.state().session.active_comp().unwrap().layers.clone() + h.state().session.active_comp().unwrap().layers.to_vec() } fn renderer(h: &Harness<'_, EffectcraftApp>) -> Renderer { From 2c3f3bb791abd4cf45cabcd5d17ab8e000ee877b Mon Sep 17 00:00:00 2001 From: Tomasz Lewandowski Date: Thu, 29 Oct 2026 07:04:05 +0100 Subject: [PATCH 060/112] i18n: add Polish (pl) interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish menus (every fixed menu entry, 630 rows) and panels (every row of the Chinese panel catalogs, 695), the undo/redo, Reveal and Assign Shortcut labels, the macOS Services item, Settings ▸ General ▸ Language and Match System for pl-* locales. Tests cover the locale mapping, the settings registry and the macOS application menu. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DukHNvUY45rjRnvtz91x33 --- ROADMAP.md | 2 +- crates/engine/src/settings/prefs/choices.rs | 1 + crates/engine/src/tests/settings.rs | 8 + crates/ui-egui/src/i18n.rs | 672 ++++++++++++++++- crates/ui-egui/src/i18n/ui.rs | 752 +++++++++++++++++++- crates/ui-egui/src/native_menu.rs | 23 + docs/localization-parity.md | 6 +- 7 files changed, 1459 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f4c99d61f..24064aee0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had +Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Polish (partial, 34%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had native-speaker review. ## Upcoming diff --git a/crates/engine/src/settings/prefs/choices.rs b/crates/engine/src/settings/prefs/choices.rs index 4145e0161..8935507a6 100644 --- a/crates/engine/src/settings/prefs/choices.rs +++ b/crates/engine/src/settings/prefs/choices.rs @@ -8,6 +8,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("Deutsch", "de"), ("Español", "es"), ("Français", "fr"), + ("Polski", "pl"), ("Português (Brasil)", "pt-br"), ("Русский", "ru"), ("Українська", "uk"), diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 81bed3c43..2e14ab972 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -1072,6 +1072,14 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { cs.load_settings(); assert_eq!(cs.prefs.general.language, "cs"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs"); + // Polish, likewise. + assert!(crate::prefs::LANGUAGES.contains(&("Polski", "pl"))); + s.execute("prefs.set", json!({"key": "general.language", "value": "pl"})).unwrap(); + assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pl")); + let mut pl = Session { config: Some(store.clone()), ..Default::default() }; + pl.load_settings(); + assert_eq!(pl.prefs.general.language, "pl"); + assert_eq!(Prefs::from_json(r#"{"general":{"language":"pl"}}"#).general.language, "pl"); // Georgian too. assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka"))); s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 611a226ee..70703cb2a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -15,7 +15,7 @@ pub(crate) use ui::catalog_characters; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. -const CATALOG_CODES: [&str; 11] = ["cs", "de", "es", "fr", "pt-br", "ru", "uk", "ka", "ja", "zh-hans", "zh-hant"]; +const CATALOG_CODES: [&str; 12] = ["cs", "de", "es", "fr", "pl", "pt-br", "ru", "uk", "ka", "ja", "zh-hans", "zh-hant"]; /// The active interface language: the stored preference when it names a catalog, otherwise `en` /// (English is the source of every label and needs no lookup). @@ -66,6 +66,11 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { language(app) == "cs" } +/// The UI is in Polish. +pub(crate) fn polish(app: &EffectcraftApp) -> bool { + language(app) == "pl" +} + /// The UI is in Georgian. pub(crate) fn georgian(app: &EffectcraftApp) -> bool { language(app) == "ka" @@ -134,6 +139,7 @@ fn supported(locale: Option<&str>) -> &'static str { "uk" => "uk", "es" => "es", "cs" => "cs", + "pl" => "pl", "ka" => "ka", // The only Portuguese catalog: Brazil's, also for Portugal rather than English. "pt" => "pt-br", @@ -170,6 +176,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } + if language == "pl" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { + match source { + "Reveal in Explorer" => return "Pokaż w Eksploratorze", + "Reveal in File Manager" => return "Pokaż w menedżerze plików", + _ => {} + } + } if language == "ka" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { match source { "Reveal in Explorer" => return "Explorer-ში ჩვენება", @@ -193,6 +206,7 @@ fn menu_catalog(language: &str) -> Option<&'static [(&'static str, &'static str, "fr" => FRENCH, "de" => GERMAN, "cs" => CZECH, + "pl" => POLISH, "ka" => GEORGIAN, _ => return None, }) @@ -261,6 +275,7 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin "es" => ("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer"), "pt-br" => ("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer"), "cs" => ("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat"), + "pl" => ("Cofnij", "Nie można cofnąć", "Ponów", "Nie można ponowić"), // A colon before the history's (untranslated) action name: "დაბრუნება: Comp1". "ka" => ("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია"), _ => return shown, @@ -293,8 +308,23 @@ pub(crate) fn effect_category(children: &[MenuNode], source: &str) -> Option Option { + let named = shown.strip_prefix("Assign Shortcut to “")?; + match source { + "Assign Shortcut to Workspace" => named.strip_suffix("” Workspace").map(|name| format!("Przypisz skrót do obszaru roboczego „{name}”")), + "Assign Shortcut to 3D View" => named.strip_suffix('”').map(|name| format!("Przypisz skrót do widoku 3D „{name}”")), + _ => None, + } +} + pub(crate) fn submenu(app: &EffectcraftApp, source: &str, shown: String) -> String { match language(app) { + "pl" => { + if let Some(label) = polish_assign_shortcut(source, &shown) { + return label; + } + } "cs" => { if source == "Assign Shortcut to Workspace" { if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { @@ -6111,6 +6141,640 @@ const GERMAN: &[(&str, &str, &str)] = &[ // <<< generated i18n catalogs +/// Polish (pl): original phrase translations of every fixed menu entry. +const POLISH: &[(&str, &str, &str)] = &[ + ("", "Front", "Przód"), + ("", "Bevel", "Fazowanie"), + ("", "Side", "Bok"), + ("", "Back", "Tył"), + ("layer.addTextAnimator", "Color", "Kolor"), + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "O programie EffectCraft..."), + ("", "Settings...", "Ustawienia..."), + ("app.settings", "General...", "Ogólne..."), + ("app.settings", "Startup & Repair...", "Uruchamianie i naprawa..."), + ("app.settings", "Project...", "Projekt..."), + ("app.settings", "Composition...", "Kompozycja..."), + ("app.settings", "Previews...", "Podglądy..."), + ("app.settings", "Appearance...", "Wygląd..."), + ("app.settings", "Grids & Guides...", "Siatki i linie pomocnicze..."), + ("app.settings", "Labels...", "Etykiety..."), + ("app.settings", "Type...", "Tekst..."), + ("app.settings", "Import...", "Import..."), + ("app.settings", "Export...", "Eksport..."), + ("app.settings", "Audio...", "Audio..."), + ("app.settings", "Disk...", "Dysk..."), + ("app.settings", "Memory & CPU...", "Pamięć i procesor..."), + ("app.settings", "Video...", "Wideo..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Skrypty i wyrażenia..."), + ("app.hide", "Hide EffectCraft", "Ukryj EffectCraft"), + ("app.hideOthers", "Hide Others", "Ukryj pozostałe"), + ("app.showAll", "Show All", "Pokaż wszystko"), + ("app.quit", "Quit EffectCraft", "Zakończ EffectCraft"), + ("", "File", "Plik"), + ("", "New", "Nowy"), + ("file.newProject", "New Project", "Nowy projekt"), + ("file.newFromTemplate", "New Project from Template...", "Nowy projekt z szablonu..."), + ("project.newFolder", "New Folder", "Nowy folder"), + ("file.open", "Open Project...", "Otwórz projekt..."), + ("", "Open Recent", "Otwórz ostatnie"), + ("file.clearRecent", "Clear Recent Projects", "Wyczyść ostatnie projekty"), + ("file.close", "Close", "Zamknij"), + ("file.closeProject", "Close Project", "Zamknij projekt"), + ("file.save", "Save", "Zapisz"), + ("", "Save As", "Zapisz jako"), + ("file.saveAs", "Save As...", "Zapisz jako..."), + ("file.saveCopy", "Save a Copy...", "Zapisz kopię..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Zapisz kopię jako XML..."), + ("templates.saveAs", "Save as Template...", "Zapisz jako szablon..."), + ("file.incrementAndSave", "Increment and Save", "Zapisz z kolejnym numerem"), + ("file.revert", "Revert", "Przywróć"), + ("", "Import", "Importuj"), + ("file.import", "File...", "Plik..."), + ("file.importMultiple", "Multiple Files...", "Wiele plików..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.importProject", "EffectCraft Project...", "Projekt EffectCraft..."), + ("file.importPlaceholder", "Placeholder...", "Element zastępczy..."), + ("file.importSolid", "Solid...", "Plansza..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Punkt zbiegu (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Szablon Essential Graphics..."), + ("", "Import Recent Footage", "Importuj ostatnie materiały"), + ("file.clearRecentFootage", "Clear Recent Footage", "Wyczyść ostatnie materiały"), + ("", "Export", "Eksportuj"), + ("renderQueue.add", "Add to Render Queue", "Dodaj do kolejki renderowania"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Szablon Essential Graphics..."), + ("app.find", "Find", "Znajdź"), + ("layer.addItem", "Add Footage to Comp", "Dodaj materiał do kompozycji"), + ("file.newCompFromSelection", "New Comp from Selection...", "Nowa kompozycja z zaznaczenia..."), + ("", "Dependencies", "Zależności"), + ("file.collectFiles", "Collect Files...", "Zbierz pliki..."), + ("file.consolidateFootage", "Consolidate All Footage", "Scal wszystkie materiały"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Usuń nieużywane materiały"), + ("file.reduceProject", "Reduce Project", "Zredukuj projekt"), + ("file.findMissing", "Find Missing Effects", "Znajdź brakujące efekty"), + ("file.findMissing", "Find Missing Fonts", "Znajdź brakujące czcionki"), + ("file.findMissing", "Find Missing Footage", "Znajdź brakujące materiały"), + ("file.relinkFootage", "Relink Missing Footage...", "Połącz ponownie brakujące materiały..."), + ("file.watchFolder", "Watch Folder...", "Folder obserwowany..."), + ("", "Scripts", "Skrypty"), + ("file.installScript", "Install Script File...", "Zainstaluj plik skryptu..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Zainstaluj panel ScriptUI..."), + ("file.runScript", "Run Script File...", "Uruchom plik skryptu..."), + ("", "Create Proxy", "Utwórz proxy"), + ("file.createProxy", "Still...", "Klatka..."), + ("file.createProxy", "Movie...", "Film..."), + ("", "Set Proxy", "Ustaw proxy"), + ("file.setProxy", "File...", "Plik..."), + ("file.setProxyNone", "None", "Brak"), + ("", "Interpret Footage", "Interpretuj materiał"), + ("file.interpretFootage", "Main...", "Główny..."), + ("file.interpretProxy", "Proxy...", "Proxy..."), + ("file.rememberInterpretation", "Remember Interpretation", "Zapamiętaj interpretację"), + ("file.applyInterpretation", "Apply Interpretation", "Zastosuj interpretację"), + ("", "Replace Footage", "Zastąp materiał"), + ("file.replaceFootage", "File...", "Plik..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Element zastępczy..."), + ("file.replaceWithSolid", "Solid...", "Plansza..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Z warstwową kompozycją"), + ("file.reloadFootage", "Reload Footage", "Wczytaj ponownie materiał"), + ("file.revealInFinder", "Reveal in Finder", "Pokaż w Finderze"), + ("file.projectSettings", "Project Settings...", "Ustawienia projektu..."), + ("app.quit", "Exit", "Zakończ"), + ("", "Edit", "Edycja"), + ("edit.undo", "Undo", "Cofnij"), + ("edit.redo", "Redo", "Ponów"), + ("", "History", "Historia"), + ("app.commandPalette", "Quick Apply...", "Szybkie stosowanie..."), + ("edit.cut", "Cut", "Wytnij"), + ("edit.copy", "Copy", "Kopiuj"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Kopiuj z łączami właściwości"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Kopiuj ze względnymi łączami właściwości"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Kopiuj tylko wyrażenie"), + ("edit.paste", "Paste", "Wklej"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Wklej odwrócone klatki kluczowe"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Wklej tekst i dopasuj formatowanie"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Wklej tylko formatowanie tekstu"), + ("edit.clear", "Clear", "Wyczyść"), + ("edit.duplicate", "Duplicate", "Powiel"), + ("edit.splitLayer", "Split Layer", "Podziel warstwę"), + ("edit.liftWorkArea", "Lift Work Area", "Wytnij obszar roboczy"), + ("edit.extractWorkArea", "Extract Work Area", "Wyodrębnij obszar roboczy"), + ("edit.selectAll", "Select All", "Zaznacz wszystko"), + ("edit.deselectAll", "Deselect All", "Usuń zaznaczenie"), + ("", "Label", "Etykieta"), + ("edit.selectLabelGroup", "Select Label Group", "Zaznacz grupę etykiet"), + ("edit.label", "None", "Brak"), + ("edit.label", "Red", "Czerwony"), + ("edit.label", "Yellow", "Żółty"), + ("edit.label", "Aqua", "Akwamaryna"), + ("edit.label", "Pink", "Różowy"), + ("edit.label", "Lavender", "Lawendowy"), + ("edit.label", "Peach", "Brzoskwiniowy"), + ("edit.label", "Sea Foam", "Morska piana"), + ("edit.label", "Blue", "Niebieski"), + ("edit.label", "Green", "Zielony"), + ("edit.label", "Purple", "Fioletowy"), + ("edit.label", "Orange", "Pomarańczowy"), + ("edit.label", "Brown", "Brązowy"), + ("edit.label", "Fuchsia", "Fuksja"), + ("edit.label", "Cyan", "Cyjan"), + ("edit.label", "Sandstone", "Piaskowiec"), + ("edit.label", "Dark Green", "Ciemnozielony"), + ("app.settings", "Edit Label Colors...", "Edytuj kolory etykiet..."), + ("", "Select Keyframe Label Group", "Zaznacz grupę etykiet klatek kluczowych"), + ("keys.selectLabelGroup", "On Selected Layers", "Na zaznaczonych warstwach"), + ("keys.selectLabelGroup", "On All Layers", "Na wszystkich warstwach"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Widoczne klatki kluczowe na zaznaczonych warstwach"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Widoczne klatki kluczowe na wszystkich warstwach"), + ("", "Purge", "Wyczyść pamięć"), + ("edit.purge", "All Cache...", "Całą pamięć podręczną..."), + ("edit.purge", "All Memory & Disk Cache...", "Całą pamięć i pamięć dyskową..."), + ("edit.purge", "All Memory", "Całą pamięć"), + ("edit.purge", "All Disk Cache...", "Całą pamięć dyskową..."), + ("edit.purge", "All 3D Cache...", "Całą pamięć 3D..."), + ("edit.purgeUndo", "Undo", "Cofanie"), + ("edit.purge", "Image Cache Memory", "Pamięć podręczna obrazów"), + ("edit.purge", "Snapshot", "Migawka"), + ("edit.editOriginal", "Edit Original...", "Edytuj oryginał..."), + ("", "Templates", "Szablony"), + ("app.templates", "Render Settings...", "Ustawienia renderowania..."), + ("app.templates", "Output Module...", "Moduł wyjściowy..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Skróty klawiaturowe"), + ("", "Preferences", "Preferencje"), + ("", "Composition", "Kompozycja"), + ("comp.new", "New Composition...", "Nowa kompozycja..."), + ("comp.settings", "Composition Settings...", "Ustawienia kompozycji..."), + ("comp.setPosterTime", "Set Poster Time", "Ustaw czas plakatu"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Przytnij kompozycję do obszaru roboczego"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Kadruj kompozycję do obszaru zainteresowania"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Kadruj kompozycję do granic zaznaczonych warstw (warstwy)"), + ("render.addOutputModule", "Add Output Module", "Dodaj moduł wyjściowy"), + ("", "Preview", "Podgląd"), + ("playback.toggle", "Play Current Preview", "Odtwórz bieżący podgląd"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Buforuj klatki w bezczynności"), + ("playback.audio", "Audio", "Audio"), + ("", "Save Frame As", "Zapisz klatkę jako"), + ("comp.saveFrameAs", "File...", "Plik..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Warstwy Photoshopa..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "Kopiuj klatkę do schowka"), + ("render.preRender", "Pre-render...", "Wstępne renderowanie..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Zapisz bieżący podgląd..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Otwórz w Essential Graphics"), + ("", "Responsive Design — Time", "Projekt responsywny — czas"), + ("comp.responsiveTime", "Create Intro", "Utwórz intro"), + ("comp.responsiveTime", "Create Outro", "Utwórz outro"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Utwórz chroniony obszar z obszaru roboczego"), + ("comp.flowchart", "Composition Flowchart", "Schemat kompozycji"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Miniaturowy schemat kompozycji"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "Utwórz środowisko VR..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Wyodrębnij cubemapę..."), + ("", "Layer", "Warstwa"), + ("layer.newText", "Text", "Tekst"), + ("layer.newSolid", "Solid...", "Plansza..."), + ("layer.newLight", "Light...", "Światło..."), + ("layer.newCamera", "Camera...", "Kamera..."), + ("layer.newNull", "Null Object", "Obiekt zerowy"), + ("layer.newShape", "Shape Layer", "Warstwa kształtu"), + ("layer.newAdjustment", "Adjustment Layer", "Warstwa korekcyjna"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Warstwa wypełnienia z uwzględnieniem zawartości..."), + ("layer.new3dPrimitive", "Cube", "Sześcian"), + ("layer.new3dPrimitive", "Sphere", "Kula"), + ("layer.new3dPrimitive", "Plane", "Płaszczyzna"), + ("layer.new3dPrimitive", "Torus", "Torus"), + ("layer.new3dPrimitive", "Cone", "Stożek"), + ("layer.new3dPrimitive", "Cylinder", "Walec"), + ("layer.settings", "Layer Settings...", "Ustawienia warstwy..."), + ("layer.openLayer", "Open Layer", "Otwórz warstwę"), + ("layer.openSource", "Open Layer Source", "Otwórz źródło warstwy"), + ("layer.revealInFinder", "Reveal in Finder", "Pokaż w Finderze"), + ("", "Mask", "Maska"), + ("layer.addMask", "New Mask", "Nowa maska"), + ("layer.mask.shape", "Mask Shape...", "Kształt maski..."), + ("layer.mask.set", "Mask Feather...", "Wtapianie maski..."), + ("layer.mask.set", "Mask Opacity...", "Krycie maski..."), + ("layer.mask.set", "Mask Expansion...", "Rozszerzenie maski..."), + ("layer.mask.reset", "Reset Mask", "Resetuj maskę"), + ("layer.mask.remove", "Remove Mask", "Usuń maskę"), + ("layer.mask.removeAll", "Remove All Masks", "Usuń wszystkie maski"), + ("track.mask", "Track Mask", "Śledź maskę"), + ("", "Mode", "Tryb"), + ("layer.mask.mode", "None", "Brak"), + ("layer.mask.mode", "Add", "Dodaj"), + ("layer.mask.mode", "Subtract", "Odejmij"), + ("layer.mask.mode", "Intersect", "Część wspólna"), + ("layer.mask.mode", "Lighten", "Rozjaśnij"), + ("layer.mask.mode", "Darken", "Przyciemnij"), + ("layer.mask.mode", "Difference", "Różnica"), + ("layer.mask.invert", "Inverted", "Odwrócona"), + ("layer.mask.lock", "Locked", "Zablokowana"), + ("", "Motion Blur", "Rozmycie ruchu"), + ("layer.mask.motionBlur", "Same As Layer", "Jak warstwa"), + ("layer.mask.motionBlur", "On", "Wł."), + ("layer.mask.motionBlur", "Off", "Wył."), + ("", "Feather Falloff", "Zanikanie wtapiania"), + ("layer.mask.featherFalloff", "Smooth", "Gładkie"), + ("layer.mask.featherFalloff", "Linear", "Liniowe"), + ("layer.mask.unlockAll", "Unlock All Masks", "Odblokuj wszystkie maski"), + ("layer.mask.lockOthers", "Lock Other Masks", "Zablokuj pozostałe maski"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Ukryj zablokowane maski"), + ("", "Mask and Shape Path", "Ścieżka maski i kształtu"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Zamknięta"), + ("path.convertToBezier", "Convert To Bezier Path", "Konwertuj na ścieżkę Béziera"), + ("path.setFirstVertex", "Set First Vertex", "Ustaw pierwszy wierzchołek"), + ("path.freeTransform", "Free Transform Points", "Swobodne przekształcanie punktów"), + ("", "Quality", "Jakość"), + ("layer.quality", "Best", "Najlepsza"), + ("layer.quality", "Draft", "Robocza"), + ("layer.quality", "Wireframe", "Szkielet"), + ("layer.sampling", "Bilinear", "Dwuliniowa"), + ("layer.sampling", "Bicubic", "Dwusześcienna"), + ("", "Switches", "Przełączniki"), + ("layer.hideOtherVideo", "Hide Other Video", "Ukryj pozostałe wideo"), + ("layer.showAllVideo", "Show All Video", "Pokaż całe wideo"), + ("layer.unlockAll", "Unlock All Layers", "Odblokuj wszystkie warstwy"), + ("layer.expressions", "Enable Expressions", "Włącz wyrażenia"), + ("layer.expressions", "Disable Expressions", "Wyłącz wyrażenia"), + ("layer.setSwitch", "Shy", "Nieśmiała"), + ("layer.setSwitch", "Lock", "Zablokuj"), + ("layer.setSwitch", "Audio", "Audio"), + ("layer.setSwitch", "Video", "Wideo"), + ("layer.setSwitch", "Solo", "Solo"), + ("layer.setSwitch", "Effect", "Efekt"), + ("layer.setSwitch", "Collapse", "Zwiń"), + ("layer.setSwitch", "Motion Blur", "Rozmycie ruchu"), + ("layer.setSwitch", "Adjustment Layer", "Warstwa korekcyjna"), + ("", "Transform", "Przekształć"), + ("layer.transform", "Reset", "Resetuj"), + ("layer.setTransform", "Anchor Point...", "Punkt zakotwiczenia..."), + ("layer.setTransform", "Position...", "Położenie..."), + ("layer.setTransform", "Scale...", "Skala..."), + ("layer.setTransform", "Orientation...", "Orientacja..."), + ("layer.setTransform", "Rotation...", "Obrót..."), + ("layer.setTransform", "Opacity...", "Krycie..."), + ("layer.transform", "Flip Horizontal", "Odbij w poziomie"), + ("layer.transform", "Flip Vertical", "Odbij w pionie"), + ("layer.transform", "Center In View", "Wyśrodkuj w widoku"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Wyśrodkuj punkt zakotwiczenia w zawartości warstwy"), + ("layer.transform", "Fit to Comp", "Dopasuj do kompozycji"), + ("layer.transform", "Fit to Comp Width", "Dopasuj do szerokości kompozycji"), + ("layer.transform", "Fit to Comp Height", "Dopasuj do wysokości kompozycji"), + ("layer.autoOrient", "Auto-Orient...", "Automatyczna orientacja..."), + ("", "Time", "Czas"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Włącz przemapowanie czasu"), + ("layer.timeReverse", "Time-Reverse Layer", "Odwróć czas warstwy"), + ("layer.timeStretch", "Time Stretch...", "Rozciągnięcie czasu..."), + ("layer.freezeFrame", "Freeze Frame", "Zamrożenie klatki"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Zamroź na ostatniej klatce"), + ("layer.alignVideoToData", "Align Video to Data", "Wyrównaj wideo do danych"), + ("", "Frame Blending", "Mieszanie klatek"), + ("layer.frameBlending", "Off", "Wył."), + ("layer.frameBlending", "Frame Mix", "Miks klatek"), + ("layer.frameBlending", "Pixel Motion", "Ruch pikseli"), + ("layer.setSwitch", "3D Layer", "Warstwa 3D"), + ("layer.setSwitch", "Guide Layer", "Warstwa pomocnicza"), + ("layer.environment", "Environment Layer", "Warstwa środowiska"), + ("", "Markers", "Znaczniki"), + ("layer.addMarker", "Add Marker", "Dodaj znacznik"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Aktualizuj znaczniki ze źródła"), + ("layer.markersLock", "Lock Markers", "Zablokuj znaczniki"), + ("layer.deleteAllMarkers", "Delete All Markers", "Usuń wszystkie znaczniki"), + ("layer.setSwitch", "Preserve Transparency", "Zachowaj przezroczystość"), + ("", "Blending Mode", "Tryb mieszania"), + ("layer.setBlendMode", "Normal", "Normalny"), + ("layer.setBlendMode", "Dissolve", "Rozpuszczenie"), + ("layer.setBlendMode", "Dancing Dissolve", "Tańczące rozpuszczenie"), + ("layer.setBlendMode", "Darken", "Ciemniej"), + ("layer.setBlendMode", "Multiply", "Mnożenie"), + ("layer.setBlendMode", "Color Burn", "Ściemnianie koloru"), + ("layer.setBlendMode", "Classic Color Burn", "Klasyczne ściemnianie koloru"), + ("layer.setBlendMode", "Linear Burn", "Ściemnianie liniowe"), + ("layer.setBlendMode", "Darker Color", "Ciemniejszy kolor"), + ("layer.setBlendMode", "Add", "Dodaj"), + ("layer.setBlendMode", "Lighten", "Jaśniej"), + ("layer.setBlendMode", "Screen", "Mnożenie odwrotne"), + ("layer.setBlendMode", "Color Dodge", "Rozjaśnianie koloru"), + ("layer.setBlendMode", "Classic Color Dodge", "Klasyczne rozjaśnianie koloru"), + ("layer.setBlendMode", "Linear Dodge", "Rozjaśnianie liniowe"), + ("layer.setBlendMode", "Lighter Color", "Jaśniejszy kolor"), + ("layer.setBlendMode", "Overlay", "Nakładka"), + ("layer.setBlendMode", "Soft Light", "Łagodne światło"), + ("layer.setBlendMode", "Hard Light", "Ostre światło"), + ("layer.setBlendMode", "Linear Light", "Światło liniowe"), + ("layer.setBlendMode", "Vivid Light", "Jaskrawe światło"), + ("layer.setBlendMode", "Pin Light", "Światło punktowe"), + ("layer.setBlendMode", "Hard Mix", "Twarde mieszanie"), + ("layer.setBlendMode", "Difference", "Różnica"), + ("layer.setBlendMode", "Classic Difference", "Klasyczna różnica"), + ("layer.setBlendMode", "Exclusion", "Wykluczenie"), + ("layer.setBlendMode", "Subtract", "Odejmowanie"), + ("layer.setBlendMode", "Divide", "Dzielenie"), + ("layer.setBlendMode", "Hue", "Barwa"), + ("layer.setBlendMode", "Saturation", "Nasycenie"), + ("layer.setBlendMode", "Color", "Kolor"), + ("layer.setBlendMode", "Luminosity", "Jasność"), + ("layer.setBlendMode", "Stencil Alpha", "Szablon alfa"), + ("layer.setBlendMode", "Stencil Luma", "Szablon luma"), + ("layer.setBlendMode", "Silhouette Alpha", "Sylweta alfa"), + ("layer.setBlendMode", "Silhouette Luma", "Sylweta luma"), + ("layer.setBlendMode", "Alpha Add", "Dodawanie alfa"), + ("layer.setBlendMode", "Luminescent Premul", "Luminescencja premul"), + ("layer.setBlendMode", "Next Blending Mode", "Następny tryb mieszania"), + ("layer.setBlendMode", "Previous Blending Mode", "Poprzedni tryb mieszania"), + ("", "Track Matte", "Matowa ścieżka"), + ("layer.trackMatte", "No Track Matte", "Bez matowej ścieżki"), + ("layer.trackMatte", "Alpha Matte", "Matowa alfa"), + ("layer.trackMatte", "Alpha Inverted Matte", "Odwrócona matowa alfa"), + ("layer.trackMatte", "Luma Matte", "Matowa luma"), + ("layer.trackMatte", "Luma Inverted Matte", "Odwrócona matowa luma"), + ("layer.trackMatte", "Matte with Layer Above", "Matowa z warstwą powyżej"), + ("layer.trackMatte", "Matte with Layer Below", "Matowa z warstwą poniżej"), + ("", "Layer Styles", "Style warstwy"), + ("layer.style.options", "Layer Style Options...", "Opcje stylu warstwy..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "Konwertuj na edytowalne style"), + ("layer.style.showAll", "Show All", "Pokaż wszystkie"), + ("layer.style.removeAll", "Remove All", "Usuń wszystkie"), + ("layer.style.dropShadow", "Drop Shadow", "Cień"), + ("layer.style.innerShadow", "Inner Shadow", "Cień wewnętrzny"), + ("layer.style.outerGlow", "Outer Glow", "Blask zewnętrzny"), + ("layer.style.innerGlow", "Inner Glow", "Blask wewnętrzny"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Faza i płaskorzeźba"), + ("layer.style.satin", "Satin", "Satyna"), + ("layer.style.colorOverlay", "Color Overlay", "Nakładka koloru"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Nakładka gradientu"), + ("layer.style.stroke", "Stroke", "Obrys"), + ("path.groupShapes", "Group Shapes", "Grupuj kształty"), + ("path.ungroupShapes", "Ungroup Shapes", "Rozgrupuj kształty"), + ("", "Arrange", "Kolejność"), + ("layer.arrange", "Bring Layer to Front", "Przesuń warstwę na wierzch"), + ("layer.arrange", "Bring Layer Forward", "Przesuń warstwę do przodu"), + ("layer.arrange", "Send Layer Backward", "Przesuń warstwę do tyłu"), + ("layer.arrange", "Send Layer to Back", "Przesuń warstwę na spód"), + ("", "Reveal", "Pokaż"), + ("layer.revealSource", "Reveal Layer Source in Project", "Pokaż źródło warstwy w projekcie"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Pokaż warstwę w schemacie projektu"), + ("comp.revealInProject", "Reveal Composition in Project", "Pokaż kompozycję w projekcie"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Pokaż błędy wyrażeń"), + ("", "Create", "Utwórz"), + ("layer.create", "Convert to Editable Text", "Konwertuj na edytowalny tekst"), + ("layer.create", "Create Shapes from Text", "Utwórz kształty z tekstu"), + ("layer.create", "Create Masks from Text", "Utwórz maski z tekstu"), + ("layer.create", "Create Shapes from Vector Layer", "Utwórz kształty z warstwy wektorowej"), + ("layer.create", "Create Keyframes from Data", "Utwórz klatki kluczowe z danych"), + ("layer.create", "Null Controllers for Positional Points", "Kontrolery zerowe dla punktów położenia"), + ("layer.create", "Null Controllers for Path Points", "Kontrolery zerowe dla punktów ścieżki"), + ("layer.create", "Nulls Following Path Points", "Obiekty zerowe podążające za punktami ścieżki"), + ("layer.create", "Null Tracing Along Path", "Obiekt zerowy wzdłuż ścieżki"), + ("layer.create", "Create 3D Layer Instance", "Utwórz instancję warstwy 3D"), + ("", "Camera", "Kamera"), + ("camera.fromView", "Create Camera from 3D View", "Utwórz kamerę z widoku 3D"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Utwórz zestaw stereo 3D"), + ("camera.orbitNull", "Create Orbit Null", "Utwórz obiekt zerowy orbity"), + ("camera.fromModel", "Create Cameras from 3D Model", "Utwórz kamery z modelu 3D"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Powiąż odległość ostrości z punktem zainteresowania"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Powiąż odległość ostrości z warstwą"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Ustaw odległość ostrości na warstwę"), + ("layer.cameraSettings", "Camera Settings...", "Ustawienia kamery..."), + ("view.reset3DView", "Reset 3D View", "Resetuj widok 3D"), + ("", "Light", "Światło"), + ("light.fromModel", "Create Lights from 3D Model", "Utwórz światła z modelu 3D"), + ("light.controlWithCamera", "Control Light with Camera", "Steruj światłem za pomocą kamery"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Utwórz warstwę tła światła otoczenia"), + ("", "Material", "Materiał"), + ("material.revealSource", "Reveal Material Source in Project", "Pokaż źródło materiału w projekcie"), + ("material.reset", "Reset Material", "Resetuj materiał"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Powiel i przypisz materiał"), + ("layer.autoTrace", "Auto-trace...", "Automatyczne obrysowanie..."), + ("layer.precompose", "Pre-compose...", "Utwórz kompozycję wstępną..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Wykrywanie zmian scen..."), + ("", "Effect", "Efekt"), + ("window.panel", "Effect Controls", "Ustawienia efektów"), + ("effect.applyLast", "Last Effect", "Ostatni efekt"), + ("effect.removeAll", "Remove All", "Usuń wszystkie"), + ("effect.manage", "Manage Effects...", "Zarządzaj efektami..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Wczytaj wtyczkę efektu..."), + ("", "Animation", "Animacja"), + ("anim.savePreset", "Save Animation Preset...", "Zapisz ustawienie animacji..."), + ("anim.applyPreset", "Apply Animation Preset...", "Zastosuj ustawienie animacji..."), + ("", "Recent Animation Presets", "Ostatnie ustawienia animacji"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Wyczyść ostatnie ustawienia"), + ("anim.browsePresets", "Browse Presets...", "Przeglądaj ustawienia..."), + ("", "Text Animation Presets", "Ustawienia animacji tekstu"), + ("layer.applyTextPreset", "Typewriter", "Maszyna do pisania"), + ("layer.applyTextPreset", "Fade Up Characters", "Pojawianie się znaków"), + ("layer.applyTextPreset", "Bounce In Words", "Odbijające się słowa"), + ("layer.applyTextPreset", "Tracking In", "Zbliżanie liter"), + ("layer.applyTextPreset", "Scramble", "Mieszanie znaków"), + ("layer.applyTextPreset", "Blur In", "Wyostrzanie z rozmycia"), + ("layer.applyTextPreset", "Jitter", "Drganie"), + ("layer.applyTextPreset", "Drop In Lines", "Wpadające wiersze"), + ("anim.addKeyframe", "Add Keyframe", "Dodaj klatkę kluczową"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Przełącz klatkę zatrzymania"), + ("keys.interpolation", "Keyframe Interpolation...", "Interpolacja klatek kluczowych..."), + ("keys.velocity", "Keyframe Velocity...", "Prędkość klatek kluczowych..."), + ("", "Keyframe Assistant", "Asystent klatek kluczowych"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Konwertuj audio na klatki kluczowe"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Konwertuj wyrażenie na klatki kluczowe"), + ("keys.easyEase", "Easy Ease", "Łagodne przejście"), + ("keys.easyEaseIn", "Easy Ease In", "Łagodne wejście"), + ("keys.easyEaseOut", "Easy Ease Out", "Łagodne wyjście"), + ("keys.exponentialScale", "Exponential Scale", "Skalowanie wykładnicze"), + ("keys.rpfCameraImport", "RPF Camera Import", "Import kamery RPF"), + ("layer.sequence", "Sequence Layers...", "Ułóż warstwy w sekwencję..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Odwróć czas klatek kluczowych"), + ("", "Animate Text", "Animuj tekst"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "Włącz 3D dla każdego znaku"), + ("layer.addTextAnimator", "Anchor Point", "Punkt zakotwiczenia"), + ("layer.addTextAnimator", "Position", "Położenie"), + ("layer.addTextAnimator", "Scale", "Skala"), + ("layer.addTextAnimator", "Skew", "Pochylenie"), + ("layer.addTextAnimator", "Rotation", "Obrót"), + ("layer.addTextAnimator", "Opacity", "Krycie"), + ("layer.addTextAnimator", "All Transform Properties", "Wszystkie właściwości przekształcenia"), + ("", "Fill Color", "Kolor wypełnienia"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "Barwa"), + ("layer.addTextAnimator", "Saturation", "Nasycenie"), + ("layer.addTextAnimator", "Brightness", "Jasność"), + ("", "Stroke Color", "Kolor obrysu"), + ("layer.addTextAnimator", "Stroke Width", "Szerokość obrysu"), + ("layer.addTextAnimator", "Tracking", "Światło"), + ("layer.addTextAnimator", "Line Anchor", "Zakotwiczenie wiersza"), + ("layer.addTextAnimator", "Line Spacing", "Odstęp między wierszami"), + ("layer.addTextAnimator", "Character Offset", "Przesunięcie znaku"), + ("layer.addTextAnimator", "Character Value", "Wartość znaku"), + ("layer.addTextAnimator", "Blur", "Rozmycie"), + ("text.animatorFontAxes", "Variable Font Axes", "Osie czcionki zmiennej"), + ("", "Add Text Selector", "Dodaj selektor tekstu"), + ("text.addSelector", "Range", "Zakres"), + ("text.addSelector", "Wiggly", "Falowanie"), + ("text.addSelector", "Expression", "Wyrażenie"), + ("text.removeAllAnimators", "Remove All Text Animators", "Usuń wszystkie animatory tekstu"), + ("prop.setExpression", "Add Expression", "Dodaj wyrażenie"), + ("essential.addProperty", "Add Property to Essential Graphics", "Dodaj właściwość do Essential Graphics"), + ("prop.separateDimensions", "Separate Dimensions", "Rozdziel wymiary"), + ("track.camera", "Track Camera", "Śledź kamerę"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Warp Stabilizer VFX"), + ("track.motion", "Track Motion", "Śledź ruch"), + ("track.stabilize", "Stabilize Motion", "Stabilizuj ruch"), + ("track.property", "Track this Property", "Śledź tę właściwość"), + ("anim.reveal", "Reveal Properties with Keyframes", "Pokaż właściwości z klatkami kluczowymi"), + ("anim.reveal", "Reveal Properties with Animation", "Pokaż właściwości z animacją"), + ("anim.reveal", "Reveal All Modified Properties", "Pokaż wszystkie zmienione właściwości"), + ("", "View", "Widok"), + ("view.newViewer", "New Viewer", "Nowa przeglądarka"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Podziel z nową zablokowaną przeglądarką"), + ("view.zoomIn", "Zoom In", "Powiększ"), + ("view.zoomOut", "Zoom Out", "Pomniejsz"), + ("", "Resolution", "Rozdzielczość"), + ("view.res.full", "Full", "Pełna"), + ("view.res.half", "Half", "Połowa"), + ("view.res.third", "Third", "Jedna trzecia"), + ("view.res.quarter", "Quarter", "Jedna czwarta"), + ("view.res.custom", "Custom...", "Niestandardowa..."), + ("view.displayColorManagement", "Use Display Color Management", "Używaj zarządzania kolorem wyświetlacza"), + ("", "Simulate Output", "Symuluj wyjście"), + ("view.simulateOutput", "No Output Simulation", "Bez symulacji wyjścia"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "Starsze RGB komputerów Macintosh (gamma 1,8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "Standardowe internetowe RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Liniowe (gamma 1,0)"), + ("view.customRgb", "My Custom RGB...", "Moje niestandardowe RGB..."), + ("view.simulateOutput", "Custom...", "Niestandardowe..."), + ("view.rulers", "Show Rulers", "Pokaż linijki"), + ("", "Panel Background Color", "Kolor tła panelu"), + ("view.panelBackground", "Black", "Czarny"), + ("view.panelBackground", "Dark Gray", "Ciemnoszary"), + ("view.panelBackground", "Medium Gray (Default)", "Średnioszary (domyślny)"), + ("view.panelBackground", "Light Gray", "Jasnoszary"), + ("view.panelBackground", "White", "Biały"), + ("view.panelBackground", "Custom", "Niestandardowy"), + ("view.panelBackground", "Select Custom Background Color...", "Wybierz niestandardowy kolor tła..."), + ("view.guides", "Show Guides", "Pokaż linie pomocnicze"), + ("view.snapToGuides", "Snap to Guides", "Przyciągaj do linii pomocniczych"), + ("view.lockGuides", "Lock Guides", "Zablokuj linie pomocnicze"), + ("view.addGuide", "Add Guide...", "Dodaj linię pomocniczą..."), + ("view.addPercentGuide", "Add Percentage Guide...", "Dodaj procentową linię pomocniczą..."), + ("view.convertGuides", "Convert Guides to Percentage", "Konwertuj linie pomocnicze na procenty"), + ("view.convertGuides", "Convert Guides to Pixels", "Konwertuj linie pomocnicze na piksele"), + ("view.clearGuides", "Clear Guides", "Wyczyść linie pomocnicze"), + ("view.importGuides", "Import Guides...", "Importuj linie pomocnicze..."), + ("view.exportGuides", "Export Guides...", "Eksportuj linie pomocnicze..."), + ("view.grid", "Show Grid", "Pokaż siatkę"), + ("view.snapToGrid", "Snap to Grid", "Przyciągaj do siatki"), + ("view.snapping", "Snapping", "Przyciąganie"), + ("view.options", "View Options...", "Opcje widoku..."), + ("view.layerControls", "Show Layer Controls", "Pokaż elementy sterujące warstwy"), + ("", "Switch View Layout", "Przełącz układ widoków"), + ("view.layout", "1 View", "1 widok"), + ("view.layout", "2 Views", "2 widoki"), + ("view.layout", "4 Views", "4 widoki"), + ("view.shareViewOptions", "Share View Options", "Wspólne opcje widoku"), + ("", "Switch 3D View", "Przełącz widok 3D"), + ("view.3d.activeCamera", "Active Camera", "Aktywna kamera"), + ("view.3d.default", "Default", "Domyślny"), + ("view.3d.front", "Front", "Przód"), + ("view.3d.left", "Left", "Lewo"), + ("view.3d.top", "Top", "Góra"), + ("view.3d.back", "Back", "Tył"), + ("view.3d.right", "Right", "Prawo"), + ("view.3d.bottom", "Bottom", "Dół"), + ("view.3d.custom1", "Custom View 1", "Widok niestandardowy 1"), + ("view.3d.custom2", "Custom View 2", "Widok niestandardowy 2"), + ("view.3d.custom3", "Custom View 3", "Widok niestandardowy 3"), + ("", "Assign Shortcut to 3D View", "Przypisz skrót do widoku 3D"), + ("view.3d.last", "Switch to Last 3D View", "Przełącz na ostatni widok 3D"), + ("view.lookAtSelected", "Look at Selected Layers", "Patrz na zaznaczone warstwy"), + ("view.lookAtAll", "Look at All Layers", "Patrz na wszystkie warstwy"), + ("time.set", "Go to Time...", "Przejdź do czasu..."), + ("view.fullScreen", "Enter Full Screen", "Pełny ekran"), + ("", "Window", "Okno"), + ("", "Workspace", "Obszar roboczy"), + ("window.workspace", "Default", "Domyślny"), + ("window.workspace", "Review", "Przegląd"), + ("window.workspace", "Learn", "Nauka"), + ("window.workspace", "Small Screen", "Mały ekran"), + ("window.workspace", "Standard", "Standardowy"), + ("window.workspace", "All Panels", "Wszystkie panele"), + ("window.workspace", "Animation", "Animacja"), + ("window.workspace", "Color", "Kolor"), + ("window.workspace", "Effects", "Efekty"), + ("window.workspace", "Essential Graphics", "Essential Graphics"), + ("window.workspace", "Minimal", "Minimalny"), + ("window.workspace", "Motion Tracking", "Śledzenie ruchu"), + ("window.workspace", "Paint", "Malowanie"), + ("window.workspace", "Text", "Tekst"), + ("window.workspace", "Undocked Panels", "Panele odpięte"), + ("window.resetWorkspace", "Reset to Saved Layout", "Przywróć zapisany układ"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Zapisz zmiany w tym obszarze roboczym"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Zapisz jako nowy obszar roboczy..."), + ("window.editWorkspaces", "Edit Workspaces...", "Edytuj obszary robocze..."), + ("", "Assign Shortcut to Workspace", "Przypisz skrót do obszaru roboczego"), + ("window.panel", "Align", "Wyrównanie"), + ("window.panel", "Audio", "Audio"), + ("window.panel", "Brushes", "Pędzle"), + ("window.panel", "Character", "Znak"), + ("window.panel", "Content-Aware Fill", "Wypełnienie z uwzględnieniem zawartości"), + ("window.panel", "Effects & Presets", "Efekty i ustawienia"), + ("window.panel", "Essential Graphics", "Essential Graphics"), + ("window.panel", "Info", "Informacje"), + ("help.inAppTutorials", "Learn", "Nauka"), + ("window.panel", "Lumetri Scopes", "Zakresy Lumetri"), + ("window.panel", "Mask Interpolation", "Interpolacja maski"), + ("window.panel", "Media Browser", "Przeglądarka multimediów"), + ("window.panel", "Metadata", "Metadane"), + ("window.panel", "Motion Sketch", "Szkic ruchu"), + ("window.panel", "Paint", "Malowanie"), + ("window.panel", "Paragraph", "Akapit"), + ("window.panel", "Preview", "Podgląd"), + ("window.panel", "Progress", "Postęp"), + ("window.panel", "Properties", "Właściwości"), + ("window.panel", "Script Console", "Konsola skryptów"), + ("window.panel", "Smoother", "Wygładzanie"), + ("window.panel", "Tools", "Narzędzia"), + ("window.panel", "Tracker", "Śledzenie"), + ("window.panel", "Wiggler", "Wahanie"), + ("window.panel", "Composition", "Kompozycja"), + ("window.panel", "Flowchart", "Schemat"), + ("window.panel", "Footage", "Materiał"), + ("window.panel", "Layer", "Warstwa"), + ("window.panel", "Project", "Projekt"), + ("window.panel", "Render Queue", "Kolejka renderowania"), + ("window.panel", "Timeline", "Oś czasu"), + ("window.panel", "Create Nulls From Paths", "Utwórz obiekty zerowe ze ścieżek"), + ("window.panel", "VR Comp Editor", "Edytor kompozycji VR"), + ("", "Help", "Pomoc"), + ("help.docs", "EffectCraft Help...", "Pomoc EffectCraft..."), + ("help.docs", "Scripting Help...", "Pomoc dotycząca skryptów..."), + ("help.docs", "Expression Reference...", "Dokumentacja wyrażeń..."), + ("help.docs", "Effect Reference...", "Dokumentacja efektów..."), + ("anim.browsePresets", "Animation Presets...", "Ustawienia animacji..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Skróty klawiaturowe..."), + ("help.inAppTutorials", "In-App Tutorials...", "Samouczki w aplikacji..."), + ("help.onlineTutorials", "Online Tutorials...", "Samouczki online..."), + ("help.systemReport", "System Compatibility Report...", "Raport zgodności systemu..."), + ("help.enableLogging", "Enable Logging", "Włącz rejestrowanie"), + ("help.revealLogFile", "Reveal Logging File", "Pokaż plik dziennika"), + ("help.discord", "Join the ArtCraft Discord...", "Dołącz do Discorda ArtCraft..."), + ("help.reportIssue", "Provide Feedback...", "Prześlij opinię..."), + ("help.website", "ArtCraft Website", "Witryna ArtCraft"), + ("help.appPage", "EffectCraft Home Page", "Strona główna EffectCraft"), + ("help.github", "EffectCraft on GitHub", "EffectCraft w serwisie GitHub"), + ("file.openDemoProject", "Open Demo Project", "Otwórz projekt demonstracyjny"), + ("", "Recent Script Files", "Ostatnie pliki skryptów"), + ("layer.newPrimitive", "3D Primitive...", "Bryła podstawowa 3D..."), +]; + /// Czech (cs): original phrase translations of every fixed menu entry. const CZECH: &[(&str, &str, &str)] = &[ ("", "Front", "Přední strana"), @@ -7566,6 +8230,10 @@ mod tests { (Some("cs-CZ"), "cs"), (Some("cs_CZ.UTF-8"), "cs"), (Some("CS_cz.UTF-8"), "cs"), + (Some("pl"), "pl"), + (Some("pl-PL"), "pl"), + (Some("pl_PL.UTF-8"), "pl"), + (Some("PL_pl.UTF-8"), "pl"), (Some("ka"), "ka"), (Some("ka-GE"), "ka"), (Some("ka_GE.UTF-8"), "ka"), @@ -7583,6 +8251,7 @@ mod tests { assert_eq!(traditional(&app), system_language() == "zh-hant"); assert_eq!(spanish(&app), system_language() == "es"); assert_eq!(czech(&app), system_language() == "cs"); + assert_eq!(polish(&app), system_language() == "pl"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"ja"})).unwrap(); assert!(japanese(&app)); app.session.execute("prefs.set", json!({"key":"general.language", "value":"zh-hans"})).unwrap(); @@ -7609,6 +8278,7 @@ mod tests { ("es", SPANISH), ("pt-br", PORTUGUESE), ("cs", CZECH), + ("pl", POLISH), ("ka", GEORGIAN), ] { for (command, en, translated) in catalog { diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 46493296b..96c6c081c 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -22,7 +22,7 @@ use std::fmt::Display; use std::sync::OnceLock; thread_local! { - /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es", "ka"); "en" until the first + /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es", "pl", "ka"); "en" until the first /// frame sets it. static CURRENT: Cell<&'static str> = const { Cell::new("en") }; } @@ -50,6 +50,7 @@ const ROWS: &[(&str, &[(&str, &str)])] = &[ ("pt-br", PORTUGUESE), ("cs", CZECH), ("es", SPANISH), + ("pl", POLISH), ("ka", GEORGIAN), ("ka", GEORGIAN_PANELS), ]; @@ -64,6 +65,7 @@ fn catalog(language: &str) -> Option<&'static Index> { static PT_BR: OnceLock = OnceLock::new(); static CS: OnceLock = OnceLock::new(); static ES: OnceLock = OnceLock::new(); + static PL: OnceLock = OnceLock::new(); static KA: OnceLock = OnceLock::new(); let (index, rows) = match language { "ja" => (&JA, JAPANESE), @@ -73,6 +75,7 @@ fn catalog(language: &str) -> Option<&'static Index> { "pt-br" => (&PT_BR, PORTUGUESE), "cs" => (&CS, CZECH), "es" => (&ES, SPANISH), + "pl" => (&PL, POLISH), // Georgian also reads its own rows for text the other catalogs don't have yet. "ka" => return Some(KA.get_or_init(|| GEORGIAN.iter().chain(GEORGIAN_PANELS).copied().collect())), _ => return None, @@ -3355,6 +3358,753 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Per Layer", "Por camada"), ]; +/// Polish (pl): original translations covering every row in the Chinese panel catalogs. +const POLISH: &[(&str, &str)] = &[ + ("Appearance Settings…", "Ustawienia wyglądu…"), + ("Add to favorites", "Dodaj do ulubionych"), + ("Remove from favorites", "Usuń z ulubionych"), + (" OK ", " OK "), + (" Delete ", " Usuń "), + (" Open Auto-Save ", " Otwórz autozapis "), + ("(missing)", "(brak)"), + ("360 View Orientation", "Orientacja widoku 360"), + ("3:2 Pulldown: {}", "Pulldown 3:2: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "Kompozytor grafiki ruchomej i efektów wizualnych, open source i tworzony metodą clean-room: natywny w macOS, Windows i Linuksie oraz w przeglądarce. Część rodziny aplikacji twórczych ArtCraft.", + ), + ("About EffectCraft", "O programie EffectCraft"), + ("About...", "O programie..."), + ("Active", "Aktywny"), + ("Active Camera", "Aktywna kamera"), + ("Alpha", "Alfa"), + ("Adapt Feature On Every Frame", "Dostosuj cechę w każdej klatce"), + ("Adaptive Sample Limit:", "Adaptacyjny limit próbek:"), + ("Add Animator", "Dodaj animator"), + ("Add Comment", "Dodaj komentarz"), + ("Add Formatting", "Dodaj formatowanie"), + ("Add Group", "Dodaj grupę"), + ("Add Mirror", "Dodaj lustro"), + ("Add Selected As", "Dodaj zaznaczone jako"), + ("Add a comment", "Dodaj komentarz"), + ("Add a shape, fill, stroke or path operation", "Dodaj kształt, wypełnienie, obrys lub operację na ścieżce"), + ("Add to Composition", "Dodaj do kompozycji"), + ("Add to Favorites", "Dodaj do ulubionych"), + ("Add:", "Dodaj:"), + ("Adjust Exposure (stops)", "Dostosuj ekspozycję (EV)"), + ("Adjust composition duration to the time span of the selected layers", "Dopasuj czas trwania kompozycji do zakresu czasu zaznaczonych warstw"), + ("Advanced 3D", "Zaawansowane 3D"), + ("Align Layers to:", "Wyrównaj warstwy do:"), + ("All six face cameras turn together; the scene is not changed.", "Wszystkie sześć kamer ścian obraca się razem; scena się nie zmienia."), + ("Anchor:", "Zakotwiczenie:"), + ("Aperture", "Przysłona"), + ("Appearance Mode", "Tryb wyglądu"), + ("Appearance Mode: {}", "Tryb wyglądu: {}"), + ("Apply Dimensions:", "Zastosuj wymiary:"), + ("Apply Motion To:", "Zastosuj ruch do:"), + ("Arrange", "Kolejność"), + ("Assign", "Przypisz"), + ("Auto", "Auto"), + ("Auto-saves", "Autozapisy"), + ("Back", "Tył"), + ("Background Color:", "Kolor tła:"), + ("Blend Mode:", "Tryb mieszania:"), + ("Blending Mode", "Tryb mieszania"), + ("Blur", "Rozmycie"), + ("Blur Level", "Poziom rozmycia"), + ("Boundary", "Granica"), + ("Browser Storage", "Pamięć przeglądarki"), + ("Browse…", "Przeglądaj…"), + ("Brush Dynamics", "Dynamika pędzla"), + ("Caching frames…", "Buforowanie klatek…"), + ("Cancel", "Anuluj"), + ("Casts Shadows", "Rzuca cienie"), + ("Change Renderer…", "Zmień renderer…"), + ( + "Changing a shortcut creates a custom preset; the default stays as it is.", + "Zmiana skrótu tworzy niestandardowe ustawienie; domyślne pozostaje bez zmian.", + ), + ("Channel", "Kanał"), + ("Channel:", "Kanał:"), + ("Chapter:", "Rozdział:"), + ("Choose...", "Wybierz..."), + ("Classic 3D", "Klasyczne 3D"), + ("Clear", "Wyczyść"), + ("Clear list", "Wyczyść listę"), + ("Click a key to see its commands.", "Kliknij klawisz, aby zobaczyć jego polecenia."), + ( + "Click above the bar to add an opacity stop, below it to add a color stop.", + "Kliknij nad paskiem, aby dodać punkt krycia, pod nim, aby dodać punkt koloru.", + ), + ("Click adds a point (½ height = no change)", "Kliknięcie dodaje punkt (½ wysokości = bez zmian)"), + ("Click adds a point; drag one off to remove", "Kliknięcie dodaje punkt; przeciągnij go poza obszar, aby usunąć"), + ("Click to make this viewer active", "Kliknij, aby uaktywnić tę przeglądarkę"), + ("Click to reveal the expression in the timeline", "Kliknij, aby pokazać wyrażenie na osi czasu"), + ("Click to show the active composition here", "Kliknij, aby pokazać tu aktywną kompozycję"), + ("Click, then click in the Composition panel", "Kliknij, a potem kliknij w panelu Kompozycja"), + ("Clone Options", "Opcje klonowania"), + ("Clone Source Overlay:", "Nakładka źródła klonowania:"), + ("Close", "Zamknij"), + ("Close Panel", "Zamknij panel"), + ("Close tutorial", "Zamknij samouczek"), + ("Color", "Kolor"), + ("Color Depth: {}", "Głębia kolorów: {}"), + ("Color:", "Kolor:"), + ("Colour map", "Mapa kolorów"), + ("Columns", "Kolumny"), + ("Comment:", "Komentarz:"), + ("Community", "Społeczność"), + ("Composition", "Kompozycja"), + ("Composition Mini-Flowchart", "Miniaturowy schemat kompozycji"), + ("Composition Name:", "Nazwa kompozycji:"), + ("Composition Settings ▸ 3D Renderer", "Ustawienia kompozycji ▸ Renderer 3D"), + ("Composition Settings…", "Ustawienia kompozycji…"), + ("Composition marker bin: drag a marker to the time ruler", "Pojemnik znaczników kompozycji: przeciągnij znacznik na linijkę czasu"), + ("Cone Angle", "Kąt stożka"), + ("Cone Feather", "Wtapianie stożka"), + ("Constrain Proportions", "Zachowaj proporcje"), + ("Continuous (Lock Outgoing to Incoming)", "Ciągły (zablokuj wyjście do wejścia)"), + ("Cue Point", "Punkt sygnału"), + ("Current Render", "Bieżące renderowanie"), + ("Dark", "Ciemny"), + ("Dark Theme", "Ciemny motyw"), + ("Darker", "Ciemniej"), + ("Default Output:", "Domyślne wyjście:"), + ("Default: {}", "Domyślnie: {}"), + ("Defaults", "Domyślne"), + ("Delete", "Usuń"), + ("Delete Selected Points", "Usuń zaznaczone punkty"), + ("Delete selected project items", "Usuń zaznaczone elementy projektu"), + ("Delete this template", "Usuń ten szablon"), + ("Depth of Field", "Głębia ostrości"), + ("Discord", "Discord"), + ("Distribute Layers:", "Rozmieść warstwy:"), + ("Don't Recover", "Nie odzyskuj"), + ("Don't Save", "Nie zapisuj"), + ("Double-click a layer to open it in the Layer panel", "Kliknij dwukrotnie warstwę, aby otworzyć ją w panelu Warstwa"), + ("Double-click footage in the Project panel to open it here", "Kliknij dwukrotnie materiał w panelu Projekt, aby otworzyć go tutaj"), + ("Double-click to edit the comment", "Kliknij dwukrotnie, aby edytować komentarz"), + ("Download", "Pobierz"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "Przeciągnij tu właściwości z osi czasu\nalbo użyj Animacja > Dodaj właściwość do Essential Graphics.", + ), + ("Drag to change", "Przeciągnij, aby zmienić"), + ("Drag to create a guide", "Przeciągnij, aby utworzyć linię pomocniczą"), + ("Drag to slip the source", "Przeciągnij, aby przesunąć źródło"), + ("Drop Frame", "Z pomijaniem klatek"), + ("Duplicate", "Powiel"), + ("Duration:", "Czas trwania:"), + ("Edit Gradient…", "Edytuj gradient…"), + ("Edit Target: {}", "Cel edycji: {}"), + ("Edit in the Timeline", "Edytuj na osi czasu"), + ("EffectCraft didn't quit normally last time.", "EffectCraft nie zakończył się poprawnie ostatnim razem."), + ("Effects & Presets options", "Opcje efektów i ustawień"), + ("Copy Presets Folder Path", "Kopiuj ścieżkę folderu ustawień"), + ("Effects: {}", "Efekty: {}"), + ("Empty folder", "Pusty folder"), + ("Enable Depth of Field", "Włącz głębię ostrości"), + ("Enable Expression", "Włącz wyrażenie"), + ("Enhance", "Ulepsz"), + ("Essential Properties — {}", "Najważniejsze właściwości — {}"), + ("Event", "Zdarzenie"), + ("Export Template…", "Eksportuj szablon…"), + ("Export...", "Eksportuj..."), + ("Expression Language Menu", "Menu języka wyrażeń"), + ("Expression pick whip: drag onto a property", "Łącznik wyrażenia: przeciągnij na właściwość"), + ("Expression:", "Wyrażenie:"), + ("Eyedropper: click, then click in the Composition panel", "Kroplomierz: kliknij, a potem kliknij w panelu Kompozycja"), + ("Falloff", "Zanikanie"), + ("Falloff Distance", "Odległość zanikania"), + ("Favorites", "Ulubione"), + ("Field Render: {}", "Renderowanie pól: {}"), + ("Figures", "Cyfry"), + ("Fill Target", "Cel wypełnienia"), + ("Finished", "Zakończono"), + ("Flowchart: {}", "Schemat: {}"), + ("Focus Distance", "Odległość ostrości"), + ("Follow Feature Rotation and Scale", "Podążaj za obrotem i skalą cechy"), + ("Footage: {}", "Materiał: {}"), + ("Format: {}", "Format: {}"), + ("Frame Aspect Ratio: {}", "Proporcje klatki: {}"), + ("Frame Blending: {}", "Mieszanie klatek: {}"), + ("Frame Rate:", "Liczba klatek na sekundę:"), + ("Frame Render Time {}ms", "Czas renderowania klatki {}ms"), + ("Frame Target:", "Cel klatki:"), + ("Freeze", "Zamroź"), + ("Grab bag", "Różności"), + ("Gradient Editor", "Edytor gradientu"), + ("Grid", "Siatka"), + ("Guide Layers: {}", "Warstwy pomocnicze: {}"), + ("Guides", "Linie pomocnicze"), + ("Height:", "Wysokość:"), + ("Hide This", "Ukryj to"), + ("Histogram", "Histogram"), + ("Hold In Place", "Zatrzymaj w miejscu"), + ("Home", "Start"), + ("If Confidence is Below", "Jeśli pewność jest poniżej"), + ("Import", "Importuj"), + ("Import and Add to Composition", "Importuj i dodaj do kompozycji"), + ("Import...", "Importuj..."), + ("Imported media", "Zaimportowane multimedia"), + ("Influence:", "Wpływ:"), + ("Install from File…", "Zainstaluj z pliku…"), + ("Intensity", "Natężenie"), + ("Inverted", "Odwrócone"), + ("Join the ArtCraft community on Discord", "Dołącz do społeczności ArtCraft na Discordzie"), + ("Key: {}", "Klawisz: {}"), + ("LEARN", "NAUKA"), + ("Label", "Etykieta"), + ("Label Colors and Names:", "Kolory i nazwy etykiet:"), + ("Label:", "Etykieta:"), + ("Latest auto-save: {}", "Ostatni autozapis: {}"), + ("Layer", "Warstwa"), + ("Layer controls", "Elementy sterujące warstwy"), + ("Layer:", "Warstwa:"), + ("Layer: {}", "Warstwa: {}"), + ("Learn", "Nauka"), + ("Leave all attributes in \"{}\"", "Zachowaj wszystkie atrybuty w „{}”"), + ("Licence: {}", "Licencja: {}"), + ("Ligatures", "Ligatury"), + ("Light", "Jasny"), + ("Light Theme", "Jasny motyw"), + ("Light Type", "Typ światła"), + ("Lighter", "Jaśniej"), + ("Link Selected Property", "Połącz zaznaczoną właściwość"), + ("Location:", "Lokalizacja:"), + ("Lock Aspect Ratio to {}", "Zablokuj proporcje na {}"), + ("Lock to Zoom", "Zablokuj do powiększenia"), + ("Locked: {} — {}", "Zablokowano: {} — {}"), + ("Log:", "Dziennik:"), + ("Loop (Trace Path)", "Pętla (śledź ścieżkę)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • Czcionki: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "Menu współczynnika powiększenia"), + ("Make Comp Size", "Ustaw rozmiar kompozycji"), + ("Markers", "Znaczniki"), + ("Masks", "Maski"), + ("Maximize Panel", "Maksymalizuj panel"), + ("Media Replacement", "Zamiana multimediów"), + ("Mesh:", "Siatka:"), + ("Diameter:", "Średnica:"), + ("Method Used: {} Average Error: {} pixels", "Użyta metoda: {} Średni błąd: {} px"), + ("Models folder: {}", "Folder modeli: {}"), + ("Modifiers:", "Modyfikatory:"), + ("More", "Więcej"), + ("More ArtCraft apps", "Więcej aplikacji ArtCraft"), + ("More tutorials", "Więcej samouczków"), + ("Motion Blur", "Rozmycie ruchu"), + ("Motion Blur: {}", "Rozmycie ruchu: {}"), + ("Motion Sketch: drag to record", "Szkic ruchu: przeciągnij, aby nagrać"), + ("Move Down", "Przesuń w dół"), + ("Move Up", "Przesuń w górę"), + ("Move to Project Root", "Przenieś do katalogu głównego projektu"), + ("Name", "Nazwa"), + ("Name:", "Nazwa:"), + ("Navigation", "Nawigacja"), + ("New", "Nowy"), + ("New Comp from Selection", "Nowa kompozycja z zaznaczenia"), + ("Replace with Precomposition", "Zastąp kompozycją wstępną"), + ("New Duration:", "Nowy czas trwania:"), + ("New composition name:", "Nazwa nowej kompozycji:"), + ("New from Render Queue item:", "Nowy z elementu kolejki renderowania:"), + ("New from Template", "Nowy z szablonu"), + ("Next", "Dalej"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "Dobra robota. Wybierz kolejny samouczek na karcie Nauka ekranu startowego."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "Brak środowisk VR. Utwórz je poleceniem Kompozycja ▸ VR ▸ Utwórz środowisko VR…", + ), + ("No audio layer selected", "Nie zaznaczono warstwy audio"), + ("No background jobs running", "Brak zadań w tle"), + ("No composition markers", "Brak znaczników kompozycji"), + ("No contributor data was built into this copy.", "Ta kopia nie zawiera danych o współtwórcach."), + ("No device list from this host; the system default output is used.", "Ten system nie podaje listy urządzeń; używane jest domyślne wyjście systemowe."), + ("No layer selected", "Nie zaznaczono warstwy"), + ("No matching effects or presets", "Brak pasujących efektów lub ustawień"), + ("No matching fonts", "Brak pasujących czcionek"), + ("Variable fonts only", "Tylko czcionki zmienne"), + ("No model credits were built into this copy.", "Ta kopia nie zawiera informacji o modelach."), + ("No nested compositions", "Brak zagnieżdżonych kompozycji"), + ("No shortcut", "Brak skrótu"), + ("No unlocked layer selected", "Nie zaznaczono odblokowanej warstwy"), + ("No video", "Brak wideo"), + ("None", "Brak"), + ("Not applied: tick it in the list to add it.", "Niezastosowany: zaznacz go na liście, aby go dodać."), + ("Not assigned", "Nieprzypisany"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "Uwaga: cienie rzucają tylko warstwy z włączoną opcją Rzuca cienie."), + ("Notify", "Powiadom"), + ("OK", "OK"), + ("Offset:", "Przesunięcie:"), + ("On ({}) stop:", "Punkt na ({}):"), + ("One-Node Camera", "Kamera jednowęzłowa"), + ("Opacity:", "Krycie:"), + ("Open Composition", "Otwórz kompozycję"), + ("used once", "użyty raz"), + ("used {} times", "użycia: {}"), + ("Compositions that use this item", "Kompozycje, które używają tego elementu"), + ("Open Last Saved Project", "Otwórz ostatnio zapisany projekt"), + ("Open New Composition", "Otwórz nową kompozycję"), + ("Open Output", "Otwórz wynik"), + ("Open a composition to build Essential Graphics", "Otwórz kompozycję, aby zbudować Essential Graphics"), + ("Open a composition to see its scopes", "Otwórz kompozycję, aby zobaczyć jej zakresy"), + ("Open a composition to track motion.", "Otwórz kompozycję, aby śledzić ruch."), + ("Opus Tuning: {}", "Strojenie Opus: {}"), + ("Original Duration:", "Oryginalny czas trwania:"), + ("Output Cycle: {}", "Cykl wyjścia: {}"), + ("Output To:", "Wyjście do:"), + ("Overlay", "Nakładka"), + ("Parameter Name:", "Nazwa parametru:"), + ("Parent pick whip: drag onto a layer", "Łącznik rodzica: przeciągnij na warstwę"), + ("Pixel Aspect Ratio:", "Proporcje piksela:"), + ("Playing on Video Preview", "Odtwarzanie w podglądzie wideo"), + ("Post-Render Action: {}", "Akcja po renderowaniu: {}"), + ("Preserve frame rate when nested or in render queue", "Zachowaj liczbę klatek po zagnieżdżeniu lub w kolejce renderowania"), + ("Preserve resolution when nested", "Zachowaj rozdzielczość po zagnieżdżeniu"), + ("Preset", "Ustawienie"), + ("Preset:", "Ustawienie:"), + ("Presets", "Ustawienia"), + ("Previous", "Wstecz"), + ("Primary:", "Główny:"), + ("Process Before Match", "Przetwarzaj przed dopasowaniem"), + ("Profile: {}", "Profil: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "Głębia kolorów projektu: kliknij, aby przełączać 8/16/32 bpc, Alt-klik otwiera Ustawienia projektu", + ), + ("Project: {}", "Projekt: {}"), + ("Projects", "Projekty"), + ("Property pick whip: drag onto a property", "Łącznik właściwości: przeciągnij na właściwość"), + ("Protected Region", "Obszar chroniony"), + ("Proxy Use: {}", "Użycie proxy: {}"), + ("Push All to Comp", "Wypchnij wszystko do kompozycji"), + ("RGB", "RGB"), + ("Radius", "Promień"), + ("Rate Control: {}", "Kontrola przepływności: {}"), + ("Recent", "Ostatnie"), + ("Recording", "Nagrywanie"), + ("Remove", "Usuń"), + ("Rename", "Zmień nazwę"), + ("Render: {} ms • UI {} fps{}", "Renderowanie: {} ms • UI {} fps{}"), + ("Renderer:", "Renderer:"), + ("Request Persistent Storage", "Poproś o trwałą pamięć"), + ("Reset", "Resetuj"), + ("Reset Labels", "Resetuj etykiety"), + ("Reset Preset", "Resetuj ustawienie"), + ("Reset to Default", "Przywróć domyślne"), + ("Reset to Saved Layout", "Przywróć zapisany układ"), + ("Resolution/Down Sample Factor Popup", "Menu rozdzielczości/współczynnika próbkowania"), + ("Restore Panel Size", "Przywróć rozmiar panelu"), + ("Reverse the order", "Odwróć kolejność"), + ("Revert All", "Przywróć wszystko"), + ("Roman Hanging Punctuation", "Interpunkcja wisząca (łacińska)"), + ("Roving:", "Wędrujące:"), + ("Ruler zero point (drag to move, double-click to reset)", "Punkt zerowy linijki (przeciągnij, aby przesunąć, kliknij dwukrotnie, aby zresetować)"), + ("Rulers", "Linijki"), + ("Run", "Uruchom"), + ("Sample Format: {}", "Format próbek: {}"), + ("Samples Per Frame:", "Próbek na klatkę:"), + ("Save from #{}", "Zapisz od #{}"), + ("Saved {}", "Zapisano {}"), + ("Script… (⌘/Ctrl+Enter runs)", "Skrypt… (⌘/Ctrl+Enter uruchamia)"), + ("Search commands or shortcuts", "Szukaj poleceń lub skrótów"), + ("Select", "Zaznacz"), + ("Select a command to change its shortcut.", "Zaznacz polecenie, aby zmienić jego skrót."), + ("Select a layer to see its effects", "Zaznacz warstwę, aby zobaczyć jej efekty"), + ("Select a layer to see its properties", "Zaznacz warstwę, aby zobaczyć jej właściwości"), + ("({}) Selected Objects", "Zaznaczone obiekty ({})"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "Zaznacz warstwę z maską lub ścieżką kształtu (albo właściwość ścieżki) lub pinezki Puppet.", + ), + ("Select a text layer", "Zaznacz warstwę tekstową"), + ("Select an item in the Project panel", "Zaznacz element w panelu Projekt"), + ("Select animated properties to show them in the Graph Editor", "Zaznacz animowane właściwości, aby pokazać je w edytorze wykresów"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "Zaznacz pinezkę prowadzącą, a potem Shift-kliknij pinezki, które mają za nią podążać (włosy, tkanina, ogony)", + ), + ("Set Ground Plane and Origin", "Ustaw płaszczyznę podłoża i początek układu"), + ("Set Paint Colors to Black and White (D)", "Ustaw kolory malowania na czarny i biały (D)"), + ("Settings", "Ustawienia"), + ("Settings Name:", "Nazwa ustawień:"), + ("Shadow Darkness", "Ciemność cienia"), + ("Shadow Diffusion", "Rozproszenie cienia"), + ("Shadows", "Cienie"), + ("Shape…", "Kształt…"), + ("Show", "Pokaż"), + ("Show Layer Name", "Pokaż nazwę warstwy"), + ("Show Source Name", "Pokaż nazwę źródła"), + ("Show me", "Pokaż mi"), + ("Show:", "Pokaż:"), + ("Shutter Angle:", "Kąt migawki:"), + ("Shutter Phase:", "Faza migawki:"), + ("Size", "Rozmiar"), + ("Skip", "Pomiń"), + ("Snapping", "Przyciąganie"), + ("Snapping options", "Opcje przyciągania"), + ("Snapshot", "Migawka"), + ("Solo Supported Properties", "Solo dla obsługiwanych właściwości"), + ("Solo Switches: {}", "Przełączniki solo: {}"), + ("Sort", "Sortuj"), + ("Spacing:", "Odstęp:"), + ("Spatial Interpolation:", "Interpolacja przestrzenna:"), + ("Speed:", "Szybkość:"), + ("Start Timecode:", "Początkowy kod czasowy:"), + ("Start: {} Duration: {}", "Początek: {} Czas trwania: {}"), + ("Step {} of {}: {}", "Krok {} z {}: {}"), + ("Stop", "Zatrzymaj"), + ("Stop {} at {}°", "Punkt {} na {}°"), + ("Stored in: {}", "Przechowywane w: {}"), + ("Stretch", "Rozciągnij"), + ("Stretch Factor:", "Współczynnik rozciągnięcia:"), + ("Stylistic Sets", "Zestawy stylistyczne"), + ("Subpixel Positioning", "Pozycjonowanie podpikselowe"), + ("Swap Colors", "Zamień kolory"), + ("Swap Paint Colors (X)", "Zamień kolory malowania (X)"), + ("Sync with System", "Synchronizuj z systemem"), + ("System Default", "Domyślne systemowe"), + ("Table", "Tabela"), + ("Template name", "Nazwa szablonu"), + ("Template: {}\n{}", "Szablon: {}\n{}"), + ("Temporal Interpolation:", "Interpolacja czasowa:"), + ("The Media Browser is not available here (use File ▸ Import)", "Przeglądarka multimediów nie jest tu dostępna (użyj Plik ▸ Importuj)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "Warstwa OpenEXR pokazana w kanałach czerwonym, zielonym, niebieskim i alfa"), + ("The render queue is empty.", "Kolejka renderowania jest pusta."), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "Te sąsiednie klatki kluczowe nie mogą być interpolowane, bo mają różną liczbę punktów.", + ), + ("This ScriptUI panel has closed", "Ten panel ScriptUI został zamknięty"), + ("This change will create a new solid for this layer.", "Ta zmiana utworzy nową planszę dla tej warstwy."), + ("Time", "Czas"), + ("Time Stretch…", "Rozciągnięcie czasu…"), + ("Time:", "Czas:"), + ("Time: {}", "Czas: {}"), + ("Title/Action Safe", "Obszar bezpieczny tytułów/akcji"), + ("Title/action safe", "Obszar bezpieczny tytułów/akcji"), + ("Toggle Switches / Modes", "Przełącz przełączniki / tryby"), + ("Track Name:", "Nazwa ścieżki:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "Wytrenowane modele są dostępne w aplikacji desktopowej; przeglądarka używa klasycznego silnika.", + ), + ("Transform", "Przekształć"), + ("Transparency grid", "Siatka przezroczystości"), + ("Tutorial complete", "Samouczek ukończony"), + ("Two-Node Camera", "Kamera dwuwęzłowa"), + ("Type", "Typ"), + ("Type a command or effect…", "Wpisz polecenie lub efekt…"), + ("URL:", "URL:"), + ("Undock Panel", "Odepnij panel"), + ("Move Panel to New Window", "Przenieś panel do nowego okna"), + ("Move Panel to Main Window", "Przenieś panel do głównego okna"), + ("Unfreeze", "Odmroź"), + ("Unlink {}", "Odłącz {}"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "Użyj tej opcji, aby umieścić zaznaczone warstwy razem w nowej kompozycji pośredniej.", + ), + ("VR Environments", "Środowiska VR"), + ("Value:", "Wartość:"), + ("Variable Font Axes", "Osie czcionki zmiennej"), + ("Video Codec: {}", "Kodek wideo: {}"), + ("View:", "Widok:"), + ("Welcome to EffectCraft", "Witaj w EffectCraft"), + ("Width:", "Szerokość:"), + ("You can undo this.", "Można to cofnąć."), + ("You can't undo this.", "Tego nie można cofnąć."), + ("Your changes will be lost if you don't save them.", "Jeśli nie zapiszesz zmian, zostaną utracone."), + ("Zoom", "Powiększenie"), + ("built-in", "wbudowany"), + ("frames per second", "klatek na sekundę"), + ("template name", "nazwa szablonu"), + ("{} contributors · {} commits", "Współtwórcy: {} · commity: {}"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {} MB na klatkę 8 bpc"), + ("{} — coming soon", "{} — wkrótce"), + ("{} — double-click to open, {}-click to reveal", "{} — kliknij dwukrotnie, aby otworzyć, {}-klik, aby pokazać w folderze"), + ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl-przeciągnij pinezkę, aby nagrać jej ruch"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} jest używany przez:"), + ("3D Reference Axes", "Osie odniesienia 3D"), + ("Affect all layers that use this solid", "Dotyczy wszystkich warstw używających tej planszy"), + ("Choose grid and guide options", "Wybierz opcje siatki i linii pomocniczych"), + ("Colorize", "Koloryzuj"), + ("Custom", "Niestandardowe"), + ("Custom...", "Niestandardowe..."), + ("Fit", "Dopasuj"), + ("Fit up to 100%", "Dopasuj do 100%"), + ("Full", "Pełna"), + ("Half", "Połowa"), + ("Only this layer uses this solid.", "Tylko ta warstwa używa tej planszy."), + ("Proportional Grid", "Siatka proporcjonalna"), + ("Quarter", "Jedna czwarta"), + ("Region of Interest", "Obszar zainteresowania"), + ("Reset Exposure", "Resetuj ekspozycję"), + ("Show Channel and Color Management Settings", "Pokaż ustawienia kanałów i zarządzania kolorem"), + ("Show Snapshot (hold)", "Pokaż migawkę (przytrzymaj)"), + ("Take Snapshot", "Zrób migawkę"), + ("Third", "Jedna trzecia"), + ("Toggle Mask and Shape Path Visibility", "Przełącz widoczność ścieżek masek i kształtów"), + ("Toggle Transparency Grid", "Przełącz siatkę przezroczystości"), + ("16 bits per channel", "16 bitów na kanał"), + ("32 bits per channel (float)", "32 bity na kanał (zmiennoprzecinkowe)"), + ("8 bits per channel", "8 bitów na kanał"), + ("Adaptive Resolution", "Rozdzielczość adaptacyjna"), + ("Adobe-style built-in", "Wbudowany w stylu Adobe"), + ("Blue", "Niebieski"), + ("Current Settings", "Bieżące ustawienia"), + ("Disable Per-character 3D", "Wyłącz 3D dla każdego znaku"), + ("Draft", "Robocza"), + ("Enable Per-character 3D", "Włącz 3D dla każdego znaku"), + ("Fast Draft", "Szybka robocza"), + ("Fast Previews (Draft 3D, Faster Effects)", "Szybkie podglądy (robocze 3D, szybsze efekty)"), + ("Green", "Zielony"), + ("Import Image Sequence", "Importuj sekwencję obrazów"), + ("Import PDF / Illustrator File", "Importuj plik PDF / Illustrator"), + ("Import Photoshop File", "Importuj plik Photoshopa"), + ("Interpret Unlabeled Alpha As", "Interpretuj nieoznaczoną alfę jako"), + ("Join the ArtCraft Discord", "Dołącz do Discorda ArtCraft"), + ("Mercury GPU Acceleration ({})", "Akceleracja Mercury GPU ({})"), + ("Mercury Software Only", "Tylko programowy Mercury"), + ("OCIO (built-in ACES config)", "OCIO (wbudowana konfiguracja ACES)"), + ("Off (Final Quality)", "Wył. (jakość końcowa)"), + ("OpenGL (compatibility)", "OpenGL (zgodność)"), + ("Opus Bitrate: {} kbps", "Przepływność Opus: {} kb/s"), + ("RGB Straight", "RGB proste"), + ("Red", "Czerwony"), + ("Tone map", "Mapowanie tonów"), + ("Use In-Window Menu Bar on macOS", "Używaj paska menu w oknie w macOS"), + ("Wireframe", "Szkielet"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (domyślny)"), + ("New Project", "Nowy projekt"), + ("Open Project…", "Otwórz projekt…"), + ("Templates", "Szablony"), + ("EffectCraft home page", "Strona główna EffectCraft"), + ("EffectCraft on GitHub", "EffectCraft w serwisie GitHub"), + ("Motion graphics and visual effects · version {}", "Grafika ruchoma i efekty wizualne · wersja {}"), + ("New Composition", "Nowa kompozycja"), + ("Open Demo Project", "Otwórz projekt demonstracyjny"), + ("Import Footage…", "Importuj materiał…"), + ("Filter recent files", "Filtruj ostatnie pliki"), + ("NAME", "NAZWA"), + ("OPENED", "OTWARTY"), + ("SIZE", "ROZMIAR"), + ("KIND", "RODZAJ"), + ("Your recent files will show here", "Tu pojawią się ostatnie pliki"), + ("Create a new project, or open the demo project to look around.", "Utwórz nowy projekt albo otwórz projekt demonstracyjny, aby się rozejrzeć."), + ("No recent files match the filter", "Żaden ostatni plik nie pasuje do filtra"), + ("Try another name or folder.", "Spróbuj innej nazwy lub folderu."), + ("Just now", "Przed chwilą"), + ("Yesterday", "Wczoraj"), + ("{} minute ago", "{} minutę temu"), + ("{} minutes ago", "{} min temu"), + ("{} hour ago", "{} godzinę temu"), + ("{} hours ago", "{} godz. temu"), + ("{} day ago", "{} dzień temu"), + ("{} days ago", "{} dni temu"), + ("EffectCraft project", "Projekt EffectCraft"), + ("After Effects project", "Projekt After Effects"), + ("Lottie animation", "Animacja Lottie"), + ("Project", "Projekt"), + ("General", "Ogólne"), + ("Language", "Język"), + ("Levels of Undo", "Poziomy cofania"), + ("Path Point and Handle Size", "Rozmiar punktów i uchwytów ścieżki"), + ("Recent Projects Shown", "Liczba wyświetlanych ostatnich projektów"), + ("Options", "Opcje"), + ("Show Tool Tips", "Pokazuj podpowiedzi"), + ("Create Layers at Composition Start Time", "Twórz warstwy w czasie początku kompozycji"), + ("Switches Affect Nested Comps", "Przełączniki wpływają na zagnieżdżone kompozycje"), + ("Default Spatial Interpolation to Linear", "Domyślna interpolacja przestrzenna liniowa"), + ("Preserve Constant Vertex and Feather Point Count when Editing Masks", "Zachowaj stałą liczbę wierzchołków i punktów wtapiania podczas edycji masek"), + ("Synchronize Time of All Related Items", "Synchronizuj czas wszystkich powiązanych elementów"), + ("Expression Pick Whip Writes Compact English", "Łącznik wyrażeń zapisuje zwięzły angielski"), + ("Create Split Layers Above Original Layer", "Twórz podzielone warstwy nad oryginalną warstwą"), + ("Use System Color Picker", "Używaj systemowego próbnika kolorów"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraft zawsze używa własnego próbnika kolorów (takiego samego na każdej platformie i w przeglądarce).", + ), + ("Startup & Repair", "Uruchamianie i naprawa"), + ("Show Home Screen When Launching", "Pokazuj ekran startowy przy uruchamianiu"), + ("Show Home Screen When Opening a Project", "Pokazuj ekran startowy przy otwieraniu projektu"), + ("Offer to Open the Latest Auto-Save After a Crash", "Proponuj otwarcie ostatniego autozapisu po awarii"), + ("Window Graphics", "Grafika okna"), + ("Automatic", "Automatycznie"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "Grafika okna obowiązuje od następnego uruchomienia. Gdy sterownik graficzny zatrzyma EffectCraft przy otwieraniu okna, następne uruchomienie przełączy się na OpenGL.", + ), + ("Repair", "Naprawa"), + ("Reset Settings", "Resetuj ustawienia"), + ("Reset Keyboard Shortcuts", "Resetuj skróty klawiaturowe"), + ("Empty Disk Cache", "Opróżnij pamięć dyskową"), + ("New Project Loads Template", "Nowy projekt wczytuje szablon"), + ("Template Project", "Projekt szablonu"), + ("Auto-Save", "Autozapis"), + ("Automatically Save Projects", "Automatycznie zapisuj projekty"), + ("Save Every", "Zapisuj co"), + ("Minutes", "min"), + ("Maximum Project Versions", "Maksymalna liczba wersji projektu"), + ("Auto-Save Location", "Lokalizacja autozapisu"), + ("Next to Project", "Obok projektu"), + ("Custom Location", "Lokalizacja niestandardowa"), + ("Save When Starting Render Queue", "Zapisuj przy uruchamianiu kolejki renderowania"), + ("Save Now", "Zapisz teraz"), + ("Motion Path", "Ścieżka ruchu"), + ("All Keyframes", "Wszystkie klatki kluczowe"), + ("No Motion Path", "Bez ścieżki ruchu"), + ("No More Than (Seconds)", "Nie więcej niż (sekundy)"), + ("No More Than (Keyframes)", "Nie więcej niż (klatki kluczowe)"), + ("Seconds", "s"), + ("Keyframes", "Klatki kluczowe"), + ("Display", "Wyświetlanie"), + ("Show Rendering Progress in Info Panel and Flowchart", "Pokazuj postęp renderowania w panelu Informacje i schemacie"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "Akceleracja sprzętowa paneli Kompozycja, Warstwa i Materiał"), + ("Panels are always drawn by the GPU (wgpu).", "Panele są zawsze rysowane przez GPU (wgpu)."), + ("Previews", "Podglądy"), + ("Adaptive Resolution Limit", "Limit rozdzielczości adaptacyjnej"), + ("Show Internal Wireframes", "Pokazuj wewnętrzne szkielety"), + ("Cache Frames When Idle", "Buforuj klatki w bezczynności"), + ("Viewer Zoom Quality", "Jakość powiększenia przeglądarki"), + ("Faster", "Szybciej"), + ("More Accurate", "Dokładniej"), + ("Display Color Space", "Przestrzeń kolorów wyświetlacza"), + ("GPU Information...", "Informacje o GPU..."), + ("Appearance", "Wygląd"), + ("Brightness", "Jasność"), + ("UI Scale", "Skala interfejsu"), + ("Labels and Colors", "Etykiety i kolory"), + ("Use Label Color for Layer Handles and Paths", "Używaj koloru etykiety dla uchwytów i ścieżek warstw"), + ("Use Label Color for Related Tabs", "Używaj koloru etykiety dla powiązanych kart"), + ("Cycle Mask Colors", "Zmieniaj kolejno kolory masek"), + ("Use Gradients", "Używaj gradientów"), + ("Menu Bar", "Pasek menu"), + ("Grids & Guides", "Siatki i linie pomocnicze"), + ("Gridline Every", "Linia siatki co"), + ("Subdivisions", "Podpodziały"), + ("Style", "Styl"), + ("Lines", "Linie"), + ("Dashed Lines", "Linie przerywane"), + ("Dots", "Kropki"), + ("Horizontal", "Poziomo"), + ("Vertical", "Pionowo"), + ("Safe Margins", "Marginesy bezpieczne"), + ("Action-safe", "Bezpieczny dla akcji"), + ("Title-safe", "Bezpieczny dla tytułów"), + ("Labels", "Etykiety"), + ("Text Engine", "Silnik tekstu"), + ("Latin", "Łaciński"), + ("South Asian and Middle Eastern", "Południowoazjatycki i bliskowschodni"), + ("Show Font Preview", "Pokazuj podgląd czcionki"), + ("Number of Recent Fonts to Display", "Liczba wyświetlanych ostatnich czcionek"), + ("Show Font Names in English", "Pokazuj nazwy czcionek po angielsku"), + ("Still Footage", "Materiał nieruchomy"), + ("Length of Composition", "Długość kompozycji"), + ("Duration (Seconds)", "Czas trwania (sekundy)"), + ("Still Duration", "Czas trwania obrazu"), + ("Sequence Footage", "Materiał sekwencyjny"), + ("Report Missing Frames", "Zgłaszaj brakujące klatki"), + ("Automatically Reload Footage", "Automatycznie wczytuj ponownie materiał"), + ("Off", "Wył."), + ("Non-Sequence Footage", "Materiał niesekwencyjny"), + ("All Footage", "Wszystkie materiały"), + ("Ask User", "Pytaj użytkownika"), + ("Guess", "Zgadnij"), + ("Ignore", "Ignoruj"), + ("Straight", "Prosta"), + ("Premultiplied", "Wstępnie mnożona"), + ("Default Drag Import As", "Domyślny import przeciągnięciem jako"), + ("Footage", "Materiał"), + ("Composition - Retain Layer Sizes", "Kompozycja – zachowaj rozmiary warstw"), + ("Export", "Eksport"), + ("Default Output Folder", "Domyślny folder wyjściowy"), + ("Segment Sequences", "Dziel sekwencje na segmenty"), + ("Files per Segment", "Pliki na segment"), + ("files", "pliki"), + ("Segment Movie Files", "Dziel pliki filmowe na segmenty"), + ("Segment Size", "Rozmiar segmentu"), + ("Append Bit Depth to File Name", "Dodawaj głębię bitową do nazwy pliku"), + ("Audio", "Audio"), + ("Audio Hardware", "Sprzęt audio"), + ("Preview Sample Rate", "Częstotliwość próbkowania podglądu"), + ("Audio Output Mapping", "Mapowanie wyjść audio"), + ("Left", "Lewy"), + ("Right", "Prawy"), + ("channel", "kanał"), + ("Disk", "Dysk"), + ("Disk Cache", "Pamięć dyskowa"), + ("Enable Disk Cache", "Włącz pamięć dyskową"), + ("Maximum Disk Cache Size", "Maksymalny rozmiar pamięci dyskowej"), + ("Disk Cache Folder", "Folder pamięci dyskowej"), + ("Media Cache", "Pamięć multimediów"), + ("Database and Cache Folder", "Folder bazy danych i pamięci"), + ("Conformed Audio Folder", "Folder dopasowanego audio"), + ("Memory & CPU", "Pamięć i procesor"), + ("RAM Reserved for Other Applications", "Pamięć RAM zarezerwowana dla innych aplikacji"), + ("Cache Budgets", "Budżety pamięci podręcznej"), + ("Layer Cache", "Pamięć warstw"), + ("Footage Frame Cache", "Pamięć klatek materiałów"), + ("Preview (RAM) Cache", "Pamięć podglądu (RAM)"), + ("Reduce Cache Size When System Is Low on Memory", "Zmniejsz pamięć podręczną, gdy w systemie brakuje pamięci"), + ("Video", "Wideo"), + ("Enable Video Preview Output", "Włącz wyjście podglądu wideo"), + ("Video Device", "Urządzenie wideo"), + ("Floating Window", "Okno pływające"), + ("Full Screen", "Pełny ekran"), + ("Video Output During Playback", "Wyjście wideo podczas odtwarzania"), + ("Mirror on Computer Monitor", "Powielaj na monitorze komputera"), + ("Disable Video Output When in Background", "Wyłącz wyjście wideo w tle"), + ("Default 3D Renderer", "Domyślny renderer 3D"), + ("Show 3D Reference Axes", "Pokazuj osie odniesienia 3D"), + ("Extended Viewer", "Rozszerzona przeglądarka"), + ("Realtime Shadows in Draft", "Cienie w czasie rzeczywistym w trybie roboczym"), + ("Scripting & Expressions", "Skrypty i wyrażenia"), + ("Application Scripting", "Skrypty aplikacji"), + ("Allow Scripts to Write Files and Access Network", "Zezwalaj skryptom na zapis plików i dostęp do sieci"), + ("Warn User When Executing Files", "Ostrzegaj przy uruchamianiu plików"), + ("Enable JavaScript Debugger", "Włącz debuger JavaScript"), + ("Expressions Editor", "Edytor wyrażeń"), + ("Font Size", "Rozmiar czcionki"), + ("Syntax Highlighting", "Podświetlanie składni"), + ("Line Numbers", "Numery wierszy"), + ("Auto-complete", "Autouzupełnianie"), + ("Bracket Matching", "Dopasowywanie nawiasów"), + ("Word Wrap", "Zawijanie wierszy"), + ("Show Expression Error Banner", "Pokazuj pasek błędów wyrażeń"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "Wyrażenia używają silnika JavaScript. Błędy skryptów podają plik i wiersz w konsoli skryptów; nie ma debugera krokowego.", + ), + ("Roto Brush", "Pędzel Roto"), + ("Segmentation Model", "Model segmentacji"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "Pędzel Roto 2.0 i 3.0 używa wybranego tu modelu (wersja 1.0 zawsze używa klasycznego silnika). Modele są open source, pobierane tylko na Twoje żądanie i sprawdzane z opublikowanym SHA-256.", + ), + ("Face Tracking", "Śledzenie twarzy"), + ("Face Model", "Model twarzy"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "Śledzenie twarzy w panelu Śledzenie (tylko kontur i szczegółowe cechy) używa wybranego tu modelu. Modele są open source, pobierane tylko na Twoje żądanie i sprawdzane z opublikowanym SHA-256.", + ), + ("Match System", "Zgodnie z systemem"), + ("Default", "Domyślny"), + ("Stored, but not used by EffectCraft yet", "Zapisane, ale jeszcze nieużywane przez EffectCraft"), + ("Total Offset", "Całkowite przesunięcie"), + ("Per Layer", "Na warstwę"), + ("Track", "Ścieżka"), + ("Copy", "Kopiuj"), + ("Paste", "Wklej"), + ("Edit Value…", "Edytuj wartość…"), + ("Remove Expression", "Usuń wyrażenie"), + ("Effects", "Efekty"), + ("Copy Source Path", "Kopiuj ścieżkę źródła"), + ("Resolution:", "Rozdzielczość:"), + ("Guide Layer", "Warstwa pomocnicza"), + ("Layer Styles", "Style warstwy"), +]; + /// Czech (cs): original translations covering every row in the Chinese panel catalogs. const CZECH: &[(&str, &str)] = &[ (" OK ", " OK "), diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index fac8f0d53..dff9aa0ce 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -227,6 +227,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { "Serviços" } else if crate::i18n::czech(app) { "Služby" + } else if crate::i18n::polish(app) { + "Usługi" } else if crate::i18n::georgian(app) { "სერვისები" } else { @@ -562,6 +564,27 @@ mod tests { assert_eq!(translated, ["Služby", "Skrýt EffectCraft", "Skrýt ostatní", "Zobrazit vše"]); } + #[test] + fn polish_translates_macos_predefined_items() { + if !cfg!(target_os = "macos") { + return; + } + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "pl"})).unwrap(); + fn labels(nodes: &[NativeNode], out: &mut Vec) { + for node in nodes { + match node { + NativeNode::Predefined { label, .. } => out.push(label.clone()), + NativeNode::Submenu { children, .. } => labels(children, out), + _ => {} + } + } + } + let mut translated = vec![]; + labels(&build(&app).menus, &mut translated); + assert_eq!(translated, ["Usługi", "Ukryj EffectCraft", "Ukryj pozostałe", "Pokaż wszystko"]); + } + #[test] fn georgian_translates_macos_predefined_items() { if !cfg!(target_os = "macos") { diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d642af43c..53bb79731 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** minor (Georgian: menus, panels and effect names; missing strings show in English; `missing_translations_report` lists them) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** minor (Polish: menus and panels) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -83,12 +83,13 @@ the true percentages are slightly lower. | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | -Other languages EffectCraft ships (4): +Other languages EffectCraft ships (5): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| | Traditional Chinese | `zh-hant` | 1,130 (≈ 29%): menus 624 rows, panels 506 | partial | 6–9 h + review | | Ukrainian | `uk` | 1,129 (≈ 29%): menus 624 rows, panels 505 | partial | 6–9 h + review | +| Polish | `pl` | 1,325 (≈ 34%): menus 630 rows, panels 695 | partial; unwrapped UI, effects and engine text still in English; AI-drafted, native review welcome | Remaining strings + native review | | Czech | `cs` | 1,125 (≈ 29%): menus 624 rows, panels 501 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | | Georgian | `ka` | 1,514 (≈ 39%): menus 628 rows, panels 886 (the Chinese catalogs' 695 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | @@ -113,6 +114,7 @@ can give; only Georgian, contributed by a native speaker (#1843), has had it. |---|---|---| | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-14 | minor | Georgian (`ka`): menus 628, panels 886 (191 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` | +| 2026-10-29 | minor | Polish (`pl`): menus 630, panels 695 | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | | 2026-10-13 | minor | Effect names and the generated Effect menu leave English: `i18n/effects.rs` adds 306 rows keyed by effect id (Simplified Chinese, from After Effects 2026's interface and Adobe's Chinese documentation for the `CC` series) and the 21 category names. Parameter names are still English; no other language has rows | From 0ffeffba74d7ca619ad93d35f95abfe89094f3dc Mon Sep 17 00:00:00 2001 From: Tomasz Lewandowski Date: Thu, 29 Oct 2026 07:16:11 +0100 Subject: [PATCH 061/112] contributors: add Tomasz Lewandowski Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DukHNvUY45rjRnvtz91x33 --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 1012a37bd..ccb315dac 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -22,6 +22,11 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false +[people.lewandowskit] +real_name = "Tomasz Lewandowski" +display_name = "Tomasz Lewandowski" +sync_github_name = false + [people.switch87] real_name = "Gert Pellin" display_name = "Gert Pellin" From 559878a1665d1c910c42bbf6f67a2b1b8c0ed436 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 23:28:59 -0700 Subject: [PATCH 062/112] 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 063/112] 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 064/112] 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 6969bd4da8b6b040410c543d9c0fce29801b76f5 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Thu, 29 Oct 2026 10:11:06 +0000 Subject: [PATCH 065/112] Review fixes: pass &env to add_environment, extract env_shadow_map, drop the repro test (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add_environment takes the evaluated light (&EnvLight) instead of nine loose parameters — strict clippy (#2496) denies too_many_arguments. - The Environment light's shadow map moves into env_shadow_map so shadow_maps stays under the cognitive-complexity limit. - tests/it/ui_env_light_repro.rs deleted: it duplicated ui_env_light_gpu and the engine tests, and polled with sleeps of up to 12 s. Signed-off-by: Minher0 --- crates/render/src/three_d/adv/raster.rs | 37 +++++---- crates/render/src/three_d/adv/scene.rs | 34 +++------ crates/ui-egui/tests/it/main.rs | 1 - crates/ui-egui/tests/it/ui_env_light_repro.rs | 75 ------------------- 4 files changed, 33 insertions(+), 114 deletions(-) delete mode 100644 crates/ui-egui/tests/it/ui_env_light_repro.rs diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 91bf45307..1c37a6267 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -13,7 +13,7 @@ use std::collections::BinaryHeap; use rayon::prelude::*; -use super::scene::{Scene, ShadowMap, Vertex}; +use super::scene::{EnvInfo, Scene, ShadowMap, Vertex}; use super::shade::{Frag, sample, shade}; /// A rendered target at raster size. @@ -500,6 +500,26 @@ pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32]) sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() } +/// The Environment light's shadow map: one orthographic map over the scene's bounding sphere +/// from its dominant direction (the light travels from the bright region into the scene), like +/// a parallel light's. +fn env_shadow_map(e: EnvInfo, centre: [f64; 3], radius: f64, size: u32) -> ShadowMap { + // After Effects' environment-light shadows are soft out of the box, and this + // map covers the whole scene bounding sphere — not the view — so its texels + // can be coarse (all the more in a draft render's flat 512 budget). A + // penumbra of at least eight output pixels, or four map texels where those + // are coarser, keeps the edge smooth at any map resolution; Shadow Diffusion + // widens it further, as for the other lights. + let texel = 2.0 * radius / size as f64; + let soft = (4.0 * texel).max(8.0) + (e.shadow_diffusion as f64 * 0.5).min(24.0); + let sun = e.sun.map(|v| v as f64); + let dir = [-sun[0], -sun[1], -sun[2]]; + let eye = [centre[0] + sun[0] * radius * 2.0, centre[1] + sun[1] * radius * 2.0, centre[2] + sun[2] * radius * 2.0]; + let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }; + let focal = (size as f64 * 0.5 / radius) as f32; + ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft as f32 } +} + /// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`), and of /// the Environment light when it casts shadows and its image has a dominant direction (marked /// `env.shadow == 0`, `env.sun != [0; 3]`): one map from that direction, like a parallel light. @@ -599,20 +619,7 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { match sizes.get(s.lights.len()).copied().flatten() { Some(size) => { let first = maps.len() as i32; - // After Effects' environment-light shadows are soft out of the box, and this - // map covers the whole scene bounding sphere — not the view — so its texels - // can be coarse (all the more in a draft render's flat 512 budget). A - // penumbra of at least eight output pixels, or four map texels where those - // are coarser, keeps the edge smooth at any map resolution; Shadow Diffusion - // widens it further, as for the other lights. - let texel = 2.0 * radius / size as f64; - let soft = (4.0 * texel).max(8.0) + (e.shadow_diffusion as f64 * 0.5).min(24.0); - let sun = e.sun.map(|v| v as f64); - let dir = [-sun[0], -sun[1], -sun[2]]; - let eye = [centre[0] + sun[0] * radius * 2.0, centre[1] + sun[1] * radius * 2.0, centre[2] + sun[2] * radius * 2.0]; - let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }; - let focal = (size as f64 * 0.5 / radius) as f32; - maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft as f32 }); + maps.push(env_shadow_map(e, centre, radius, size)); if let Some(e) = &mut s.env { e.shadow = first; } diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index dfd35b048..1197e5916 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -691,7 +691,7 @@ fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { /// The evaluated Environment light: its image source, intensity × colour, rotation, and /// shadow settings (After Effects' Light Settings shows Cast Shadows for Environment lights). #[derive(Clone, Copy)] -struct EnvLight { +pub(crate) struct EnvLight { src: Option, color: [f32; 3], rot: f32, @@ -856,27 +856,17 @@ pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], tint: [f32; 3], } /// Prefilter an environment image into the scene's textures. -pub(crate) fn add_environment( - b: &mut Builder, - w: u32, - h: u32, - data: Vec<[f32; 4]>, - intensity: [f32; 3], - rotation: f32, - casts_shadows: bool, - shadow_darkness: f64, - shadow_diffusion: f64, -) { +pub(crate) fn add_environment(b: &mut Builder, env: &EnvLight, w: u32, h: u32, data: Vec<[f32; 4]>) { // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels. // A white (or off) light leaves them as they are. - let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0; - let tint = if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) { intensity.map(|c| c / k) } else { [1.0, 1.0, 1.0] }; + let k = (env.color[0] + env.color[1] + env.color[2]) / 3.0; + let tint = if k > 0.0 && (env.color[0] != env.color[1] || env.color[1] != env.color[2]) { env.color.map(|c| c / k) } else { [1.0, 1.0, 1.0] }; // The shadow direction and key strength from the full-resolution image, before the mip // chain averages it — only a shadow-casting light reads them, so only one pays for the // pass — and weighted by the tint, so the shadow follows the radiance the light emits: // opposing chromatic regions that cancel untinted need not cancel once the colour removes // one of them (a red light over equally bright red and blue sides shadows from the red). - let (sun, key) = if casts_shadows { dominant_direction(w, h, &data, tint, rotation) } else { ([0.0; 3], 0.0) }; + let (sun, key) = if env.casts_shadows { dominant_direction(w, h, &data, tint, env.rot) } else { ([0.0; 3], 0.0) }; // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { @@ -914,13 +904,13 @@ pub(crate) fn add_environment( mips: levels, irradiance: ii, intensity: k, - rotation, + rotation: env.rot, sun, key, // Marked; `raster::shadow_maps` assigns the map (a directionless image casts none). - shadow: if casts_shadows { 0 } else { -1 }, - shadow_darkness: shadow_darkness as f32, - shadow_diffusion: shadow_diffusion as f32, + shadow: if env.casts_shadows { 0 } else { -1 }, + shadow_darkness: env.shadow_darkness as f32, + shadow_diffusion: env.shadow_diffusion as f32, }); } @@ -1091,16 +1081,14 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: b.s.ambient = amb; if let Some(env) = env { match env_pixels(r, ctx, env.src, lin) { - EnvPixels::Image((w, h, d)) => { - add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion) - } + EnvPixels::Image((w, h, d)) => add_environment(&mut b, &env, w, h, d), // No Source and no Environment Layer: the default environment, a soft studio // gradient of the light's colour × intensity — After Effects "uses a default // environment map" for an Environment light — so the light always lights the // scene per its parameters, and its dominant region casts shadows (#2436). EnvPixels::Absent => { let (w, h, d) = default_environment(); - add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion) + add_environment(&mut b, &env, w, h, d) } // A selected or found source that produced no pixels (deleted layer, missing // footage, failed decode): no environment at all — the light contributes diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 724b64b08..a99094baf 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -21,7 +21,6 @@ mod ui_effect_controls; mod ui_effect_drop_highlight; mod ui_effect_names; mod ui_env_light_gpu; -mod ui_env_light_repro; mod ui_font_search; mod ui_form_fit; mod ui_frame_step; diff --git a/crates/ui-egui/tests/it/ui_env_light_repro.rs b/crates/ui-egui/tests/it/ui_env_light_repro.rs deleted file mode 100644 index 78a26160f..000000000 --- a/crates/ui-egui/tests/it/ui_env_light_repro.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Reproduction of issue #2436's report: an Environment light added through the real command -//! path (what the Light Settings dialog runs) must visibly light the scene in the viewer. - -use effectcraft_engine::Session; -use effectcraft_engine::project::Renderer; -use effectcraft_ui_egui::EffectcraftApp; -use egui_kittest::Harness; -use serde_json::json; - -fn app() -> EffectcraftApp { - let mut s = Session::default(); - s.execute("file.openDemoProject", json!({})).unwrap(); - s.execute("comp.open", json!({"comp": "3D Showcase"})).unwrap(); - s.execute("time.set", json!({"time": 5.0})).unwrap(); - EffectcraftApp::new(s) -} - -/// Step the UI until background frame renders have landed. -fn settle(h: &mut Harness<'_, EffectcraftApp>) { - for _ in 0..600 { - h.step(); - if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { - break; - } - std::thread::sleep(std::time::Duration::from_millis(20)); - } - for _ in 0..4 { - h.step(); - } -} - -fn changed_pixels(before: &[egui::Color32], after: &[egui::Color32]) -> usize { - before.iter().zip(after).filter(|(a, b)| a != b).count() -} - -/// An Environment light (red, full intensity, no source: the flat default environment) added to -/// a Classic 3D comp switches it to Advanced 3D and visibly changes the viewer's pixels. -#[test] -fn environment_light_lights_the_scene() { - let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); - h.state_mut().session.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap(); - h.run_steps(3); - settle(&mut h); - let before = h.state_mut().viewer_pixels().expect("frame before adding the light").pixels.clone(); - - h.state_mut().session.execute("layer.newLight", json!({"kind": "Environment", "color": [1.0, 0.0, 0.0], "intensity": 100.0})).unwrap(); - - assert_eq!(h.state().session.active_comp().unwrap().renderer, Renderer::Advanced3D, "the comp switched to Advanced 3D"); - settle(&mut h); - let after = h.state_mut().viewer_pixels().expect("frame after adding the light").pixels.clone(); - assert_eq!(before.len(), after.len()); - let diff = changed_pixels(&before, &after); - assert!(diff > 500, "a red Environment light at 100 % changed only {diff} pixels"); -} - -/// The literal action from the issue: default white Environment light, defaults everywhere — -/// even then the lighting model changes (Advanced 3D IBL instead of unlit/Classic), so pixels -/// must change somewhere in the comp frame. -#[test] -fn default_environment_light_changes_the_scene() { - let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); - h.state_mut().session.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap(); - h.run_steps(3); - settle(&mut h); - let before = h.state_mut().viewer_pixels().expect("frame before adding the light").pixels.clone(); - - h.state_mut().session.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); - - assert_eq!(h.state().session.active_comp().unwrap().renderer, Renderer::Advanced3D); - settle(&mut h); - let after = h.state_mut().viewer_pixels().expect("frame after adding the light").pixels.clone(); - assert_eq!(before.len(), after.len()); - let diff = changed_pixels(&before, &after); - assert!(diff > 500, "a default white Environment light changed only {diff} pixels"); -} From 383ff2d487ffc876296d14c53d2c99c13d1f293c Mon Sep 17 00:00:00 2001 From: FeiLiuEM Date: Thu, 15 Oct 2026 23:13:12 +0800 Subject: [PATCH 066/112] i18n: the panel tabs, and the labels the call-shape scan cannot see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two English surfaces the translation work so far could not reach. The dock's tab titles: `PanelKind::title()` is drawn by `dock_ui.rs`'s `tab_titles`, which listed only the tabs that name a comp, a layer or a script and let every other one fall back to `title()`'s English - so every panel tab (`Timeline`, `Preview`, `Properties`, `Effects & Presets`, ...) stayed English. The Window menu already carries a (`window.panel`, title) row in every catalog, so the dock reads that pair and falls back to the panel catalog for `Markers` (the one panel only it names); `History` and `ScriptUI Panel` have no row anywhere and keep their English names. `app.auto`'s labels and the dock's serialized state keep the English title, which the control channel needs. The scan's blind shapes: `converted_panels_have_no_unwrapped_literals` only sees a literal that follows a call token, so a label passed to a helper, the third argument of `Painter::text`, a `(key, label, ...)` table, a `let` binding and a `match` arm were all invisible. The two that can be checked without guessing are added (`helper_and_painter_labels_are_translated`), with the same single-character exemption the call scan makes, and the 25 sites they find now ask the catalog (`Apply`, `Analyze`, `Cancel`, `Create Camera`, `Add Point`, `Remove Point`, `Clear`, `Render`, `Stop`, `Save Current as Template...`, `New Composition From Footage`, the three search fields, `(no composition)`, `per second`, `Waveform`, `steps`), with 15 rows added to the Simplified and Traditional catalogs in the conventions those tables already use (Render 算圖, Apply 套用, Add Point 添加點). The `uic!` labels and the Keyboard Shortcuts dialog are a different shape and are still to decide (#1451). Verified on Linux: cargo fmt --all --check, cargo test -p effectcraft-ui-egui (every suite passes), cargo clippy -p effectcraft-ui-egui --all-targets -- -D warnings clean. Reverting the dock's catalog lookup fails `the_dock_draws_every_panel_it_can_in_the_interface_language`; unwrapping a helper or a painter label fails `helper_and_painter_labels_are_translated` ("panels/project.rs:434: the interface text \"Search project\" that widgets::search_field draws is not in the catalog"). Also answers #1451's question about languages whose rows have no Simplified row to be a subset of: six labels the Composition Settings dialog draws from a `(label, value)` table, a tab array and a `match` arm (`Square Pixels`, `Basic`, `Advanced`, `3D Renderer`, `Black`, `Composition Settings`, plus `White` from the same `color_name`) had no row anywhere, so every interface drew them in English; they have Simplified and Traditional rows now, in the existing menu wording. The seventh, `Lock Aspect Ratio`, is only an automation name - the drawn label is `Lock Aspect Ratio to {}`, which already had rows, and `comp_settings.rs` built it with `format!` where `dialogs.rs` already used `tr_args`, so that one is wired now too. Review fixes (CodeRabbit): the scan now runs over the whole file with line comments blanked, so a `.text(` call split across lines is still one argument list - which found five more labels the per-line loop could not see (`No effects. Drag one from Effects & Presets.`, the colour-ring hint in `fx_editors.rs`, the Learn blurb, the Script Console hint, the Templates blurb, plus `Building waveform…` from the review), all wired with rows. And the New Composition title is back to the English source: `modal_fit` keys the modal id and the drag offset on the title (its own comment says the id stays on the untranslated source) and translates the heading itself, so `tr(..)` there would have moved the dialog when the interface language changed. And the argument list is bounded by the call's closing parenthesis: `args_of` used to flush a trailing argument at end-of-string, so a last argument without a trailing comma carried the `)` and the source after it, which made the check skip it as "not a literal". Reverting that one line is a two-line test (a literal last argument in `header.rs` is caught now and was silent before). The label scan also understands the two string forms a label can be written in: a plain string ends at the first unescaped quote, a raw string (`r"…"`, `r#"…"#`) at a quote followed by as many `#` as opened it, and `literal_text` decodes the argument before the exemptions run - so an escaped or raw label is checked like any other instead of being skipped. Two more scan fixes from review: `without_comments` tracks the escape state rather than the previous character, so a string ending in a backslash (`"a\\"`) closes where it should; and the scanned range ends at the test *module* - the first `#[cfg(test)]` followed by a `mod` - rather than at the first `#[cfg(test)]`, because `dialogs.rs` keeps a test-only helper above its test module and production code between them was not scanned. And call tokens are looked for in a code-only mask: comments blanked, the contents of plain and raw strings blanked too, with the quotes kept and every byte offset preserved (a blanked character becomes as many spaces as its UTF-8 length, and a debug assertion pins the mask length). The argument list is read from the original source at that offset, so a sample string that quotes a call cannot be mistaken for one. Building the mask from chars instead of bytes shifted every offset after the first non-ASCII character, which the debug assertion now catches. The dock's display path has its own test now: `tab_titles` is called with `general.language` set to Chinese and the labels a window shows are asserted (`Timeline` 时间轴 ...), plus `History` keeping its English tab and the English interface keeping all of them. Reverting the dock to `PanelKind::title()` fails it with "Timeline is not drawn in Chinese", which the catalog-only test could not see. --- crates/ui-egui/src/dock_ui.rs | 6 +- crates/ui-egui/src/i18n.rs | 29 ++ crates/ui-egui/src/i18n/ui.rs | 339 ++++++++++++++++++ crates/ui-egui/src/panels.rs | 2 +- crates/ui-egui/src/panels/anim_tools.rs | 8 +- .../ui-egui/src/panels/camera_tracker_ui.rs | 6 +- crates/ui-egui/src/panels/comp_settings.rs | 10 +- crates/ui-egui/src/panels/dialogs.rs | 2 + .../src/panels/effect_controls/editors.rs | 4 +- .../src/panels/effect_controls/panel.rs | 2 +- crates/ui-egui/src/panels/effects_presets.rs | 2 +- crates/ui-egui/src/panels/flowchart.rs | 2 +- crates/ui-egui/src/panels/fx_editors.rs | 8 +- crates/ui-egui/src/panels/learn.rs | 4 +- crates/ui-egui/src/panels/misc.rs | 3 +- crates/ui-egui/src/panels/render_queue.rs | 4 +- crates/ui-egui/src/panels/script_console.rs | 2 +- crates/ui-egui/src/panels/templates.rs | 4 +- crates/ui-egui/src/panels/timeline.rs | 8 +- crates/ui-egui/src/panels/viewer/frame.rs | 4 +- 20 files changed, 412 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 30710392a..09cc045ac 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -791,7 +791,11 @@ impl DockContent for EffectContent<'_> { } fn logical_label(&self, panel: &PanelKind) -> String { - crate::i18n::command_label("window.panel", panel.title()).to_string() + let title = panel.title(); + // The Window menu names most panels; `Markers` is only in the panel catalog, and the two + // panels in neither (`History`, `ScriptUI Panel`) keep their English names. + let listed = crate::i18n::command_label("window.panel", title); + if listed == title { crate::i18n::tr(title).to_string() } else { listed.to_string() } } /// The tab names what the panel shows ("Composition Intro"), after the label swatch of its diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 611a226ee..b82a3d15a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -7620,6 +7620,35 @@ mod tests { } } + /// The dock's tab titles are `PanelKind::title()`; the panels the Window menu lists are drawn in + /// the interface language through their `(window.panel, title)` rows. `History` and `ScriptUI + /// Panel` are in no menu and keep their English names, which is what this pins. + #[test] + fn the_dock_draws_every_panel_it_can_in_the_interface_language() { + use crate::dock::PanelKind; + const WITHOUT_A_ROW: &[&str] = &["History", "ScriptUI Panel"]; + for (name, catalog, code) in [("ja", JAPANESE, "ja"), ("zh-hans", SIMPLIFIED_CHINESE, "zh-hans"), ("zh-hant", TRADITIONAL_CHINESE, "zh-hant")] { + // The dock asks both catalogs, so a title may be named by either. + ui::set_current(code); + for p in PanelKind::ALL { + let title = p.title(); + let in_menu = catalog.iter().any(|(c, en, _)| *c == "window.panel" && *en == title); + let in_panel = ui::tr(title) != title; + assert_eq!( + in_menu || in_panel, + !WITHOUT_A_ROW.contains(&title), + "{name}: {title} is {}named by a catalog and {}in the exception list", + if in_menu || in_panel { "" } else { "not " }, + if WITHOUT_A_ROW.contains(&title) { "" } else { "not " } + ); + } + } + ui::set_current("en"); + // The dock asks the catalog rather than drawing the English name. + let dock_ui = include_str!("dock_ui.rs"); + assert!(dock_ui.contains(r#"crate::i18n::command_label("window.panel", title)"#), "dock_ui.rs no longer asks the catalog for the panel titles"); + } + #[test] fn ukrainian_catalog_matches_the_settings_registry() { let codes: Vec<_> = effectcraft_engine::prefs::LANGUAGES.iter().map(|(_, code)| *code).filter(|code| !matches!(*code, "system" | "en")).collect(); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 46493296b..efde2c370 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1641,6 +1641,32 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "总偏移"), ("Per Layer", "每个图层"), ("Track", "跟踪"), + ("Apply", "应用"), + ("Analyze", "分析"), + ("Create Camera", "创建摄像机"), + ("Add Point", "添加点"), + ("Remove Point", "移除点"), + ("Render", "渲染"), + ("Save Current as Template…", "将当前项存为模板…"), + ("New Composition From Footage", "从素材新建合成"), + ("Search effects and presets", "搜索效果和预设"), + ("Search project", "搜索项目"), + ("Search", "搜索"), + ("(no composition)", "没有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步"), + ("Building waveform…", "正在生成波形…"), + ("No effects. Drag one from Effects & Presets.", "没有效果。从效果和预设中拖入一个。"), + ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "点击色环添加颜色;按住 Alt 点击色标(或将其拖出)可移除"), + ("Start from a ready-made project. Opening a template makes an untitled copy.", "从现成项目开始。打开模板会创建一份未命名副本。"), + ("Square Pixels", "方形像素"), + ("Basic", "基本"), + ("Advanced", "高级"), + ("3D Renderer", "3D 渲染器"), + ("Black", "黑色"), + ("White", "白色"), + ("Composition Settings", "合成设置"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -2327,6 +2353,32 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "總偏移"), ("Per Layer", "每個圖層"), ("Track", "追蹤"), + ("Apply", "套用"), + ("Analyze", "分析"), + ("Create Camera", "建立攝影機"), + ("Add Point", "添加點"), + ("Remove Point", "移除點"), + ("Render", "算圖"), + ("Save Current as Template…", "將目前項目存為範本…"), + ("New Composition From Footage", "從素材新增合成"), + ("Search effects and presets", "搜尋效果與預設"), + ("Search project", "搜尋專案"), + ("Search", "搜尋"), + ("(no composition)", "沒有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步驟"), + ("Building waveform…", "正在產生波形…"), + ("No effects. Drag one from Effects & Presets.", "沒有效果。從效果與預設集中拖入一個。"), + ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "點擊色環新增顏色;按住 Alt 點擊色標(或將其拖出)即可移除"), + ("Start from a ready-made project. Opening a template makes an untitled copy.", "從現成專案開始。開啟範本會建立一份未命名副本。"), + ("Square Pixels", "方形像素"), + ("Basic", "基本"), + ("Advanced", "高級"), + ("3D Renderer", "3D 算圖引擎"), + ("Black", "黑色"), + ("White", "白色"), + ("Composition Settings", "合成設置"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -5579,6 +5631,293 @@ mod tests { /// English source changed or went away. A string a helper draws for the panel (a toolbar /// tooltip, a popup item, a checkbox label) is looked up inside that helper, so its English /// source only appears as a literal there. + #[test] + fn helper_and_painter_labels_are_translated() { + /// The `widgets.rs` helpers that draw a label, and which argument holds it. + const HELPER_LABELS: &[(&str, usize)] = + &[("text_button", 2), ("search_field", 3), ("text_field", 3), ("dropdown", 2), ("dropdown_tip", 2), ("text_fit", 3), ("check_label", 1)]; + /// Literals that draw as they are: a brand, a value, a separator, a unit, and the kerning + /// sample pair the Character panel shows. + const VERBATIM: &[&str] = &["EffectCraft", "32", "dB", "···", "×", "V/A"]; + + /// The top-level arguments of a call, given the text after its `(`. A call split over + /// several lines is still one argument list, so this walks past newlines until the + /// parenthesis closes. It understands the two string forms a label can be written in - a + /// plain one with escapes and a raw one (`r"…"`, `r#"…"#`) - so a comma or parenthesis + /// inside either is not mistaken for code. + fn args_of(s: &str) -> Vec<&str> { + #[derive(Clone, Copy)] + enum Mode { + Code, + Str(bool), + Raw(usize), + } + let cs: Vec<(usize, char)> = s.char_indices().collect(); + let (mut out, mut depth, mut start, mut end) = (Vec::new(), 0i32, None, s.len()); + let mut mode = Mode::Code; + let mut k = 0; + while k < cs.len() { + let (i, c) = cs[k]; + match mode { + Mode::Raw(hashes) => { + if c == '"' { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if n >= hashes { + mode = Mode::Code; + k += n; + } + } + } + Mode::Str(escaped) => { + mode = if c == '"' && !escaped { Mode::Code } else { Mode::Str(c == '\\' && !escaped) }; + } + Mode::Code => match c { + '"' => { + mode = Mode::Str(false); + if start.is_none() { + start = Some(i); + } + } + 'r' if start.is_none() && cs.get(k + 1).is_some_and(|(_, n)| *n == '#' || *n == '"') => { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if cs.get(k + 1 + n).is_some_and(|(_, q)| *q == '"') { + mode = Mode::Raw(n); + start = Some(i); + k += n + 1; + } + } + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => { + if depth == 0 { + end = i; + break; + } + depth -= 1; + } + ',' if depth == 0 => { + if let Some(st) = start { + out.push(s[st..i].trim()); + } + start = None; + } + c if !c.is_whitespace() && start.is_none() => start = Some(i), + _ => {} + }, + } + k += 1; + } + if let Some(st) = start { + out.push(s[st..end].trim()); + } + out + } + + /// The text a literal argument draws: a plain string with its escapes undone, or a raw + /// string with none. `None` when the argument is not one literal (a concatenation, a call). + fn literal_text(arg: &str) -> Option { + if let Some(rest) = arg.strip_prefix('r') { + let hashes = rest.chars().take_while(|c| *c == '#').count(); + // An identifier that starts with `r` (`result`) is not a raw string: the quote has + // to follow the `#`s. + if rest.chars().nth(hashes) != Some('"') { + return None; + } + let close = format!("\"{}", "#".repeat(hashes)); + return arg.get(1 + hashes + 1..)?.strip_suffix(&close).map(str::to_string); + } + let body = arg.strip_prefix('"')?.strip_suffix('"')?; + // Decode the escapes that matter for the exemption: a literal written as `"\u{bb}"` is + // one character (`»`), not six. + let mut out = String::new(); + let mut it = body.chars().peekable(); + while let Some(c) = it.next() { + if c != '\\' { + if c == '"' { + return None; // two literals side by side + } + out.push(c); + continue; + } + match it.next()? { + '"' => out.push('"'), + '\\' => out.push('\\'), + 'n' => out.push('\n'), + 't' => out.push('\t'), + 'x' => { + let hex: String = it.by_ref().take(2).collect(); + let byte = u8::from_str_radix(&hex, 16).ok()?; + out.push(char::from(byte)); + } + 'u' => { + if it.next() != Some('{') { + return None; + } + let hex: String = it.by_ref().take_while(|c| *c != '}').collect(); + out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?); + } + other => { + out.push('\\'); + out.push(other); + } + } + } + Some(out) + } + + /// `source` with comments blanked and the *contents* of every string literal blanked too, + /// with offsets, newlines and the quotes kept. Call tokens are looked for in this text, so a + /// `widgets::…(` or `.text(` that only appears inside a doc string or a sample is not + /// mistaken for a call; the argument list is then read from the original at that offset. + fn code_only(source: &str) -> String { + #[derive(Clone, Copy)] + enum Mode { + Code, + Str(bool), + Raw(usize), + Comment, + } + let cs: Vec<(usize, char)> = source.char_indices().collect(); + // Byte-for-byte: a blanked character becomes as many spaces as its UTF-8 length, so + // every offset found here indexes the original source as well. + let mut out: Vec = Vec::with_capacity(source.len()); + let keep = |out: &mut Vec, c: char| out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()); + let blank = |out: &mut Vec, c: char| out.extend(std::iter::repeat_n(b' ', c.len_utf8())); + let mut mode = Mode::Code; + let mut k = 0; + while k < cs.len() { + let (_, c) = cs[k]; + match mode { + Mode::Comment => { + if c == '\n' { + mode = Mode::Code; + keep(&mut out, c); + } else { + blank(&mut out, c); + } + } + Mode::Raw(hashes) => { + if c == '"' { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if n >= hashes { + keep(&mut out, c); + for j in 1..=n { + keep(&mut out, cs[k + j].1); + } + mode = Mode::Code; + k += n; + } else { + blank(&mut out, c); + } + } else { + blank(&mut out, c); + } + } + Mode::Str(escaped) => { + if c == '"' && !escaped { + mode = Mode::Code; + keep(&mut out, c); + } else { + mode = Mode::Str(c == '\\' && !escaped); + blank(&mut out, c); + } + } + Mode::Code => match c { + '"' => { + mode = Mode::Str(false); + keep(&mut out, c); + } + '/' if out.last() == Some(&b'/') => { + // The first `/` stays; this one becomes a space. Both are one byte, so + // the mask keeps its length and every offset still indexes the source. + blank(&mut out, c); + mode = Mode::Comment; + } + 'r' if matches!(cs.get(k + 1), Some((_, n)) if *n == '#' || *n == '"') => { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if cs.get(k + 1 + n).is_some_and(|(_, q)| *q == '"') { + keep(&mut out, c); + for j in 1..=n { + keep(&mut out, cs[k + j].1); + } + keep(&mut out, '"'); + mode = Mode::Raw(n); + k += n + 1; + } else { + keep(&mut out, c); + } + } + _ => keep(&mut out, c), + }, + } + k += 1; + } + debug_assert_eq!(out.len(), source.len(), "the mask must keep every byte offset"); + String::from_utf8(out).expect("mask keeps UTF-8 boundaries") + } + + for (path, source) in CONVERTED { + // The test module draws nothing; it holds sample strings. A test-only helper above it + // (`#[cfg(test)] pub(crate) fn press_key` in `dialogs.rs`) is kept in the scanned range, + // because production code follows it. + let cut = source + .match_indices("#[cfg(test)]") + .find(|(at, _)| source[at + "#[cfg(test)]".len()..].lines().take(3).any(|l| l.trim_start().starts_with("mod "))) + .map_or(source.len(), |(at, _)| at); + let source = &source[..cut]; + let code = code_only(source); + let line_of = |at: usize| code[..at].matches('\n').count() + 1; + let check = |at: usize, arg: &str, what: &str| { + // A single character is a symbol or a glyph, not interface text - the same exemption + // the call-shape scan above makes. A call that already asks the catalog is not a + // literal here at all. + if let Some(text) = literal_text(arg) + && text.chars().count() >= 2 + && !VERBATIM.contains(&text.as_str()) + { + panic!("{path}:{}: the interface text {arg} that {what} draws is not in the catalog", line_of(at)); + } + }; + for (call, index) in HELPER_LABELS { + let marker = format!("widgets::{call}("); + for (at, _) in code.match_indices(&marker) { + if let Some(arg) = args_of(&source[at + marker.len()..]).get(*index) { + check(at, arg, &format!("widgets::{call}")); + } + } + } + for (at, _) in code.match_indices(".text(") { + if let Some(arg) = args_of(&source[at + ".text(".len()..]).get(2) { + check(at, arg, "Painter::text"); + } + } + } + } + + #[test] + fn the_composition_settings_labels_are_translated() { + // The Composition Settings dialog (and New Composition) draws these from a `(label, value)` + // table, a tab array and a `match` arm, which the call-shape scan cannot see (#1451). They + // used to have no row in any catalog, so every interface drew them in English. + for key in ["Square Pixels", "Basic", "Advanced", "3D Renderer", "Black", "White", "Composition Settings"] { + for (name, table) in [("Simplified", SIMPLIFIED_CHINESE), ("Traditional", TRADITIONAL_CHINESE)] { + let value = table.iter().find(|(k, _)| *k == key).map(|(_, v)| *v); + assert!(value.is_some_and(|v| !v.is_empty()), "{key} has an empty {name} row"); + } + } + } + #[test] fn every_row_is_used_by_a_converted_panel() { for (en, _) in catalogs() { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a8fd2a54..533ecfe87 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -281,7 +281,7 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } } _ => { - ui.label(egui::RichText::new(panel.title()).weak()); + ui.label(egui::RichText::new(crate::i18n::label(app, "window.panel", panel.title())).weak()); } } }); diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index b07da8567..e6b62efb4 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -127,7 +127,7 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.anim_tools = st.clone(); let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 2; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("wg-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("wg-apply")).clicked() { let params = json!({ "apply": if st.wiggle_spatial { "spatial" } else { "temporal" }, "noise": if st.wiggle_smooth { "smooth" } else { "jagged" }, @@ -175,7 +175,7 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += ROW + 8.0; let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 3; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("sm-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("sm-apply")).clicked() { apply(app, "keys.smooth", json!({"tolerance": tol})); } app.auto.add("smoother.apply", b, "Apply"); @@ -375,7 +375,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec changes.insert("keyframeRate".into(), json!(nr)); } } else { - p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, "per second", Tokens::ui(12.0), t.text_dim); + p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, crate::i18n::tr("per second"), Tokens::ui(12.0), t.text_dim); } y += ROW; let mut check = |app: &mut EffectcraftApp, y: f32, key: &str, text: &str, on: bool| { @@ -454,7 +454,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec } let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 2; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("mi-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("mi-apply")).clicked() { match app.session.execute("mask.interpolate", json!({})) { Ok(r) => app.ui.status = format!("Mask Interpolation: {} keyframes added ({} vertices)", r["keys"], r["vertices"]), Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index bca98ea4b..3dc0be8d2 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -413,12 +413,12 @@ pub fn editor( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy { actions.push(("camera.analyze".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() { actions.push(("camera.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel"); @@ -426,7 +426,7 @@ pub fn editor( let params = effectcraft_engine::camera_track::static_params(g); let solve = ct::solve(¶ms); if solve.is_some() { - if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() { + if widgets::text_button(ui, b3, crate::i18n::tr("Create Camera"), false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() { actions.push(("camera.create".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera"); diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 3f7cda26d..ffa9af8a8 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -266,7 +266,7 @@ pub fn show( ui.add_space(8.0); ui.horizontal(|ui| { for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { - let r = ui.selectable_label(d.tab == tab, label); + let r = ui.selectable_label(d.tab == tab, tr(label)); auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); if r.clicked() { d.tab = tab; @@ -327,7 +327,7 @@ pub fn show( let (ow, oh) = (d.width, d.height); let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.width", r.rect, "Width"); - let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64)); + let lbl = tr_args("Lock Aspect Ratio to {}", &[&aspect_label(ow as f64, oh as f64)]); let r = ui.checkbox(&mut d.lock_aspect, lbl); auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio"); if d.lock_aspect && ow != d.width && ow > 0 { @@ -348,11 +348,11 @@ pub fn show( let cur = PIXEL_ASPECTS .iter() .find(|(_, v)| (v - d.pixel_aspect).abs() < 1e-3) - .map(|p| p.0.to_string()) + .map(|p| tr(p.0).to_string()) .unwrap_or_else(|| format!("{:.2}", d.pixel_aspect)); let r = craft_ui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| { for (name, v) in PIXEL_ASPECTS { - if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, *name).clicked() { + if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, tr(name)).clicked() { d.pixel_aspect = *v; } } @@ -420,7 +420,7 @@ pub fn show( ui.horizontal(|ui| { let r = crate::widgets::srgb_color_button(ui, &mut d.bg); auto.add("dialog.comp.background", r.rect, "Background Color"); - ui.label(color_name(d.bg)); + ui.label(tr(color_name(d.bg))); }); ui.end_row(); }); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 7c89ad32d..8e89c109a 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -403,6 +403,8 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis let mut result = None; let custom = app.session.prefs.comp_presets.clone(); let mut cmds = Vec::new(); + // The title keys the modal id and its drag offset; `modal_fit` translates the heading itself, + // so passing the source keeps the dialog in place when the interface language changes. modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds); }); diff --git a/crates/ui-egui/src/panels/effect_controls/editors.rs b/crates/ui-egui/src/panels/effect_controls/editors.rs index c95c90c07..bd566cb7f 100644 --- a/crates/ui-egui/src/panels/effect_controls/editors.rs +++ b/crates/ui-egui/src/panels/effect_controls/editors.rs @@ -8,12 +8,12 @@ pub(super) fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui: let busy = app.session.is_warp_analyzing(); let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy { actions.push(("warp.analyze".into(), json!({"layer": layer.id.0, "effect": g.uid}))); } app.auto.add(&format!("effectControls.warp.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() { actions.push(("warp.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.warp.{}.cancel", g.uid), b2, "Cancel"); diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 39cdee76c..515ecd579 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -183,7 +183,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { bp.text( pos2(body.center().x, body.min.y + 40.0), Align2::CENTER_CENTER, - "No effects. Drag one from Effects & Presets.", + crate::i18n::tr("No effects. Drag one from Effects & Presets."), Tokens::ui(12.0), t.text_faint, ); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 380d1d869..0079328b9 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -357,7 +357,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0)); let mut q = app.ui.effects_search.clone(); - widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t); + widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search effects and presets"), &t); app.ui.effects_search = q.clone(); app.auto.add("effects.search", sr, "Search"); let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t); diff --git a/crates/ui-egui/src/panels/flowchart.rs b/crates/ui-egui/src/panels/flowchart.rs index c7019492f..89cc3493e 100644 --- a/crates/ui-egui/src/panels/flowchart.rs +++ b/crates/ui-egui/src/panels/flowchart.rs @@ -250,7 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = ui.painter().with_clip_rect(rect); p.rect_filled(rect, 0.0, t.tl_bg); let Some(rid) = root(app) else { - p.text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint); return; }; // ---- toolbar: switches and direction. diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index 4ed96797a..85b6be25f 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -600,7 +600,7 @@ fn colorama_wheel( p.text( pos2(sr.min.x, sr.max.y + 9.0), Align2::LEFT_CENTER, - "Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", + crate::i18n::tr("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it"), Tokens::ui(10.5), t.text_faint, ); @@ -801,19 +801,19 @@ fn reshape_buttons( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(96.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(56.0, 22.0)); - if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::unique(("reshape-add", euid))).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("Add Point"), false, &t, egui::Id::unique(("reshape-add", euid))).clicked() { let mut v = pairs.clone(); v.push(add_pair(&pairs)); set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.add"), b1, "Add Point"); - if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b2, crate::i18n::tr("Remove Point"), false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() { let mut v = pairs.clone(); v.pop(); set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.remove"), b2, "Remove Point"); - if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b3, crate::i18n::tr("Clear"), false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() { set_str(actions, layer, g, "correspondencePoints", String::new(), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.clear"), b3, "Clear"); diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index a3c4a85f6..fcb98cce1 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -18,7 +18,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.text( pos2(x, y + 26.0), Align2::LEFT_CENTER, - "Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me.", + crate::i18n::tr("Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me."), Tokens::ui(11.5), t.text_faint, ); @@ -38,7 +38,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.circle_filled(badge.center(), 22.0, t.accent.gamma_multiply(0.25)); p.circle_stroke(badge.center(), 22.0, Stroke::new(1.5, t.accent)); p.text(badge.center() - vec2(0.0, 5.0), Align2::CENTER_CENTER, tut.steps.len().to_string(), Tokens::semibold(15.0), Color32::WHITE); - p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, "steps", Tokens::ui(9.0), t.text_dim); + p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, crate::i18n::tr("steps"), Tokens::ui(9.0), t.text_dim); let tx = badge.max.x + 16.0; p.text(pos2(tx, r.min.y + 22.0), Align2::LEFT_CENTER, &tut.title, Tokens::semibold(14.0), Color32::WHITE); p.text(pos2(tx, r.min.y + 40.0), Align2::LEFT_CENTER, format!("{} min", tut.minutes), Tokens::ui(11.0), t.text_faint); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index dfa9a42e0..4ed1275d1 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -11,7 +11,8 @@ use crate::{EffectcraftApp, widgets}; pub fn placeholder(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, p: PanelKind) { let t = app.tokens; - ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&(p.title())]), Tokens::ui(12.0), t.text_faint); + let name = crate::i18n::label(app, "window.panel", p.title()); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&name]), Tokens::ui(12.0), t.text_faint); } /// Preview panel: transport controls, the shortcut whose options are shown, and that diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 5e39a30c2..0d8eebbef 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -474,7 +474,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0)); let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0)); let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty(); - if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render { + if widgets::text_button(ui, render_b, crate::i18n::tr("Render"), can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render { actions.push(("renderQueue.render", json!({"wait": false}))); } app.auto.add("renderQueue.render", render_b, "Render"); @@ -487,7 +487,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim); app.auto.add("renderQueue.notify", nb, if notify { "Notify: on" } else { "Notify: off" }); if rendering { - if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::unique("rq-stop")).clicked() { + if widgets::text_button(ui, stop_b, crate::i18n::tr("Stop"), false, &t, egui::Id::unique("rq-stop")).clicked() { actions.push(("renderQueue.stop", json!({}))); } app.auto.add("renderQueue.stop", stop_b, "Stop"); diff --git a/crates/ui-egui/src/panels/script_console.rs b/crates/ui-egui/src/panels/script_console.rs index affd46317..7a2cecd30 100644 --- a/crates/ui-egui/src/panels/script_console.rs +++ b/crates/ui-egui/src/panels/script_console.rs @@ -102,7 +102,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text( out_rect.center(), Align2::CENTER_CENTER, - "JavaScript with the After Effects scripting object model — try app.project.numItems", + crate::i18n::tr("JavaScript with the After Effects scripting object model — try app.project.numItems"), Tokens::ui(12.0), t.text_faint, ); diff --git a/crates/ui-egui/src/panels/templates.rs b/crates/ui-egui/src/panels/templates.rs index ff8778a15..5a1ac35a0 100644 --- a/crates/ui-egui/src/panels/templates.rs +++ b/crates/ui-egui/src/panels/templates.rs @@ -62,14 +62,14 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.text( pos2(x, y + 26.0), Align2::LEFT_CENTER, - "Start from a ready-made project. Opening a template makes an untitled copy.", + crate::i18n::tr("Start from a ready-made project. Opening a template makes an untitled copy."), Tokens::ui(11.5), t.text_faint, ); // Save the open project as a user template. let can_save = app.session.project.comps().next().is_some(); let sr = Rect::from_min_size(pos2(area.max.x - 210.0, y - 4.0), vec2(210.0, 28.0)); - let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::unique("home-templates-save")).clicked(); + let save = widgets::text_button(ui, sr, crate::i18n::tr("Save Current as Template…"), false, &t, egui::Id::unique("home-templates-save")).clicked(); app.auto.add("home.templates.saveCurrent", sr, "Save Current as Template…"); if save { if can_save { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a45d8e708..897771216 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1721,7 +1721,7 @@ fn layout(app: &mut EffectcraftApp, ui: &egui::Ui, rect: Rect, cid: ItemId, comp fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); - ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint); // Dropping footage into an empty Timeline should create its first composition, // just like dropping onto the Project panel's New Composition button (#649). if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() @@ -2152,7 +2152,7 @@ fn header_search_and_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &T let comp = g.comp.clone(); let sr = Rect::from_min_size(pos2(tc_rect.max.x + 14.0, top + 12.0), vec2((left_w - 420.0).clamp(120.0, 220.0), 22.0)); let mut search = app.ui.timeline.search.clone(); - widgets::search_field(ui, sr, &mut search, "Search", &t); + widgets::search_field(ui, sr, &mut search, crate::i18n::tr("Search"), &t); app.ui.timeline.search = search; app.auto.add("timeline.search", sr, "Search"); let mut bx = rect.min.x + left_w - 10.0; @@ -3536,7 +3536,7 @@ fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) { let RowKind::Waveform { item, .. } = &rc.row.kind else { return }; lp.rect_filled(left, 0.0, if ri.is_multiple_of(2) { t.row } else { t.row_alt }); let indent = cw.name + 6.0 + 14.0 * row.depth as f32; - lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text); + lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Waveform"), Tokens::ui(12.0), t.text); let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max); gp.rect_filled(wr, 0.0, t.tl_bg); app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform"); @@ -3546,7 +3546,7 @@ fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) { gp.text( pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y), Align2::LEFT_CENTER, - "Building waveform…", + crate::i18n::tr("Building waveform…"), Tokens::ui(11.0), t.text_faint, ); diff --git a/crates/ui-egui/src/panels/viewer/frame.rs b/crates/ui-egui/src/panels/viewer/frame.rs index ac467ecd5..9b7548846 100644 --- a/crates/ui-egui/src/panels/viewer/frame.rs +++ b/crates/ui-egui/src/panels/viewer/frame.rs @@ -9,12 +9,12 @@ pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec let c = rect.center(); p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text); let b1 = Rect::from_center_size(c + vec2(0.0, 0.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::unique("empty-newcomp")).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("New Composition"), true, &t, egui::Id::unique("empty-newcomp")).clicked() { crate::panels::dialogs::open_new_comp(app); } 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() { + if widgets::text_button(ui, b2, crate::i18n::tr("New Composition From Footage"), false, &t, egui::Id::unique("empty-fromfootage")).clicked() { let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({})); } app.auto.add("viewer.empty.import", b2, "New Composition From Footage"); From 89f33415bb6c6775e24e9d679d131afbd2085ef3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:05:43 +0000 Subject: [PATCH 067/112] #1436: take a preview back without taking other edits with it; sync the draft before OK keeps a preview Cancel and Preview-off merge the checkpoint, the preview's result and the project now (new preview_merge module), so an edit made while the dialog was open stays. OK first brings the preview up to the draft it finds, and Composition Settings OK applies the Resolution after a kept preview. The Gradient Editor's OK syncs and sets the draft when it differs from the last preview. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/src/panels/dialogs.rs | 10 +- crates/ui-egui/src/panels/dialogs_3d.rs | 4 +- crates/ui-egui/src/panels/gradient_editor.rs | 23 +- .../ui-egui/src/panels/layer_styles_dialog.rs | 4 +- crates/ui-egui/src/panels/live_preview.rs | 24 ++- crates/ui-egui/src/panels/preview_merge.rs | 204 ++++++++++++++++++ crates/ui-egui/tests/it/ui_dialog_preview.rs | 37 +++- crates/ui-egui/tests/it/ui_gradient_editor.rs | 15 ++ 9 files changed, 302 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/panels/preview_merge.rs diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 83d529646..4f1a97c69 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -36,6 +36,7 @@ pub mod paint_panels; pub mod panel_kit; pub mod path_vr_panels; pub mod precomp; +pub(crate) mod preview_merge; pub mod primitive_dialog; pub mod project; pub mod properties; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 70363d527..44349beb6 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -472,7 +472,13 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis m.insert("comp".into(), json!(cid.0)); } // A preview already made the edit (one undo step): OK keeps it. - if existing && super::live_preview::keep(app) { + if existing && super::live_preview::keep(app, "comp.settings", params.clone()) { + // The Resolution is not part of the command, so the preview never applied it. + if let Some(cid) = app.dialog_state.editing_comp + && let Err(e) = set_resolution(app, cid.0, app.dialog_state.comp.resolution) + { + app.ui.status = e.to_string(); + } app.dialog = None; return; } @@ -657,7 +663,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let (command, params) = (solid_command(ds), solid_params(ds)); if ok || ui_enter(ctx) { // A preview already made the edit (one undo step): OK keeps it. - if !super::live_preview::keep(app) + if !super::live_preview::keep(app, command, params.clone()) && let Err(e) = app.session.execute(command, params) { app.ui.status = e.to_string(); diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 92b9624f7..4b9524a3e 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -255,7 +255,7 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let (id, p) = camera_command(&d); if ok || super::dialogs::ui_enter(ctx) { // A preview already made the edit (one undo step): OK keeps it. - if !super::live_preview::keep(app) + if !super::live_preview::keep(app, id, p.clone()) && let Err(e) = app.session.execute(id, p) { app.ui.status = e.to_string(); @@ -372,7 +372,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let (id, p) = light_command(&d); if ok || super::dialogs::ui_enter(ctx) { // A preview already made the edit (one undo step): OK keeps it. - if !super::live_preview::keep(app) + if !super::live_preview::keep(app, id, p.clone()) && let Err(e) = app.session.execute(id, p) { app.ui.status = e.to_string(); diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index f6e7123ab..127d1f00f 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -22,7 +22,8 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value as Json, json}; use crate::i18n::tr; -use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint, restore}; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint}; +use crate::panels::preview_merge::{self, Applied}; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; @@ -67,6 +68,8 @@ pub struct Draft { pub preview: bool, /// The gradient the property was last given by the preview, if it has been. pub applied: Option, + /// The project as the preview left it, to tell edits made since from the preview's. + applied_at: Option, /// The project and undo history as the editor opened, for Cancel. checkpoint: Option, next_id: u64, @@ -244,12 +247,12 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { app.dialog = None; let d = std::mem::take(&mut app.dialog_state.gradient); - // The preview already set the property, in one merged undo step: OK keeps it. - if d.applied.is_some() { + let g = d.gradient(); + // The preview already set the property to this draft, in one merged undo step: OK keeps it. + if d.applied.as_ref() == Some(&g) { app.session.history.merge_key = None; return; } - let g = d.gradient(); let Some(tg) = d.target else { return }; if g == d.baseline { return; @@ -264,6 +267,8 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { /// were when it opened (nothing happens when the preview never changed the property). pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) { if ok { + // An edit made in the same frame as OK is not in the last preview yet. + sync_preview(app, ctx); commit(app, ctx); return; } @@ -271,7 +276,7 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) { if d.applied.is_some() && let Some(cp) = d.checkpoint { - restore(app, cp); + preview_merge::rollback(app, cp, d.applied_at.as_ref()); } app.session.history.merge_key = None; app.dialog = None; @@ -291,14 +296,18 @@ fn sync_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { "value": Value::Gradient(g.clone()).to_json(), "merge": format!("gradient-editor-{}", tg.prop), }); match crate::menus::invoke(app, ctx, "prop.set", params) { - Ok(_) => app.dialog_state.gradient.applied = Some(g), + Ok(_) => { + app.dialog_state.gradient.applied = Some(g); + app.dialog_state.gradient.applied_at = Some(preview_merge::applied(app)); + } Err(e) => app.ui.status = e, } } else if !want && d.applied.is_some() && let Some(cp) = d.checkpoint.clone() { - restore(app, cp); + let at = app.dialog_state.gradient.applied_at.take(); + preview_merge::rollback(app, cp, at.as_ref()); app.dialog_state.gradient.applied = None; } } diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 70ba5b5b0..713dfe6d4 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -27,8 +27,8 @@ pub struct LayerStyleState { /// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back. #[derive(Clone)] pub(crate) struct Checkpoint { - project: std::sync::Arc, - history: effectcraft_engine::history::History, + pub(crate) project: std::sync::Arc, + pub(crate) history: effectcraft_engine::history::History, } impl std::fmt::Debug for Checkpoint { diff --git a/crates/ui-egui/src/panels/live_preview.rs b/crates/ui-egui/src/panels/live_preview.rs index 861f8eb9f..83d0d2836 100644 --- a/crates/ui-egui/src/panels/live_preview.rs +++ b/crates/ui-egui/src/panels/live_preview.rs @@ -16,7 +16,8 @@ use egui::vec2; use serde_json::Value; use crate::panels::dialogs::Placement; -use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint, restore}; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint}; +use crate::panels::preview_merge::{self, Applied}; use crate::{Dialog, EffectcraftApp}; /// Where a previewing dialog opens: the lower right, undimmed. @@ -34,6 +35,8 @@ pub struct LivePreview { baseline: Option, /// The command and parameters the preview last applied. applied: Option<(&'static str, Value)>, + /// The project as that command left it, to tell edits made since from the preview's. + applied_at: Option, } impl LivePreview { @@ -78,7 +81,10 @@ pub fn sync(app: &mut EffectcraftApp, command: &'static str, params: Value) { put_back(app); } match app.session.execute(command, params.clone()) { - Ok(_) => app.dialog_state.live.applied = Some((command, params)), + Ok(_) => { + app.dialog_state.live.applied = Some((command, params)); + app.dialog_state.live.applied_at = Some(preview_merge::applied(app)); + } Err(e) => app.ui.status = e.to_string(), } } else if is_applied { @@ -89,14 +95,20 @@ pub fn sync(app: &mut EffectcraftApp, command: &'static str, params: Value) { /// The project as the dialog found it. fn put_back(app: &mut EffectcraftApp) { if let Some(cp) = app.dialog_state.live.checkpoint.clone() { - restore(app, cp); + let at = app.dialog_state.live.applied_at.take(); + // Only what the preview did is taken back: an edit made while the dialog was open stays. + preview_merge::rollback(app, cp, at.as_ref()); } app.dialog_state.live.applied = None; + app.dialog_state.live.applied_at = None; } -/// OK: the preview's result is the edit. Returns whether it already is (the caller then must not -/// run the command again); otherwise the caller runs it, as without a preview. -pub fn keep(app: &mut EffectcraftApp) -> bool { +/// OK: the preview's result is the edit. The preview is first brought up to `command` with +/// `params`, the draft as OK found it (an edit made in the same frame as OK is not in the last +/// preview). Returns whether the project then already has the edit (the caller must not run the +/// command again); otherwise the caller runs it, as without a preview. +pub fn keep(app: &mut EffectcraftApp, command: &'static str, params: Value) -> bool { + sync(app, command, params); let kept = active(app) && app.dialog_state.live.applied.is_some(); app.dialog_state.live = LivePreview::default(); kept diff --git a/crates/ui-egui/src/panels/preview_merge.rs b/crates/ui-egui/src/panels/preview_merge.rs new file mode 100644 index 000000000..4eb99f2f0 --- /dev/null +++ b/crates/ui-egui/src/panels/preview_merge.rs @@ -0,0 +1,204 @@ +//! Taking a dialog's Preview back without taking other edits with it. +//! +//! A previewing dialog (Camera, Light, Solid, Composition Settings, the Gradient Editor) puts the +//! project and undo history back to a checkpoint when it is cancelled or Preview goes off. If the +//! project changed in the meantime for another reason (a camera analysis finishing, a script, the +//! control channel), swapping the checkpoint in would throw that edit away. This module undoes +//! only what the preview did: a three-way merge of the checkpoint (before the preview), the project +//! right after the preview (`Applied`) and the project now. Whatever the preview did not touch +//! stays as it is now; what only the preview touched goes back to the checkpoint; what both touched +//! keeps the other edit. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use effectcraft_engine::commands::text_edit::active_editor_snapshot; +use effectcraft_engine::project::{Comp, Item, ItemId, ItemKind, Layer, LayerId, Project}; + +use crate::EffectcraftApp; +use crate::panels::layer_styles_dialog::{Checkpoint, restore}; + +/// The project right after a preview was applied, and the undo depth then. +#[derive(Clone)] +pub(crate) struct Applied { + project: Arc, + undo_len: usize, +} + +impl std::fmt::Debug for Applied { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Applied").field("undo_len", &self.undo_len).finish_non_exhaustive() + } +} + +/// Note the project as the preview that has just been applied left it. +pub(crate) fn applied(app: &EffectcraftApp) -> Applied { + Applied { project: app.session.project.clone(), undo_len: app.session.history.undo.len() } +} + +/// Put the project back as `checkpoint` has it, minus nothing but the preview: when the project is +/// still what the preview made (or nothing says what that was) the checkpoint is simply restored. +pub(crate) fn rollback(app: &mut EffectcraftApp, checkpoint: Checkpoint, applied: Option<&Applied>) { + let Some(applied) = applied.filter(|a| !Arc::ptr_eq(&a.project, &app.session.project)) else { + restore(app, checkpoint); + return; + }; + let merged = merge(&checkpoint.project, &applied.project, &app.session.project); + // One step for what was done meanwhile, from the project before the dialog: Undo takes it back. + let label = app.session.history.undo.get(applied.undo_len).map(|(label, _)| label.clone()).unwrap_or_else(|| "Edit".to_string()); + let mut history = checkpoint.history; + history.undo.push((label, checkpoint.project)); + history.redo.clear(); + history.merge_key = None; + let edited = active_editor_snapshot(&app.session); + app.session.project = Arc::new(merged); + app.session.history = history; + app.session.sanitize_after_replacement(edited.as_ref()); + app.session.bump(); +} + +/// `now` without the changes between `before` and `after`, keeping any other change. +fn merge(before: &Project, after: &Project, now: &Project) -> Project { + let mut merged = now.clone(); + let ids: BTreeSet = before.items.keys().chain(after.items.keys()).chain(now.items.keys()).copied().collect(); + for id in ids { + match item(before.items.get(&id), after.items.get(&id), now.items.get(&id)) { + Some(it) => { + merged.items.insert(id, it); + } + None => { + merged.items.remove(&id); + } + } + } + merged +} + +/// The three-way choice for one value: untouched by the preview keeps `now`, touched only by the +/// preview goes back to `before`, touched by both is `both`. +fn pick(before: Option<&T>, after: Option<&T>, now: Option<&T>, both: impl FnOnce() -> Option) -> Option { + if after == before { + now.cloned() + } else if now == after { + before.cloned() + } else { + both() + } +} + +fn item(before: Option<&Item>, after: Option<&Item>, now: Option<&Item>) -> Option { + pick(before, after, now, || { + if let (Some(b), Some(a), Some(n)) = (before, after, now) + && let (ItemKind::Comp(bc), ItemKind::Comp(ac), ItemKind::Comp(nc)) = (&b.kind, &a.kind, &n.kind) + { + let mut merged = n.clone(); + // Name, folder and the like: only a preview that changed the item itself and nothing else + // is taken back; the comp's own contents are merged below. + merged.kind = ItemKind::Comp(Arc::new(comp(bc, ac, nc))); + return Some(merged); + } + now.cloned() + }) +} + +fn bare(c: &Comp) -> Comp { + let mut b = c.clone(); + b.layers.clear(); + b +} + +fn layer_of(c: &Comp, id: LayerId) -> Option<&Layer> { + c.layers.iter().find(|l| l.id == id) +} + +fn comp(before: &Comp, after: &Comp, now: &Comp) -> Comp { + // The comp's own settings, apart from its layers. + let mut merged = pick(Some(&bare(before)), Some(&bare(after)), Some(&bare(now)), || Some(bare(now))).unwrap_or_else(|| bare(now)); + let merge_layer = |id: LayerId| pick(layer_of(before, id), layer_of(after, id), layer_of(now, id), || layer_of(now, id).cloned()); + // Layers as they stand now, in their order, then the ones the preview removed put back. + let mut layers: Vec = now.layers.iter().filter_map(|l| merge_layer(l.id)).collect(); + for (at, l) in before.layers.iter().enumerate() { + if layer_of(now, l.id).is_none() + && let Some(back) = merge_layer(l.id) + { + layers.insert(at.min(layers.len()), back); + } + } + merged.layers = layers; + merged +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dialog; + use crate::panels::live_preview; + use effectcraft_engine::Session; + use serde_json::json; + + fn app_with_light() -> (EffectcraftApp, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Scene", "width": 320, "height": 180, "duration": 1})).unwrap(); + let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap(); + (EffectcraftApp::new(s), light) + } + + fn name_of(app: &EffectcraftApp, layer: u64) -> String { + app.session.active_comp().unwrap().layer(LayerId(layer)).unwrap().name.clone() + } + + fn layer_count(app: &EffectcraftApp) -> usize { + app.session.active_comp().unwrap().layers.len() + } + + /// The preview renames the light; a null is added meanwhile (what a finished background job + /// would do). Cancel takes the rename back and keeps the null, with one undo step for it. + #[test] + fn cancelling_a_preview_keeps_an_edit_made_while_it_was_open() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + let rename = |name: &str| json!({"layer": light, "name": name}); + live_preview::sync(&mut app, "layer.rename", rename(&original)); + live_preview::sync(&mut app, "layer.rename", rename("Previewed")); + assert_eq!(name_of(&app, light), "Previewed"); + let layers = layer_count(&app); + app.session.execute("layer.newNull", json!({})).unwrap(); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), original, "the preview is taken back"); + assert_eq!(layer_count(&app), layers + 1, "the other edit stays"); + assert!(app.session.undo()); + assert_eq!(layer_count(&app), layers, "and has its own undo step"); + assert_eq!(name_of(&app, light), original); + } + + /// With nothing else done meanwhile the checkpoint is restored as before. + #[test] + fn cancelling_an_undisturbed_preview_restores_the_checkpoint() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + let steps = app.session.history.undo.len(); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": original})); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": "Previewed"})); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), original); + assert_eq!(app.session.history.undo.len(), steps); + } + + /// An item both the preview and another edit changed keeps the other edit. + #[test] + fn a_layer_both_edits_changed_keeps_the_other_edit() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": original})); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": "Previewed"})); + app.session.execute("layer.rename", json!({"layer": light, "name": "Renamed elsewhere"})).unwrap(); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), "Renamed elsewhere"); + } +} diff --git a/crates/ui-egui/tests/it/ui_dialog_preview.rs b/crates/ui-egui/tests/it/ui_dialog_preview.rs index 861548012..ce5f2803e 100644 --- a/crates/ui-egui/tests/it/ui_dialog_preview.rs +++ b/crates/ui-egui/tests/it/ui_dialog_preview.rs @@ -6,7 +6,7 @@ //! at the lower right; the same dialogs creating something new keep the middle. use effectcraft_engine::Session; -use effectcraft_engine::project::{ItemKind, LayerId}; +use effectcraft_engine::project::{ItemKind, LayerId, Resolution}; use effectcraft_ui_egui::{Dialog, EffectcraftApp, menus}; use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; use egui_kittest::Harness; @@ -218,6 +218,41 @@ fn composition_settings_preview() { assert_eq!(undo_len(&h), undo + 1, "in one undo step"); } +/// The Resolution is no part of the Composition Settings command, so a preview never applies it: +/// OK has to, also when it keeps a preview of another field. +#[test] +fn composition_settings_ok_after_a_preview_still_applies_the_resolution() { + let mut h = harness(|_| {}); + let before = comp_width(&h); + open(&mut h, "comp.settings", json!({})); + h.state_mut().dialog_state_mut().comp.width = before + 80; + h.run_steps(3); + assert_eq!(comp_width(&h), before + 80, "the preview applied the width"); + h.state_mut().dialog_state_mut().comp.resolution = Resolution::Half; + h.run_steps(3); + click(&mut h, "dialog.comp.ok"); + assert_eq!(comp_width(&h), before + 80); + assert_eq!(h.state().session.active_comp().unwrap().resolution, Resolution::Half, "OK applied the Resolution"); +} + +/// An edit made in the same frame as OK (Enter) is not in the last preview yet: OK brings the +/// project up to the draft it finds instead of keeping the older preview. +#[test] +fn ok_in_the_frame_of_an_edit_keeps_the_edit_not_the_older_preview() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + h.state_mut().dialog_state_mut().solid_size = [240, 120]; + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [240, 120], "OK kept the draft it found, not the earlier preview"); +} + /// A previewing dialog opens at the lower right so the canvas stays in view; the same dialogs /// creating something new keep the middle and have no Preview. #[test] diff --git a/crates/ui-egui/tests/it/ui_gradient_editor.rs b/crates/ui-egui/tests/it/ui_gradient_editor.rs index 3b5da558d..f5ee8634b 100644 --- a/crates/ui-egui/tests/it/ui_gradient_editor.rs +++ b/crates/ui-egui/tests/it/ui_gradient_editor.rs @@ -466,6 +466,21 @@ fn the_preview_applies_edits_live_and_cancel_rolls_them_back() { assert_eq!(undo_len(&h), undo); } +/// An edit made in the same frame as OK is not in the last preview: OK sets the draft it finds. +#[test] +fn ok_in_the_frame_of_an_edit_sets_the_draft_not_the_older_preview() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the preview has three stops"); + h.state_mut().gradient_draft_mut().add(Track::Color, 0.75); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::panels::gradient_editor::finish(h.state_mut(), true, &ctx); + assert_eq!(h.state().dialog, None); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft it found"); +} + /// With Preview off nothing is set until OK, and switching it off takes back what it applied. #[test] fn with_preview_off_nothing_is_set_until_ok() { From 2bef8aa6696da6a48a812b96e99789eb72af1951 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 06:46:51 -0700 Subject: [PATCH 068/112] 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 069/112] 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 29034da722b2e2772d6fcce6ce5be210985f6d70 Mon Sep 17 00:00:00 2001 From: midasdf Date: Fri, 30 Oct 2026 05:51:14 +0900 Subject: [PATCH 070/112] i18n-report: print every catalog, and correct the Georgian panel row --- crates/ui-egui/src/i18n.rs | 2 +- docs/localization-parity.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 3b6452ff7..af112e7cf 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -7517,7 +7517,7 @@ mod tests { #[test] fn catalog_row_counts_report() { println!("Localization catalog rows:"); - for code in ["ja", "zh-hans", "zh-hant"] { + for code in CATALOG_CODES { let menus = menu_catalog(code).map_or(0, |catalog| catalog.len()); let panels = ui::translated_rows(code); let effect_names = effects::translated_rows(code); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 76cac2258..a5ddd6e8a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -26,8 +26,8 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea source string, with English fallback. Catalogs exist for Japanese (996 rows), Simplified Chinese (998), Traditional Chinese (951, after #2244 pruned the table), Ukrainian (524), Brazilian Portuguese (695), Czech (518) and Spanish (513); counted at this tree. Georgian has - every row the Simplified Chinese catalog has, plus 191 for panels converted to the catalog - since. + the Simplified Chinese catalog's 694 rows, plus 191 for panels converted to the catalog since + (885 in all). - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified Chinese (307 rows, one per effect in the registry), Japanese (297 of the 307 ids) and Georgian (307 rows, plus its 21 categories). The registry's From 18c5c4022d039d2106661f16cbafe6f0cf56315d Mon Sep 17 00:00:00 2001 From: midasdf Date: Fri, 30 Oct 2026 05:58:37 +0900 Subject: [PATCH 071/112] Drop the xtask i18n-report wrapper The maintainer asked for the counts, not a new CLI surface: the numbers come from the catalog tests, and the row-count report test is enough to reproduce them (cargo test -p effectcraft-ui-egui --lib catalog_row_counts_report -- --nocapture). --- xtask/src/main.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index b04d0e009..da8026d78 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,4 +1,4 @@ -//! Workspace automation: `cargo xtask `. +//! Workspace automation: `cargo xtask `. //! //! - `layers`: enforces the dependency layering of `docs/architecture.md` §1 (downward-only edges, //! listed same-layer edges, no UI/OS crates below L5). @@ -11,8 +11,6 @@ //! - `ico …`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`). //! - `ci`: fmt check, clippy -D warnings, tests (`cargo nextest run` when installed) and doctests, //! PDF dependency policy, layers, assets, wasm. Everything builds in the dev profile. -//! - `i18n-report`: the translated menu, panel and effect-name rows per language, the counts -//! `docs/localization-parity.md` quotes. //! - `bench [--bin CLI] [--quick] [--compare OLD.jsonl] …`: render benchmark of generated scenarios //! (memory, CPU, wall time) into `target/bench/results.jsonl` (`bench.rs`, docs/testing.md). @@ -453,12 +451,6 @@ fn ci() -> Result<(), String> { if failed.is_empty() { Ok(()) } else { Err(format!("ci failed: {}", failed.join(", "))) } } -/// The translated-row counts per language, printed by the `catalog_row_counts_report` test: the -/// numbers `docs/localization-parity.md` quotes. -fn i18n_report() -> Result<(), String> { - run(Command::new(env!("CARGO")).args(["test", "-p", "effectcraft-ui-egui", "--lib", "catalog_row_counts_report", "--", "--nocapture"])) -} - fn main() -> ExitCode { let task = std::env::args().nth(1).unwrap_or_default(); let r = match task.as_str() { @@ -467,7 +459,6 @@ fn main() -> ExitCode { "assets" => assets(), "pdf-deps" => pdf_deps::run(&root()), "ci" => ci(), - "i18n-report" => i18n_report(), "web" => web(&std::env::args().skip(2).collect::>()), "bench" => bench::run(&root(), &std::env::args().skip(2).collect::>()), "ico" => { @@ -478,7 +469,7 @@ fn main() -> ExitCode { let rest: Vec = std::env::args().skip(2).collect(); version::run(&root(), &rest.iter().map(String::as_str).collect::>()) } - _ => Err("usage: cargo xtask ".into()), + _ => Err("usage: cargo xtask ".into()), }; match r { Ok(()) => ExitCode::SUCCESS, From cca5447e09e4bb8f689b19dda093901f0d3e1c4e Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 17:43:30 -0400 Subject: [PATCH 072/112] 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 073/112] 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 8bb5dbb81e8651072f7e19220342225f8bd2d7f1 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 17:52:57 -0400 Subject: [PATCH 074/112] Media: downmix AAC in the order its channel configuration decodes in (#1701) FilmCraft reorders AAC configurations 3 to 6, 11 and 12 to WAVE/SMPTE order and leaves configuration 7 and program config elements as coded, so the channel count alone can't say which order arrives: ffmpeg writes 7.1 as configuration 7 (C L R first) and 6.1 as a PCE it fills in its own order (L R C LFE Cs Ls Rs), while FilmCraft's encoder writes a centre-first PCE. The order is now read from an MP4/MOV's AudioSpecificConfig when the source opens; other containers keep a channel-count guess. The ffmpeg oracle test gains configuration 12 (ffmpeg's 7.1 relabelled) and skips PCE files ffmpeg 4.2 marks as stereo, which FilmCraft reads as two channels. --- Cargo.lock | 1 + crates/media/Cargo.toml | 1 + crates/media/src/downmix.rs | 123 +++++++++++++++++++++++-------- crates/media/src/pool.rs | 77 ++++++++++++------- crates/media/src/probe.rs | 11 +++ crates/media/tests/it/downmix.rs | 72 ++++++++++++++---- 6 files changed, 211 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7b774557..627da7cf4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1632,6 +1632,7 @@ dependencies = [ "effectcraft-svg", "effectcraft-time", "exr", + "filmcraft-aac", "filmcraft-codecs", "filmcraft-color", "filmcraft-frame", diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 923a28afa..d3f815bba 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -21,6 +21,7 @@ effectcraft-pdf = { workspace = true } effectcraft-model = { workspace = true } filmcraft-codecs = { workspace = true } filmcraft-media = { workspace = true } +filmcraft-aac = { workspace = true } filmcraft-isobmff = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs index 1ad9721ba..745f28065 100644 --- a/crates/media/src/downmix.rs +++ b/crates/media/src/downmix.rs @@ -12,9 +12,10 @@ //! //! Channel order follows the WAVE / SMPTE convention by channel count (what FilmCraft's AC-3, PCM, //! FLAC and Opus readers deliver): 3 = L R C, 4 = quad L R Ls Rs, 5 = L R C Ls Rs, -//! 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. FilmCraft's AAC-LC -//! decoder instead delivers AAC's own order (C L R first, LFE last), so AAC gets its own tables. -//! More than 8 channels carry no known layout: only the first two (front L/R) are used. +//! 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. AAC arrives in either +//! that order or AAC's own (C L R first, LFE last) depending on its channel configuration +//! ([`aac_order`]). More than 8 channels carry no known layout: only the first two (front L/R) +//! are used. use std::f32::consts::FRAC_1_SQRT_2; @@ -64,28 +65,59 @@ pub fn stereo_gains(count: usize, aac: bool) -> Vec { out } -/// The gains for AAC as FilmCraft's decoder hands it over: channel configurations 3 to 6 (3 to 6 -/// channels) and 12 (7.1, 8 channels) are already in WAV/SMPTE order (L R C [Cs] / [LFE] Ls Rs, -/// L R C LFE Lb Rb Ls Rs), everything else (mono, stereo, program-config layouts such as the 6.1 -/// ffmpeg writes) in AAC's element order. Configuration 11 (6.1) and 7 (7.1 with front wides) -/// can't be told from those by the channel count and are read in the order of the commoner one. -pub fn decoded_aac_gains(count: usize) -> Vec { - match count { - // C L R Cs, reordered to L R C Cs: the same gains, permuted. - 4 => { - let g = stereo_gains(4, true); - [1, 2, 0, 3].iter().filter_map(|&i| g.get(i).copied()).collect() - } - 3 | 5 | 6 | 8 => stereo_gains(count, false), - _ => stereo_gains(count, true), +/// The order a source's decoded channels arrive in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Order { + /// WAVE / SMPTE by channel count: PCM, FLAC, AC-3, Opus and the rest. + #[default] + Wav, + /// AAC that FilmCraft reorders to WAVE / SMPTE (configurations 3 to 6, 11 and 12; 4 becomes + /// L R C Cs) or that ffmpeg wrote in it (a program config element listing the front pair + /// first: ffmpeg fills a PCE's elements in its own channel order and reads them back so). + AacWav, + /// AAC in its element order (C L R … LFE), which FilmCraft keeps: configuration 7 and a PCE + /// listing the centre first. + AacElements, + /// AAC whose configuration the container doesn't show us (only MP4 / MOV headers are read): + /// as [`Order::AacWav`] up to 7 channels (configurations 3 to 6, 11 and ffmpeg's 6.1 PCE), + /// as [`Order::AacElements`] above (configuration 7, ffmpeg's 7.1). + Aac, +} + +/// How FilmCraft's decoder hands over the AAC described by the AudioSpecificConfig `asc`. +pub fn aac_order(asc: &[u8]) -> Order { + let Ok(asc) = filmcraft_aac::AudioSpecificConfig::parse(asc) else { return Order::Aac }; + match (asc.channel_config, asc.pce) { + (3..=6 | 11 | 12, _) => Order::AacWav, + (0, Some(pce)) if pce.front.first().is_some_and(|&(cpe, _)| cpe) => Order::AacWav, + (0, None) => Order::Aac, + _ => Order::AacElements, } } -/// Downmix planar `channels` to interleaved stereo in `out` (`out.len() / 2` frames); frames a -/// channel is too short for count as silence, extra frames in `out` stay as they are. -pub fn downmix_into(channels: &[Vec], aac: bool, out: &mut [f32]) { - let gains = if aac { decoded_aac_gains(channels.len()) } else { stereo_gains(channels.len(), false) }; - mix(channels, &gains, out); +/// The gains for `count` channels arriving in `order`. +pub fn decoded_gains(count: usize, order: Order) -> Vec { + let order = match order { + Order::Aac if (3..=7).contains(&count) => Order::AacWav, + Order::Aac => Order::AacElements, + o => o, + }; + match (order, count) { + // C L R Cs, reordered to L R C Cs: the same gains, permuted. + (Order::AacWav, 4) => { + let g = stereo_gains(4, true); + [1, 2, 0, 3].iter().filter_map(|&i| g.get(i).copied()).collect() + } + (Order::AacElements, _) => stereo_gains(count, true), + _ => stereo_gains(count, false), + } +} + +/// Downmix planar `channels` arriving in `order` to interleaved stereo in `out` (`out.len() / 2` +/// frames); frames a channel is too short for count as silence, extra frames in `out` stay as +/// they are. +pub fn downmix_into(channels: &[Vec], order: Order, out: &mut [f32]) { + mix(channels, &decoded_gains(channels.len(), order), out); } fn mix(channels: &[Vec], gains: &[Pair], out: &mut [f32]) { @@ -119,19 +151,46 @@ mod tests { } } + /// AudioSpecificConfigs as ffmpeg 8.1 and FilmCraft's encoder write them (48 kHz AAC-LC). + const PCE_FFMPEG_61: &str = "118004c848002000c4400d4c61766336322e32382e31303256e500"; + const PCE_FILMCRAFT_61: &str = "118004c845000108840000"; + + fn asc(hex: &str) -> Vec { + (0..hex.len() / 2).filter_map(|i| hex.get(2 * i..2 * i + 2).and_then(|b| u8::from_str_radix(b, 16).ok())).collect() + } + #[test] - fn decoded_aac_is_in_wav_order_for_three_to_six_and_eight_channels() { - // FilmCraft hands AAC 3.0 to 5.1 and 7.1 over in the order of the plain layouts. - for n in [3, 5, 6, 8] { - assert_eq!(decoded_aac_gains(n), stereo_gains(n, false), "{n} channels"); + fn aac_order_follows_the_channel_configuration() { + // AAC-LC at 48 kHz with channel configuration n + let config = |n: u8| aac_order(&[0x11, 0x80 | n << 3]); + for n in [3, 4, 5, 6, 11, 12] { + assert_eq!(config(n), Order::AacWav, "configuration {n}"); } - let g = decoded_aac_gains(4); + for n in [1, 2, 7] { + assert_eq!(config(n), Order::AacElements, "configuration {n}"); + } + assert_eq!(aac_order(&asc(PCE_FFMPEG_61)), Order::AacWav, "ffmpeg's 6.1 PCE lists L R first"); + assert_eq!(aac_order(&asc(PCE_FILMCRAFT_61)), Order::AacElements, "a PCE listing the centre first"); + assert_eq!(aac_order(&[]), Order::Aac); + } + + #[test] + fn decoded_aac_gains_follow_the_order() { + for n in [3, 5, 6, 7, 8] { + assert_eq!(decoded_gains(n, Order::AacWav), stereo_gains(n, false), "{n} channels"); + assert_eq!(decoded_gains(n, Order::AacElements), stereo_gains(n, true), "{n} channels"); + } + let g = decoded_gains(4, Order::AacWav); assert_eq!(g.len(), 4); assert!(g[0][0] > 0.0 && g[0][1] == 0.0 && g[1][1] > 0.0 && g[1][0] == 0.0, "L R first"); assert!(g[2][0] > 0.0 && g[2][0] == g[2][1], "the centre is third and feeds both sides"); assert!(g[3][0] > 0.0 && g[3][0] < g[2][0], "the rear centre is last, below the centre"); - for n in [1, 2, 7, 9] { - assert_eq!(decoded_aac_gains(n), stereo_gains(n, true), "{n} channels"); + assert_eq!(decoded_gains(4, Order::Wav), stereo_gains(4, false), "PCM 4 channels are quad"); + for n in [3, 4, 5, 6, 7] { + assert_eq!(decoded_gains(n, Order::Aac), decoded_gains(n, Order::AacWav), "{n} channels"); + } + for n in [1, 2, 8, 9] { + assert_eq!(decoded_gains(n, Order::Aac), stereo_gains(n, true), "{n} channels"); } } @@ -161,10 +220,10 @@ mod tests { #[test] fn ragged_and_empty_input_never_panics() { let mut out = vec![0.0; 8]; - downmix_into(&[], false, &mut out); - downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], true, &mut out); + downmix_into(&[], Order::Wav, &mut out); + downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], Order::Aac, &mut out); assert!(out.iter().all(|v| v.is_finite())); let mut none: [f32; 0] = []; - downmix_into(&vec![vec![1.0; 3]; 7], false, &mut none); + downmix_into(&vec![vec![1.0; 3]; 7], Order::Wav, &mut none); } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index ed89bf7e8..193804447 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -12,6 +12,7 @@ use filmcraft_media::{FrameRequest, SharedSource}; use image::metadata::Orientation; use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned}; +use crate::downmix::Order; use crate::gif_anim::{self, Anim}; use crate::{MediaError, Result}; @@ -178,10 +179,37 @@ struct CacheState { last: HashMap, i64>, } -/// A movie/audio source by path and the turn its picture is shown with -/// ([`crate::probe::video_turn`]), opened by the first caller while the others wait for it -/// (`None`: failed to open; not retried until `forget`). -type SourceSlot = Arc>>; +/// A movie/audio source by path ([`Opened`]), opened by the first caller while the others wait +/// for it (`None`: failed to open; not retried until `forget`). +type SourceSlot = Arc>>; + +/// An opened movie/audio source, the turn its picture is shown with and the order its decoded +/// audio channels arrive in. +#[derive(Clone)] +struct Opened { + src: SharedSource, + turn: Orientation, + order: Order, +} + +impl Opened { + /// `src` with what its MP4/MOV `header` (ftyp and moov) says, if it has one. + fn new(src: SharedSource, header: Option<&[u8]>) -> Opened { + let turn = header.map_or(Orientation::NoTransforms, crate::probe::video_turn); + let order = channel_order(&src, header.and_then(crate::probe::aac_config).as_deref()); + Opened { src, turn, order } + } +} + +/// The order `src`'s audio channels decode in, AAC's decided by its AudioSpecificConfig `asc` +/// when the container's header was read. +fn channel_order(src: &SharedSource, asc: Option<&[u8]>) -> Order { + match (src.info().audio().is_some_and(|a| a.codec.starts_with("AAC")), asc) { + (false, _) => Order::Wav, + (true, Some(asc)) => crate::downmix::aac_order(asc), + (true, None) => Order::Aac, + } +} /// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load). type GifSlot = Arc, String>>>; @@ -514,7 +542,7 @@ impl MediaPool { /// [`MediaPool::decode_audio`], failing when the source can't be opened or read. fn try_decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> std::result::Result, String> { let path: Arc = footage.path.as_str().into(); - let src = self.inner.source(&path).ok_or("the file can't be opened")?; + let Opened { src, order, .. } = self.inner.opened(&path).ok_or("the file can't be opened")?; let s0 = start.to_units_floor(rate as i64); let native = src.info().audio().map_or(rate, |a| a.sample_rate); // The header's rate scales the samples read: 0xFFFFFFFF Hz made one conform chunk about @@ -523,14 +551,14 @@ impl MediaPool { return Err(format!("a sample rate of {native} Hz is outside {MIN_SAMPLE_RATE}–{MAX_SAMPLE_RATE} Hz")); } if native == 0 || rate == 0 || native == rate { - return read_stereo(&src, s0, frames, rate); + return read_stereo(&src, order, s0, frames, rate); } // Output sample `n` sits at source position n × native / rate. let (native_i, rate_i) = (i128::from(native), i128::from(rate)); let first = (i128::from(s0) * native_i).div_euclid(rate_i); let last = ((i128::from(s0) + frames as i128) * native_i).div_euclid(rate_i) + 1; let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return Ok(vec![0.0; frames * 2]) }; - let buf = read_stereo(&src, first64, count, native)?; + let buf = read_stereo(&src, order, first64, count, native)?; let mut out = Vec::with_capacity(frames * 2); for k in 0..frames as i128 { let pos = (i128::from(s0) + k) * native_i; @@ -593,17 +621,12 @@ impl Inner { crate::read_regular_file(std::path::Path::new(path)).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) } - /// The opened movie/audio source for `path`. The first caller opens it, outside the map's - /// lock (reading and parsing a large file takes a moment; FLAC, MP3, Ogg and AIFF files are - /// decoded whole); callers asking for the same file meanwhile wait for that instead of opening - /// it again: a preview's frame workers, audio feeder, conform and waveform threads each - /// decoded a long FLAC in full at once (#408). - fn source(&self, path: &Arc) -> Option { - self.opened(path).map(|(src, _)| src) - } - - /// [`Self::source`] and the turn the movie's picture is shown with. - fn opened(&self, path: &Arc) -> Option<(SharedSource, Orientation)> { + /// The opened movie/audio source for `path` ([`Opened`]). The first caller opens it, outside + /// the map's lock (reading and parsing a large file takes a moment; FLAC, MP3, Ogg and AIFF + /// files are decoded whole); callers asking for the same file meanwhile wait for that instead + /// of opening it again: a preview's frame workers, audio feeder, conform and waveform threads + /// each decoded a long FLAC in full at once (#408). + fn opened(&self, path: &Arc) -> Option { let slot = lock(&self.sources).entry(path.clone()).or_default().clone(); slot.get_or_init(|| { self.opened.fetch_add(1, Ordering::Relaxed); @@ -613,14 +636,15 @@ impl Inner { let file = std::path::Path::new(&**path); let in_place = if in_memory { None } else { crate::stream::open_source(file) }; let in_place = in_place.map(|src| { - let turn = crate::stream::movie_header(file).map_or(Orientation::NoTransforms, |h| crate::probe::video_turn(&h)); - src.map(|src| (src, turn)) + let header = crate::stream::movie_header(file); + src.map(|src| Opened::new(src, header.as_deref())) }); let opened = in_place.unwrap_or_else(|| { self.read(path).and_then(|bytes| { let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let turn = crate::probe::video_turn(&bytes); - filmcraft_codecs::open_bytes(&name, bytes).map(|src| (src, turn)).map_err(MediaError::from) + let (turn, asc) = (crate::probe::video_turn(&bytes), crate::probe::aac_config(&bytes)); + let src = filmcraft_codecs::open_bytes(&name, bytes).map_err(MediaError::from)?; + Ok(Opened { order: channel_order(&src, asc.as_deref()), src, turn }) }) }); match opened { @@ -854,7 +878,7 @@ impl Inner { Ok(dynamic_to_image(frame, op)) } Some(mt) => { - let (src, turn) = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; + let Opened { src, turn, .. } = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; let vf = src .video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))) .map_err(MediaError::from) @@ -953,8 +977,8 @@ impl FootageSource for MediaPool { /// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which /// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both -/// channels, multichannel sources are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails. -fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { +/// channels, multichannel sources (channels in `order`) are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails. +fn read_stereo(src: &SharedSource, order: Order, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { let mut out = vec![0.0; frames * 2]; let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0); if skip >= frames { @@ -962,8 +986,7 @@ fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::re } let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?; let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) }; - let aac = src.info().audio().is_some_and(|a| a.codec.starts_with("AAC")); - crate::downmix::downmix_into(&buf.channels, aac, dst); + crate::downmix::downmix_into(&buf.channels, order, dst); Ok(out) } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 9a7ebfa1d..435f9ce57 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -259,6 +259,17 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> im Ok(img) } +/// The AudioSpecificConfig of an MP4/MOV movie's first audio track (the one FilmCraft plays), if +/// that is AAC. +pub(crate) fn aac_config(bytes: &[u8]) -> Option> { + let file = filmcraft_isobmff::open(bytes).ok()?; + let track = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Audio)?; + match &track.entries.first()?.codec { + filmcraft_isobmff::CodecConfig::Aac(a) => Some(a.asc.clone()), + _ => None, + } +} + /// The rotation an MP4/MOV movie is shown with (a phone held upright records sideways frames and /// says so in its video track's header): the quarter turn nearest the track's rotation. Only the /// rotation counts; any mirroring, scaling or offset in the header is ignored, and other diff --git a/crates/media/tests/it/downmix.rs b/crates/media/tests/it/downmix.rs index e2c95c251..c139676d6 100644 --- a/crates/media/tests/it/downmix.rs +++ b/crates/media/tests/it/downmix.rs @@ -164,24 +164,66 @@ fn each_channel_reaches_stereo_at_its_bs775_gain() { for c in cases() { let freqs: Vec = FREQS.iter().copied().take(c.names.len()).collect(); let Some(p) = fixture(c.file, c.layout, c.names, &freqs, c.codec) else { continue }; - let s = ours(&p); - let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3) - for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() { - for (side, gain) in g.iter().enumerate() { - let got = amp(&s, side, freqs[i]); - let want = sine * gain; - assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]); - } - } - let theirs = ffmpeg_ac2(&p); - let (a, b) = (rms(&s), rms(&theirs)); - let db = 20.0 * (a / b).log10(); - assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file); - let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10(); - assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file); + check(&c, &p); } } +/// AAC 7.1 in channel configuration 12 (back surrounds), which FilmCraft reorders where it +/// leaves ffmpeg's configuration 7 as coded: ffmpeg's 7.1 with its configuration rewritten (both +/// code the same elements). +#[test] +fn seven_one_aac_in_configuration_12() { + let c = cases().into_iter().find(|c| c.file == "dm71.m4a").expect("the 7.1 AAC case"); + let freqs: Vec = FREQS.iter().copied().take(c.names.len()).collect(); + let Some(p) = fixture(c.file, c.layout, c.names, &freqs, c.codec) else { return }; + let mut bytes = std::fs::read(&p).expect("read"); + let (asc, _) = aac_entry(&bytes).expect("an AAC track"); + assert_eq!(asc.get(..2), Some(&[0x11, 0xB8][..]), "AAC-LC 48 kHz, configuration 7"); + let at = bytes.windows(asc.len()).position(|w| w == asc).expect("the configuration in the file"); + bytes[at + 1] = 0xE0; + let p12 = fixtures().join("dm71c12.m4a"); + std::fs::write(&p12, bytes).expect("write"); + check(&Case { file: "dm71c12.m4a", ..c }, &p12); +} + +/// The AudioSpecificConfig and sample entry channel count of an MP4's first AAC track. +fn aac_entry(bytes: &[u8]) -> Option<(Vec, u32)> { + let file = filmcraft_isobmff::open(bytes).ok()?; + file.tracks.iter().flat_map(|t| &t.entries).find_map(|e| match &e.codec { + filmcraft_isobmff::CodecConfig::Aac(a) => Some((a.asc.clone(), e.audio.as_ref()?.channels)), + _ => None, + }) +} + +fn check(c: &Case, p: &Path) { + let pce_as_stereo = aac_entry(&std::fs::read(p).expect("read")).is_some_and(|(asc, entry)| { + let config = filmcraft_aac::AudioSpecificConfig::parse(&asc).map(|a| a.channel_config); + config.ok() == Some(0) && entry == 2 + }); + if pce_as_stereo && c.names.len() > 2 { + // ffmpeg 4.2 writes 2 in the sample entry of an AAC PCE layout (8.1 the real count), and + // that is all FilmCraft decodes. + eprintln!("{}: FilmCraft reads 2 of its {} channels: skipping", c.file, c.names.len()); + return; + } + let s = ours(p); + let freqs: Vec = FREQS.iter().copied().take(c.names.len()).collect(); + let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3) + for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() { + for (side, gain) in g.iter().enumerate() { + let got = amp(&s, side, freqs[i]); + let want = sine * gain; + assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]); + } + } + let theirs = ffmpeg_ac2(p); + let (a, b) = (rms(&s), rms(&theirs)); + let db = 20.0 * (a / b).log10(); + assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file); + let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10(); + assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file); +} + /// Full-scale in every channel must not clip. #[test] fn loud_surround_does_not_clip() { From ffad10c7f33ba48bf93bbf460677f5fa30e4dfc4 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 17:53:50 -0400 Subject: [PATCH 075/112] 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 076/112] 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 393a7aa56529c7ff424116e9f02b27b9bf9bc5dd Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 17:57:08 -0400 Subject: [PATCH 077/112] Export tests: pin the H.264 bytes FilmCraft 09aaf9b writes (#1701) The encoder's first-frame user_data_unregistered SEI now lists its settings ("... - options: rc=vbr bitrate=10000 ... scenecut=1", 211 bytes where "filmcraft-h264enc 0.1.1 - clean-room H.264 encoder" took 50): +161 bytes. The same build gives (104322, 11051847956767423392), the pin Linux CI took on a base before the 19-byte nclx colr box, when that box is left out, so the bytes match across platforms. --- crates/export/tests/it/movie_bytes.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/export/tests/it/movie_bytes.rs b/crates/export/tests/it/movie_bytes.rs index 1f48efd1e..7a3fba611 100644 --- a/crates/export/tests/it/movie_bytes.rs +++ b/crates/export/tests/it/movie_bytes.rs @@ -94,7 +94,7 @@ fn export_bytes(format: OutputFormat, name: &str) -> Vec { #[test] fn h264_movie_bytes_are_pinned() { let data = export_bytes(OutputFormat::H264, "/out/pinned.mp4"); - assert_eq!((data.len(), fnv1a(&data)), (104_180, 8_728_430_532_616_484_528), "H.264 MP4"); + assert_eq!((data.len(), fnv1a(&data)), (104_341, 17_655_680_090_264_218_914), "H.264 MP4"); } #[test] From e675df7cabe9ccb4b6f0f1f159c7db784d8fac10 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 18:20:08 -0400 Subject: [PATCH 078/112] Media: downmix AAC by where each decoded channel plays (#1701) The gains of an AAC source now come from the configuration FilmCraft decodes it with, read from MP4/MOV (esds), Matroska (CodecPrivate) and LATM in MPEG TS/PS (its first StreamMuxConfig). Channel configurations 3 to 6, 11 and 12 arrive reordered to WAVE order, 4 as L R C Cs, 7 in element order. A program config element goes by its element positions, except the six structures in which ffmpeg's encoder fills its own channel order across them (an LFE in a side SCE, a back centre in a side pair), recognised by its "Lavc" comment. ADTS can only signal configurations 1 to 7 that FilmCraft decodes, which the channel count tells apart. A decode of fewer channels than the layout keeps its first ones (7 of configuration 7's 8 in MPEG-TS LATM). --- Cargo.lock | 2 + Cargo.toml | 5 + crates/media/Cargo.toml | 2 + crates/media/src/aac.rs | 85 ++++++++++ crates/media/src/downmix.rs | 272 ++++++++++++++++++------------- crates/media/src/lib.rs | 1 + crates/media/src/pool.rs | 36 ++-- crates/media/src/probe.rs | 11 -- crates/media/src/stream.rs | 11 ++ crates/media/tests/it/footage.rs | 2 +- 10 files changed, 283 insertions(+), 144 deletions(-) create mode 100644 crates/media/src/aac.rs diff --git a/Cargo.lock b/Cargo.lock index 79ceb885c..21adef411 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1637,7 +1637,9 @@ dependencies = [ "filmcraft-color", "filmcraft-frame", "filmcraft-isobmff", + "filmcraft-matroska", "filmcraft-media", + "filmcraft-mpegts", "filmcraft-time", "gif", "image", diff --git a/Cargo.toml b/Cargo.toml index 57ea0d720..c101c489c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,9 @@ filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "09a filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +# container headers read for AAC channel layouts (crates/media) +filmcraft-matroska = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-mpegts = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency) filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } @@ -128,6 +131,8 @@ sys-locale = "0.3" # filmcraft-prores = { path = "../filmcraft/crates/prores" } # filmcraft-aac = { path = "../filmcraft/crates/aac" } # filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" } +# filmcraft-matroska = { path = "../filmcraft/crates/matroska" } +# filmcraft-mpegts = { path = "../filmcraft/crates/mpegts" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index d3f815bba..77bda60d0 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -23,6 +23,8 @@ filmcraft-codecs = { workspace = true } filmcraft-media = { workspace = true } filmcraft-aac = { workspace = true } filmcraft-isobmff = { workspace = true } +filmcraft-matroska = { workspace = true } +filmcraft-mpegts = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } diff --git a/crates/media/src/aac.rs b/crates/media/src/aac.rs new file mode 100644 index 000000000..a2d7de37b --- /dev/null +++ b/crates/media/src/aac.rs @@ -0,0 +1,85 @@ +//! The AudioSpecificConfig a movie's first AAC track decodes with, from the containers that carry +//! it outside the audio frames: MP4 / MOV (`esds`), Matroska (`CodecPrivate`) and LATM in MPEG +//! TS / PS (its StreamMuxConfig). FilmCraft's other AAC is ADTS (in MPEG TS / PS or a raw `.aac`), +//! whose channel configurations FilmCraft decodes (1 to 7) its channel count tells apart +//! ([`crate::downmix::Order::Aac`]). + +use std::io; + +use filmcraft_aac::AudioSpecificConfig; +use filmcraft_media::ByteReader; + +/// How far into an MPEG TS / PS file its first LATM frame is looked for: the stream is indexed +/// as far as this, not through the whole file. +const MPEG_HEAD: u64 = 8 << 20; + +/// The configuration of the first audio track, if it is AAC: from an MP4 / MOV `header` (its +/// ftyp and moov, or the whole file), or else from `file`, read as Matroska or MPEG TS / PS. +pub(crate) fn config(header: Option<&[u8]>, file: Option<&dyn ByteReader>) -> Option { + let asc = match header.and_then(mp4) { + Some(asc) => asc, + None => { + let file = file?; + let mut magic = [0u8; 4]; + file.read_at(0, &mut magic).ok()?; + if magic == [0x1A, 0x45, 0xDF, 0xA3] { matroska(file)? } else { latm(file)? } + } + }; + AudioSpecificConfig::parse(&asc).ok() +} + +fn mp4(bytes: &[u8]) -> Option> { + let file = filmcraft_isobmff::open(bytes).ok()?; + let track = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Audio)?; + match &track.entries.first()?.codec { + filmcraft_isobmff::CodecConfig::Aac(a) => Some(a.asc.clone()), + _ => None, + } +} + +fn matroska(file: &dyn ByteReader) -> Option> { + let opts = filmcraft_matroska::OpenOptions { index: false, ..Default::default() }; + let mkv = filmcraft_matroska::open_with(Head(file, file.len()), &opts).ok()?; + match &mkv.tracks.iter().find(|t| t.kind == filmcraft_matroska::TrackKind::Audio)?.codec { + filmcraft_matroska::Codec::Aac { asc } => Some(asc.clone()), + _ => None, + } +} + +fn latm(file: &dyn ByteReader) -> Option> { + let head = Head(file, file.len().min(MPEG_HEAD)); + let mpeg = filmcraft_mpegts::open(&head).ok()?; + let stream = mpeg.find(filmcraft_mpegts::Kind::Audio, |c| *c == filmcraft_mpegts::Codec::AacLatm)?; + let frame = mpeg.read_unit(&head, stream, 0).ok()?; + Some(filmcraft_codecs::audio::latm_config(&frame)?.asc) +} + +/// The first `.1` bytes of a file, as the byte source FilmCraft's demuxers read. +struct Head<'a>(&'a dyn ByteReader, u64); + +impl Head<'_> { + fn read(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + match offset.checked_add(buf.len() as u64) { + Some(end) if end <= self.1 => self.0.read_at(offset, buf), + _ => Err(io::Error::new(io::ErrorKind::UnexpectedEof, "read past the end")), + } + } +} + +impl filmcraft_matroska::ByteSource for Head<'_> { + fn len(&self) -> u64 { + self.1 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.read(offset, buf) + } +} + +impl filmcraft_mpegts::ByteSource for Head<'_> { + fn len(&self) -> u64 { + self.1 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.read(offset, buf) + } +} diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs index 745f28065..09bb1e1f6 100644 --- a/crates/media/src/downmix.rs +++ b/crates/media/src/downmix.rs @@ -10,17 +10,19 @@ //! exceed full scale: 5.1 is scaled by 1/(1 + 0.707 + 0.707). Mono stays duplicated at unity and //! stereo is untouched. //! -//! Channel order follows the WAVE / SMPTE convention by channel count (what FilmCraft's AC-3, PCM, -//! FLAC and Opus readers deliver): 3 = L R C, 4 = quad L R Ls Rs, 5 = L R C Ls Rs, -//! 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. AAC arrives in either -//! that order or AAC's own (C L R first, LFE last) depending on its channel configuration -//! ([`aac_order`]). More than 8 channels carry no known layout: only the first two (front L/R) -//! are used. +//! A decoded channel's gains come from where it plays. WAVE / SMPTE order by channel count is what +//! FilmCraft's AC-3, PCM, FLAC and Opus readers deliver: 3 = L R C, 4 = quad L R Ls Rs, +//! 5 = L R C Ls Rs, 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. AAC +//! arrives in an order its configuration decides ([`aac_order`]). More than 8 channels in WAVE +//! order carry no known layout: only the first two (front L/R) are used. use std::f32::consts::FRAC_1_SQRT_2; +use std::sync::Arc; + +use filmcraft_aac::{AudioSpecificConfig, ProgramConfig}; /// A source channel's contribution to the left and right outputs, before normalisation. -type Pair = [f32; 2]; +pub type Pair = [f32; 2]; const FL: Pair = [1.0, 0.0]; const FR: Pair = [0.0, 1.0]; @@ -30,33 +32,18 @@ const SL: Pair = [FRAC_1_SQRT_2, 0.0]; const SR: Pair = [0.0, FRAC_1_SQRT_2]; const CS: Pair = [0.5, 0.5]; -/// The per-channel `[left, right]` gains for `count` channels in the given order, normalised. -/// `aac` selects AAC's native channel order. Empty for zero channels. -pub fn stereo_gains(count: usize, aac: bool) -> Vec { - let raw: &[Pair] = match (count, aac) { - (0, _) => &[], - (1, _) => &[[1.0, 1.0]], - (2, _) => &[FL, FR], - (3, false) => &[FL, FR, FC], - (3, true) => &[FC, FL, FR], - (4, false) => &[FL, FR, SL, SR], - (4, true) => &[FC, FL, FR, CS], - (5, false) => &[FL, FR, FC, SL, SR], - (5, true) => &[FC, FL, FR, SL, SR], - (6, false) => &[FL, FR, FC, LFE, SL, SR], - (6, true) => &[FC, FL, FR, SL, SR, LFE], - (7, false) => &[FL, FR, FC, LFE, CS, SL, SR], - (7, true) => &[FC, FL, FR, SL, SR, CS, LFE], - (8, false) => &[FL, FR, FC, LFE, SL, SR, SL, SR], - (8, true) => &[FC, FL, FR, SL, SR, SL, SR, LFE], - _ => &[FL, FR], - }; +/// AAC channel configuration 7 as FilmCraft decodes it (element order, not reordered). +const CONFIG_7: [Pair; 8] = [FC, FL, FR, SL, SR, SL, SR, LFE]; +/// AAC channel configuration 4 (C L R Cs), which FilmCraft reorders to L R C Cs. +const CONFIG_4: [Pair; 4] = [FL, FR, FC, CS]; + +/// `raw` scaled by the reciprocal of its larger row sum when that exceeds 1 (more than two +/// channels): full-scale input can never exceed full scale. +fn normalised(raw: &[Pair]) -> Vec { let mut out = raw.to_vec(); - // Channels beyond a known layout (more than 8) are silent. - out.resize(count, [0.0, 0.0]); let peak = out.iter().map(|g| g[0]).sum::().max(out.iter().map(|g| g[1]).sum::()); // Mono is deliberately left at unity on both sides; so is anything already within range. - if count > 2 && peak > 1.0 { + if out.len() > 2 && peak > 1.0 { for g in &mut out { g[0] /= peak; g[1] /= peak; @@ -65,63 +52,109 @@ pub fn stereo_gains(count: usize, aac: bool) -> Vec { out } -/// The order a source's decoded channels arrive in. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +/// The per-channel `[left, right]` gains for `count` channels in WAVE / SMPTE order, normalised. +/// Empty for zero channels. +pub fn stereo_gains(count: usize) -> Vec { + let raw: &[Pair] = match count { + 0 => &[], + 1 => &[[1.0, 1.0]], + 2 => &[FL, FR], + 3 => &[FL, FR, FC], + 4 => &[FL, FR, SL, SR], + 5 => &[FL, FR, FC, SL, SR], + 6 => &[FL, FR, FC, LFE, SL, SR], + 7 => &[FL, FR, FC, LFE, CS, SL, SR], + 8 => &[FL, FR, FC, LFE, SL, SR, SL, SR], + _ => &[FL, FR], + }; + let mut raw = raw.to_vec(); + // Channels beyond a known layout (more than 8) are silent. + raw.resize(count, [0.0, 0.0]); + normalised(&raw) +} + +/// Where a source's decoded channels play. +#[derive(Clone, Debug, Default, PartialEq)] pub enum Order { /// WAVE / SMPTE by channel count: PCM, FLAC, AC-3, Opus and the rest. #[default] Wav, - /// AAC that FilmCraft reorders to WAVE / SMPTE (configurations 3 to 6, 11 and 12; 4 becomes - /// L R C Cs) or that ffmpeg wrote in it (a program config element listing the front pair - /// first: ffmpeg fills a PCE's elements in its own channel order and reads them back so). - AacWav, - /// AAC in its element order (C L R … LFE), which FilmCraft keeps: configuration 7 and a PCE - /// listing the centre first. - AacElements, - /// AAC whose configuration the container doesn't show us (only MP4 / MOV headers are read): - /// as [`Order::AacWav`] up to 7 channels (configurations 3 to 6, 11 and ffmpeg's 6.1 PCE), - /// as [`Order::AacElements`] above (configuration 7, ffmpeg's 7.1). + /// AAC whose configuration wasn't read: by channel count as the configurations 1 to 7 decode + /// (exact for ADTS, which can signal no other configuration FilmCraft decodes), 7 channels as + /// configuration 11. Aac, + /// Each decoded channel's gains, before normalisation. + Speakers(Arc<[Pair]>), } -/// How FilmCraft's decoder hands over the AAC described by the AudioSpecificConfig `asc`. -pub fn aac_order(asc: &[u8]) -> Order { - let Ok(asc) = filmcraft_aac::AudioSpecificConfig::parse(asc) else { return Order::Aac }; - match (asc.channel_config, asc.pce) { - (3..=6 | 11 | 12, _) => Order::AacWav, - (0, Some(pce)) if pce.front.first().is_some_and(|&(cpe, _)| cpe) => Order::AacWav, - (0, None) => Order::Aac, - _ => Order::AacElements, +/// How FilmCraft's decoder hands over AAC with the configuration `asc`: it reorders channel +/// configurations 3 to 6, 11 and 12 to WAVE / SMPTE and leaves 7 and PCE layouts in element order. +pub fn aac_order(asc: &AudioSpecificConfig) -> Order { + match (asc.channel_config, &asc.pce) { + (4, _) => Order::Speakers(CONFIG_4.into()), + (1..=6 | 11 | 12, _) => Order::Wav, + (7, _) => Order::Speakers(CONFIG_7.into()), + (0, Some(pce)) => Order::Speakers(pce_speakers(pce).into()), + _ => Order::Aac, } } -/// The gains for `count` channels arriving in `order`. -pub fn decoded_gains(count: usize, order: Order) -> Vec { - let order = match order { - Order::Aac if (3..=7).contains(&count) => Order::AacWav, - Order::Aac => Order::AacElements, - o => o, - }; - match (order, count) { - // C L R Cs, reordered to L R C Cs: the same gains, permuted. - (Order::AacWav, 4) => { - let g = stereo_gains(4, true); - [1, 2, 0, 3].iter().filter_map(|&i| g.get(i).copied()).collect() +/// ffmpeg's encoder fills a program config element's elements with its layout's channels in its +/// own order (L R C LFE …), and for these structures that order doesn't match the element +/// positions: an LFE in a side SCE, a back centre in a side pair. Front, side and back element +/// types (`true` for a pair) and the channels of the ffmpeg layout with that structure (the first +/// ffmpeg 8.1 lists, where several share it: 6.1 for 6.1(back) and 6.1(front), 7.1(wide) for +/// 7.1(wide-side)). +const FFMPEG_PCE: [(&[bool], &[bool], &[bool], &[Pair]); 6] = [ + (&[true, false], &[false], &[false], &[FL, FR, FC, LFE, CS]), + (&[true, false], &[false], &[true], &[FL, FR, FC, LFE, SL, SR]), + (&[true, false], &[true], &[false], &[FL, FR, FC, CS, SL, SR]), + (&[true, false], &[false], &[true, false], &[FL, FR, FC, LFE, CS, SL, SR]), + (&[true, false], &[false], &[true, true], &[FL, FR, FC, LFE, SL, SR, FL, FR]), + (&[true, false], &[true], &[true, false], &[FL, FR, FC, SL, SR, CS, SL, SR]), +]; + +/// The channels of a PCE layout in decoded (element) order: ffmpeg's for its structures above +/// (it names itself in the PCE's comment), otherwise by position (ISO/IEC 14496-3's program config element): +/// front pairs left and right, a front single the centre, side and back pairs surrounds, a side +/// or back single a back centre. +fn pce_speakers(pce: &ProgramConfig) -> Vec { + let pairs = |els: &[(bool, u8)]| els.iter().map(|e| e.0).collect::>(); + let (front, side, back) = (pairs(&pce.front), pairs(&pce.side), pairs(&pce.back)); + if pce.comment.starts_with(b"Lavc") && pce.lfe.is_empty() { + let known = FFMPEG_PCE.iter().find(|(f, s, b, _)| *f == front.as_slice() && *s == side.as_slice() && *b == back.as_slice()); + if let Some((.., speakers)) = known { + return speakers.to_vec(); } - (Order::AacElements, _) => stereo_gains(count, true), - _ => stereo_gains(count, false), + } + let mut out = Vec::new(); + for pair in front { + out.extend_from_slice(if pair { &[FL, FR] } else { &[FC] }); + } + for pair in side.into_iter().chain(back) { + out.extend_from_slice(if pair { &[SL, SR] } else { &[CS] }); + } + out.extend(pce.lfe.iter().map(|_| LFE)); + out +} + +/// The gains for `count` channels arriving in `order`. FilmCraft decodes as many channels as the +/// container names, the first ones of the layout where that is fewer (MPEG-TS LATM names 7 of +/// configuration 7's 8, ffmpeg 4.2 MP4s 2 of a PCE's); more go by count. +pub fn decoded_gains(count: usize, order: &Order) -> Vec { + match order { + Order::Speakers(s) if count <= s.len() => normalised(s.get(..count).unwrap_or_default()), + Order::Aac if count == 4 => normalised(&CONFIG_4), + Order::Aac if count == 8 => normalised(&CONFIG_7), + _ => stereo_gains(count), } } /// Downmix planar `channels` arriving in `order` to interleaved stereo in `out` (`out.len() / 2` /// frames); frames a channel is too short for count as silence, extra frames in `out` stay as /// they are. -pub fn downmix_into(channels: &[Vec], order: Order, out: &mut [f32]) { - mix(channels, &decoded_gains(channels.len(), order), out); -} - -fn mix(channels: &[Vec], gains: &[Pair], out: &mut [f32]) { - for (ch, g) in channels.iter().zip(gains) { +pub fn downmix_into(channels: &[Vec], order: &Order, out: &mut [f32]) { + for (ch, g) in channels.iter().zip(&decoded_gains(channels.len(), order)) { if g[0] == 0.0 && g[1] == 0.0 { continue; } @@ -138,13 +171,14 @@ mod tests { #[test] fn every_channel_count_has_a_bounded_defined_matrix() { - for aac in [false, true] { + let pce = Order::Speakers([FL, FR, FC, FC, SL, SR, SL, SR, CS, LFE].into()); + for order in [Order::Wav, Order::Aac, Order::Speakers(CONFIG_7.into()), pce] { for n in 0..40usize { - let g = stereo_gains(n, aac); + let g = decoded_gains(n, &order); assert_eq!(g.len(), n); let (l, r) = (g.iter().map(|p| p[0]).sum::(), g.iter().map(|p| p[1]).sum::()); if n > 2 { - assert!(l <= 1.0001 && r <= 1.0001, "{n} channels (aac {aac}): {l} {r}"); + assert!(l <= 1.0001 && r <= 1.0001, "{n} channels ({order:?}): {l} {r}"); assert!(l > 0.0 && r > 0.0); } } @@ -155,75 +189,87 @@ mod tests { const PCE_FFMPEG_61: &str = "118004c848002000c4400d4c61766336322e32382e31303256e500"; const PCE_FILMCRAFT_61: &str = "118004c845000108840000"; - fn asc(hex: &str) -> Vec { - (0..hex.len() / 2).filter_map(|i| hex.get(2 * i..2 * i + 2).and_then(|b| u8::from_str_radix(b, 16).ok())).collect() + fn order(hex: &str) -> Order { + let asc: Vec = (0..hex.len() / 2).filter_map(|i| hex.get(2 * i..2 * i + 2).and_then(|b| u8::from_str_radix(b, 16).ok())).collect(); + aac_order(&AudioSpecificConfig::parse(&asc).expect("an AudioSpecificConfig")) + } + + fn speakers(order: &Order) -> Vec { + match order { + Order::Speakers(s) => s.to_vec(), + o => panic!("{o:?} names no speakers"), + } } #[test] fn aac_order_follows_the_channel_configuration() { - // AAC-LC at 48 kHz with channel configuration n - let config = |n: u8| aac_order(&[0x11, 0x80 | n << 3]); - for n in [3, 4, 5, 6, 11, 12] { - assert_eq!(config(n), Order::AacWav, "configuration {n}"); + for n in [1, 2, 3, 5, 6, 11, 12] { + assert_eq!(aac_order(&AudioSpecificConfig::lc(3, n)), Order::Wav, "configuration {n}"); } - for n in [1, 2, 7] { - assert_eq!(config(n), Order::AacElements, "configuration {n}"); - } - assert_eq!(aac_order(&asc(PCE_FFMPEG_61)), Order::AacWav, "ffmpeg's 6.1 PCE lists L R first"); - assert_eq!(aac_order(&asc(PCE_FILMCRAFT_61)), Order::AacElements, "a PCE listing the centre first"); - assert_eq!(aac_order(&[]), Order::Aac); + assert_eq!(speakers(&aac_order(&AudioSpecificConfig::lc(3, 4))), [FL, FR, FC, CS]); + assert_eq!(speakers(&aac_order(&AudioSpecificConfig::lc(3, 7))), [FC, FL, FR, SL, SR, SL, SR, LFE]); + assert_eq!(speakers(&order(PCE_FFMPEG_61)), [FL, FR, FC, LFE, CS, SL, SR], "ffmpeg's 6.1 carries the LFE in a side SCE"); + assert_eq!(speakers(&order(PCE_FILMCRAFT_61)), [FC, FL, FR, SL, SR, CS, LFE], "a centre-first PCE by position"); } #[test] - fn decoded_aac_gains_follow_the_order() { - for n in [3, 5, 6, 7, 8] { - assert_eq!(decoded_gains(n, Order::AacWav), stereo_gains(n, false), "{n} channels"); - assert_eq!(decoded_gains(n, Order::AacElements), stereo_gains(n, true), "{n} channels"); - } - let g = decoded_gains(4, Order::AacWav); - assert_eq!(g.len(), 4); - assert!(g[0][0] > 0.0 && g[0][1] == 0.0 && g[1][1] > 0.0 && g[1][0] == 0.0, "L R first"); - assert!(g[2][0] > 0.0 && g[2][0] == g[2][1], "the centre is third and feeds both sides"); - assert!(g[3][0] > 0.0 && g[3][0] < g[2][0], "the rear centre is last, below the centre"); - assert_eq!(decoded_gains(4, Order::Wav), stereo_gains(4, false), "PCM 4 channels are quad"); - for n in [3, 4, 5, 6, 7] { - assert_eq!(decoded_gains(n, Order::Aac), decoded_gains(n, Order::AacWav), "{n} channels"); - } - for n in [1, 2, 8, 9] { - assert_eq!(decoded_gains(n, Order::Aac), stereo_gains(n, true), "{n} channels"); + fn only_ffmpeg_pce_structures_it_misplaces_take_its_channel_order() { + let pce = |comment: &[u8], side: Vec<(bool, u8)>, lfe: Vec| ProgramConfig { + front: vec![(true, 0), (false, 0)], + side, + back: vec![(true, 1)], + lfe, + comment: comment.to_vec(), + ..Default::default() + }; + // 5.1(side): ffmpeg's LFE sits in the side SCE + assert_eq!(pce_speakers(&pce(b"Lavc58.54.100", vec![(false, 1)], vec![])), [FL, FR, FC, LFE, SL, SR]); + // the same structure from another encoder: a back centre by position + assert_eq!(pce_speakers(&pce(b"", vec![(false, 1)], vec![])), [FL, FR, FC, CS, SL, SR]); + // 7.0: ffmpeg's order matches the positions + assert_eq!(pce_speakers(&pce(b"Lavc62.28.102", vec![(true, 2)], vec![])), [FL, FR, FC, SL, SR, SL, SR]); + // a real LFE element is never ffmpeg's misplaced one + assert_eq!(pce_speakers(&pce(b"Lavc62.28.102", vec![(false, 1)], vec![0])), [FL, FR, FC, CS, SL, SR, LFE]); + } + + #[test] + fn a_decode_of_fewer_channels_takes_the_first_of_the_layout_and_more_go_by_count() { + assert_eq!(decoded_gains(2, &order(PCE_FILMCRAFT_61)), [FC, FL]); + assert_eq!(decoded_gains(7, &Order::Speakers(CONFIG_7.into())), normalised(&CONFIG_7[..7])); + assert_eq!(decoded_gains(9, &Order::Speakers(CONFIG_7.into())), stereo_gains(9)); + assert_eq!(decoded_gains(4, &Order::Aac), normalised(&[FL, FR, FC, CS])); + assert_eq!(decoded_gains(8, &Order::Aac), normalised(&CONFIG_7)); + for n in [3, 5, 6, 7] { + assert_eq!(decoded_gains(n, &Order::Aac), stereo_gains(n), "{n} channels"); } } #[test] fn five_one_matches_the_bs775_matrix() { let k = 1.0 / (1.0 + 2.0 * FRAC_1_SQRT_2); - let g = stereo_gains(6, false); + let g = stereo_gains(6); assert!((g[0][0] - k).abs() < 1e-6 && g[0][1] == 0.0); assert!((g[2][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && (g[2][1] - k * FRAC_1_SQRT_2).abs() < 1e-6); assert_eq!(g[3], [0.0, 0.0], "LFE is left out"); assert!((g[4][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[4][1] == 0.0); assert!((g[5][1] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[5][0] == 0.0); - // AAC order: the centre is first, LFE last - let a = stereo_gains(6, true); - assert_eq!((a[0], a[5]), (g[2], g[3])); - assert_eq!((a[1], a[2]), (g[0], g[1])); } #[test] fn mono_and_stereo_are_untouched_and_wide_layouts_use_the_front_pair() { - assert_eq!(stereo_gains(1, false), vec![[1.0, 1.0]]); - assert_eq!(stereo_gains(2, true), vec![FL, FR]); - let g = stereo_gains(12, false); + assert_eq!(stereo_gains(1), vec![[1.0, 1.0]]); + assert_eq!(stereo_gains(2), vec![FL, FR]); + let g = stereo_gains(12); assert_eq!((g[0], g[1], g[2]), (FL, FR, [0.0, 0.0])); } #[test] fn ragged_and_empty_input_never_panics() { let mut out = vec![0.0; 8]; - downmix_into(&[], Order::Wav, &mut out); - downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], Order::Aac, &mut out); + downmix_into(&[], &Order::Wav, &mut out); + downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], &Order::Aac, &mut out); assert!(out.iter().all(|v| v.is_finite())); let mut none: [f32; 0] = []; - downmix_into(&vec![vec![1.0; 3]; 7], Order::Wav, &mut none); + downmix_into(&vec![vec![1.0; 3]; 7], &Order::Speakers(CONFIG_7.into()), &mut none); } } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 306f8d860..d9df8944a 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -15,6 +15,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod aac; mod convert; pub mod downmix; mod dpx; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 193804447..655ca4ed8 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -193,21 +193,20 @@ struct Opened { } impl Opened { - /// `src` with what its MP4/MOV `header` (ftyp and moov) says, if it has one. - fn new(src: SharedSource, header: Option<&[u8]>) -> Opened { + /// `src` with what its container says: `header` is an MP4/MOV's ftyp and moov (or the whole + /// file), `file` the file for other containers' AAC configuration. + fn new(src: SharedSource, header: Option<&[u8]>, file: Option<&dyn filmcraft_media::ByteReader>) -> Opened { let turn = header.map_or(Orientation::NoTransforms, crate::probe::video_turn); - let order = channel_order(&src, header.and_then(crate::probe::aac_config).as_deref()); - Opened { src, turn, order } + Opened { order: channel_order(&src, header, file), src, turn } } } -/// The order `src`'s audio channels decode in, AAC's decided by its AudioSpecificConfig `asc` -/// when the container's header was read. -fn channel_order(src: &SharedSource, asc: Option<&[u8]>) -> Order { - match (src.info().audio().is_some_and(|a| a.codec.starts_with("AAC")), asc) { - (false, _) => Order::Wav, - (true, Some(asc)) => crate::downmix::aac_order(asc), - (true, None) => Order::Aac, +/// The order `src`'s audio channels decode in; multichannel AAC's by its configuration, where +/// the container's header shows it ([`crate::aac::config`]). +fn channel_order(src: &SharedSource, header: Option<&[u8]>, file: Option<&dyn filmcraft_media::ByteReader>) -> Order { + match src.info().audio() { + Some(a) if a.codec.contains("AAC") && a.channels > 2 => crate::aac::config(header, file).map_or(Order::Aac, |asc| crate::downmix::aac_order(&asc)), + _ => Order::Wav, } } @@ -551,14 +550,14 @@ impl MediaPool { return Err(format!("a sample rate of {native} Hz is outside {MIN_SAMPLE_RATE}–{MAX_SAMPLE_RATE} Hz")); } if native == 0 || rate == 0 || native == rate { - return read_stereo(&src, order, s0, frames, rate); + return read_stereo(&src, &order, s0, frames, rate); } // Output sample `n` sits at source position n × native / rate. let (native_i, rate_i) = (i128::from(native), i128::from(rate)); let first = (i128::from(s0) * native_i).div_euclid(rate_i); let last = ((i128::from(s0) + frames as i128) * native_i).div_euclid(rate_i) + 1; let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return Ok(vec![0.0; frames * 2]) }; - let buf = read_stereo(&src, order, first64, count, native)?; + let buf = read_stereo(&src, &order, first64, count, native)?; let mut out = Vec::with_capacity(frames * 2); for k in 0..frames as i128 { let pos = (i128::from(s0) + k) * native_i; @@ -636,15 +635,14 @@ impl Inner { let file = std::path::Path::new(&**path); let in_place = if in_memory { None } else { crate::stream::open_source(file) }; let in_place = in_place.map(|src| { - let header = crate::stream::movie_header(file); - src.map(|src| Opened::new(src, header.as_deref())) + let (header, reader) = (crate::stream::movie_header(file), crate::stream::file_reader(file)); + src.map(|src| Opened::new(src, header.as_deref(), reader.as_deref())) }); let opened = in_place.unwrap_or_else(|| { self.read(path).and_then(|bytes| { let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let (turn, asc) = (crate::probe::video_turn(&bytes), crate::probe::aac_config(&bytes)); - let src = filmcraft_codecs::open_bytes(&name, bytes).map_err(MediaError::from)?; - Ok(Opened { order: channel_order(&src, asc.as_deref()), src, turn }) + let src = filmcraft_codecs::open_bytes(&name, bytes.clone()).map_err(MediaError::from)?; + Ok(Opened::new(src, Some(&bytes), Some(&filmcraft_media::reader::MemReader(bytes.clone())))) }) }); match opened { @@ -978,7 +976,7 @@ impl FootageSource for MediaPool { /// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which /// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both /// channels, multichannel sources (channels in `order`) are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails. -fn read_stereo(src: &SharedSource, order: Order, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { +fn read_stereo(src: &SharedSource, order: &Order, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { let mut out = vec![0.0; frames * 2]; let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0); if skip >= frames { diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 435f9ce57..9a7ebfa1d 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -259,17 +259,6 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> im Ok(img) } -/// The AudioSpecificConfig of an MP4/MOV movie's first audio track (the one FilmCraft plays), if -/// that is AAC. -pub(crate) fn aac_config(bytes: &[u8]) -> Option> { - let file = filmcraft_isobmff::open(bytes).ok()?; - let track = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Audio)?; - match &track.entries.first()?.codec { - filmcraft_isobmff::CodecConfig::Aac(a) => Some(a.asc.clone()), - _ => None, - } -} - /// The rotation an MP4/MOV movie is shown with (a phone held upright records sideways frames and /// says so in its video track's header): the quarter turn nearest the track's rotation. Only the /// rotation counts; any mirroring, scaling or offset in the header is ignored, and other diff --git a/crates/media/src/stream.rs b/crates/media/src/stream.rs index 447e239d1..80db21fc4 100644 --- a/crates/media/src/stream.rs +++ b/crates/media/src/stream.rs @@ -85,6 +85,17 @@ pub(crate) fn open_source(_path: &Path) -> Option> { None } +/// `path` read in place (`None` when it can't be opened, or without a native file system). +#[cfg(any(unix, windows))] +pub(crate) fn file_reader(path: &Path) -> Option> { + Some(Box::new(FileReader::open(path).ok()?)) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn file_reader(_path: &Path) -> Option> { + None +} + /// The leading `ftyp` and the `moov` box of an ISO-BMFF movie on disk (all a track's display /// matrix needs), without reading the samples: top-level box headers are walked with positional /// reads. `None` when the file isn't ISO-BMFF or its `moov` is missing or implausibly large. diff --git a/crates/media/tests/it/footage.rs b/crates/media/tests/it/footage.rs index 4d539c4e5..c57cec7eb 100644 --- a/crates/media/tests/it/footage.rs +++ b/crates/media/tests/it/footage.rs @@ -915,7 +915,7 @@ fn flac_audio_of_any_shape_reads_in_device_blocks() { // ffmpeg's sine source has an amplitude of 1/8 (mono is duplicated). The fixture carries // channels 0 and 1 at full level and the others at half, so a multichannel file sounds as // its stereo downmix (tests/it/downmix.rs checks that matrix channel by channel). - let gains = effectcraft_media::downmix::stereo_gains(channels as usize, false); + let gains = effectcraft_media::downmix::stereo_gains(channels as usize); let side = |s: usize| gains.iter().enumerate().map(|(c, g)| f64::from(g[s]) * if c < 2 { 1.0 } else { 0.5 }).sum::(); let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt(); let rms = (sum / n.max(1) as f64).sqrt(); From ec86f24afeaf54da8a4ea661c00c5d7ee89b9f30 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 18:20:09 -0400 Subject: [PATCH 079/112] Media tests: every AAC layout mixes by speaker in MP4, Matroska and ADTS (#1701) Tones encoded with FilmCraft's AAC encoder, with the configuration rewritten to 11, 12 or ffmpeg's 6.1 PCE where the coded elements fit it, so nothing depends on ffmpeg or skips. Expected gains come from each channel's speaker. --- crates/media/tests/it/aac_layout.rs | 224 ++++++++++++++++++++++++++++ crates/media/tests/it/main.rs | 1 + 2 files changed, 225 insertions(+) create mode 100644 crates/media/tests/it/aac_layout.rs diff --git a/crates/media/tests/it/aac_layout.rs b/crates/media/tests/it/aac_layout.rs new file mode 100644 index 000000000..9e5ddbb23 --- /dev/null +++ b/crates/media/tests/it/aac_layout.rs @@ -0,0 +1,224 @@ +//! Multichannel AAC reaches stereo by where each channel plays, whatever order its channel +//! configuration decodes in (#1701). Fixtures are tones encoded with FilmCraft's AAC encoder (no +//! ffmpeg needed, so nothing is skipped), with the configuration rewritten where the coded +//! elements fit another: configurations 11 and 12 code the same elements as the encoder's 7- and +//! 8-channel output, and a program config element can name any of them. + +use std::f64::consts::PI; +use std::io::Cursor; +use std::path::PathBuf; +use std::sync::Arc; + +use effectcraft_media::{MediaPool, probe, probe_bytes}; +use effectcraft_time::Tick; +use filmcraft_aac::{AudioSpecificConfig, Encoder, EncoderConfig, ProgramConfig}; + +const RATE: u32 = 48_000; +/// Tone frequencies by channel; an LFE gets [`LFE_TONE`], which its element can carry. +const FREQS: [u32; 8] = [300, 500, 700, 900, 1100, 1300, 1500, 1700]; +const LFE_TONE: u32 = 60; +const AMP: f32 = 0.25; +const K: f64 = std::f64::consts::FRAC_1_SQRT_2; + +/// A channel's content, by where it plays. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Ch { + L, + R, + C, + Lfe, + Ls, + Rs, + Cs, +} + +impl Ch { + /// ITU-R BS.775 gains into `[left, right]`, before normalisation. + fn gains(self) -> [f64; 2] { + match self { + Ch::L => [1.0, 0.0], + Ch::R => [0.0, 1.0], + Ch::C => [K, K], + Ch::Lfe => [0.0, 0.0], + Ch::Ls => [K, 0.0], + Ch::Rs => [0.0, K], + Ch::Cs => [0.5, 0.5], + } + } +} + +struct Case { + name: &'static str, + /// What the encoder's input channels carry (its element order: C, L R, …, LFE last). + content: &'static [Ch], + /// The AudioSpecificConfig written to the container instead of the encoder's, if any. + asc: Option, +} + +fn lc(config: u8) -> Option { + Some(AudioSpecificConfig::lc(3, config)) +} + +fn pce(front: Vec<(bool, u8)>, side: Vec<(bool, u8)>, back: Vec<(bool, u8)>, lfe: Vec, comment: &[u8]) -> Option { + let pce = ProgramConfig { object_type: 1, sf_index: 3, front, side, back, lfe, comment: comment.to_vec(), ..Default::default() }; + Some(AudioSpecificConfig { pce: Some(pce), ..AudioSpecificConfig::lc(3, 0) }) +} + +use Ch::*; + +fn cases() -> Vec { + vec![ + Case { name: "config3", content: &[C, L, R], asc: None }, + Case { name: "config4", content: &[C, L, R, Cs], asc: None }, + Case { name: "config5", content: &[C, L, R, Ls, Rs], asc: None }, + Case { name: "config6", content: &[C, L, R, Ls, Rs, Lfe], asc: None }, + // configuration 7 as ffmpeg writes 7.1: the second and third pairs are surrounds + Case { name: "config7", content: &[C, L, R, Ls, Rs, Ls, Rs, Lfe], asc: None }, + Case { name: "config11", content: &[C, L, R, Ls, Rs, Cs, Lfe], asc: lc(11) }, + Case { name: "config12", content: &[C, L, R, Ls, Rs, Ls, Rs, Lfe], asc: lc(12) }, + // the encoder's own 6.1: a PCE listing the centre first + Case { name: "pce61-centre-first", content: &[C, L, R, Ls, Rs, Cs, Lfe], asc: None }, + // ffmpeg's 6.1 PCE: front pair and centre, a side SCE, a back pair and SCE, no LFE + // element, its channels in ffmpeg's order L R C LFE Cs Ls Rs. The decoder places the + // encoder's SCE 0 in the front SCE, pair 0 front, pair 1 back, SCE 1 in the back SCE (tag + // 1) and the LFE in the side SCE left over (tag 5). + Case { + name: "pce61-ffmpeg", + content: &[C, L, R, Cs, Ls, Rs, Lfe], + asc: pce(vec![(true, 0), (false, 0)], vec![(false, 5)], vec![(true, 1), (false, 1)], vec![], b"Lavc62.28.102"), + }, + ] +} + +fn freq(content: &[Ch], i: usize) -> u32 { + if content.get(i) == Some(&Lfe) { LFE_TONE } else { FREQS[i] } +} + +/// Two seconds of `content`'s tones encoded as AAC: the AudioSpecificConfig and access units. +fn encode(content: &[Ch], adts: bool) -> (Vec, Vec>) { + let n = content.len(); + let mut cfg = EncoderConfig::cbr(RATE, n, 96_000 * n as u32); + cfg.adts = adts; + let mut enc = Encoder::new(cfg).expect("encoder"); + let planes: Vec> = + (0..n).map(|c| (0..2 * RATE).map(|i| AMP * (2.0 * PI * f64::from(freq(content, c)) * f64::from(i) / f64::from(RATE)).sin() as f32).collect()).collect(); + let refs: Vec<&[f32]> = planes.iter().map(Vec::as_slice).collect(); + let mut units = enc.encode(&refs); + units.extend(enc.flush()); + (enc.audio_specific_config(), units) +} + +fn mp4(asc: Vec, channels: u32, units: &[Vec]) -> Vec { + use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).expect("writer"); + let t = w.add_track(TrackConfig::new(SampleEntry::aac(asc, channels, RATE), RATE)).expect("track"); + for u in units { + w.write_sample(t, WriteSample { data: u, duration: 1024, composition_offset: 0, is_sync: true }).expect("sample"); + } + w.finish().expect("finish").into_inner() +} + +fn mkv(asc: Vec, channels: u32, units: &[Vec]) -> Vec { + use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec}; + let mut t = TrackSpec::new(TrackKind::Audio, "A_AAC"); + t.codec_private = asc; + t.audio = Some((f64::from(RATE), u64::from(channels), None)); + let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![t], MuxOptions::default()).expect("writer"); + let frame_ns = 1_000_000_000 * 1024 / i64::from(RATE); + for (i, u) in units.iter().enumerate() { + w.write_frame(0, i as i64 * frame_ns, true, u, Some(frame_ns as u64)).expect("frame"); + } + w.finish().expect("finish").into_inner() +} + +/// The fixture written to disk as `name`. +fn write(name: &str, bytes: &[u8]) -> PathBuf { + let dir = std::env::temp_dir().join(format!("effectcraft-aac-layout-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("mkdir"); + let p = dir.join(name); + std::fs::write(&p, bytes).expect("write"); + p +} + +/// Amplitude of the tone at `freq` in one side of interleaved stereo. +fn amp(s: &[f32], side: usize, freq: u32) -> f64 { + let (mut re, mut im, mut n) = (0.0, 0.0, 0usize); + for (i, f) in s.as_chunks::<2>().0.iter().enumerate() { + let w = 2.0 * PI * f64::from(freq) * i as f64 / f64::from(RATE); + re += f64::from(f[side]) * w.cos(); + im += f64::from(f[side]) * w.sin(); + n += 1; + } + 2.0 * (re * re + im * im).sqrt() / n.max(1) as f64 +} + +/// Stereo of one second from 0.5 s in, through the media pool as footage reads it. +fn mix(pool: &MediaPool, footage: &effectcraft_project::Footage) -> Vec { + pool.audio_samples(footage, Tick::from_seconds_f64(0.5), RATE as usize, RATE) +} + +/// Each channel of `content` reaches each side at its BS.775 gain, normalised by the larger +/// side's sum (an LFE nowhere). +fn check(label: &str, content: &[Ch], s: &[f32]) { + let raw: Vec<[f64; 2]> = content.iter().map(|c| c.gains()).collect(); + let norm = raw.iter().map(|g| g[0]).sum::().max(raw.iter().map(|g| g[1]).sum::()).max(1.0); + for (i, (ch, g)) in content.iter().zip(&raw).enumerate() { + for side in 0..2 { + let (got, want) = (amp(s, side, freq(content, i)), f64::from(AMP) * g[side] / norm); + assert!( + (got - want).abs() <= f64::from(AMP) * 0.05, + "{label}: {ch:?} ({} Hz) -> {} is {got:.4}, expected {want:.4}", + freq(content, i), + ["L", "R"][side] + ); + } + } +} + +/// The LFE tone survives encoding, so its absence from the mix means it was left out. +fn lfe_is_coded(case: &Case, bytes: Vec) { + if !case.content.contains(&Lfe) { + return; + } + let src = filmcraft_codecs::open_bytes("lfe.mp4", bytes.into()).expect("open"); + let buf = src.audio(24_000, RATE as usize, RATE).expect("decode"); + let best = buf.channels.iter().map(|ch| amp(&ch.iter().flat_map(|&v| [v, v]).collect::>(), 0, LFE_TONE)).fold(0.0, f64::max); + assert!(best > 0.8 * f64::from(AMP), "{}: the LFE tone is coded ({best:.4})", case.name); +} + +#[test] +fn every_aac_layout_mixes_by_speaker_in_mp4() { + for case in cases() { + let (asc, units) = encode(case.content, false); + let asc = case.asc.as_ref().map_or(asc, AudioSpecificConfig::to_bytes); + let bytes = mp4(asc, case.content.len() as u32, &units); + lfe_is_coded(&case, bytes.clone()); + let p = write(&format!("{}.mp4", case.name), &bytes); + check(case.name, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + // in memory (`add_bytes`) as well as in place + let (path, bytes): (String, Arc<[u8]>) = (format!("/mem/{}.mp4", case.name), bytes.into()); + let pool = MediaPool::new(); + pool.add_bytes(&path, bytes.clone()); + check(&format!("{} in memory", case.name), case.content, &mix(&pool, &probe_bytes(&path, bytes).expect("probe"))); + } +} + +#[test] +fn matroska_aac_layouts_mix_by_speaker() { + for case in cases().into_iter().filter(|c| matches!(c.name, "config12" | "pce61-centre-first" | "pce61-ffmpeg")) { + let (asc, units) = encode(case.content, false); + let asc = case.asc.as_ref().map_or(asc, AudioSpecificConfig::to_bytes); + let p = write(&format!("{}.mkv", case.name), &mkv(asc, case.content.len() as u32, &units)); + check(case.name, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + } +} + +/// ADTS signals configurations 1 to 7 only, which the channel count tells apart. +#[test] +fn adts_aac_layouts_mix_by_speaker() { + for case in cases().into_iter().filter(|c| c.asc.is_none() && c.content.len() != 7) { + let (_, units) = encode(case.content, true); + let p = write(&format!("{}.aac", case.name), &units.concat()); + check(case.name, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + } +} diff --git a/crates/media/tests/it/main.rs b/crates/media/tests/it/main.rs index 5f7fb8037..69bed4a9f 100644 --- a/crates/media/tests/it/main.rs +++ b/crates/media/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, one binary (each module is a former `tests/.rs`). +mod aac_layout; mod downmix; mod exif_orientation; mod footage; From 799303ad68bcaac91bc197ecc7335d371efe23d5 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 18:20:09 -0400 Subject: [PATCH 080/112] Media tests: read ffmpeg 4.2's PCE fixtures with their real channel count (#1701) ffmpeg 4.2 writes 2 in the sample entry of an AAC PCE layout and FilmCraft decodes as many channels as that names, so the 6.1 check now reads a copy naming all seven instead of skipping. Configuration 12 moves to the synthetic layout tests. --- crates/media/tests/it/downmix.rs | 44 +++++++++++++------------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/crates/media/tests/it/downmix.rs b/crates/media/tests/it/downmix.rs index c139676d6..05205ab6a 100644 --- a/crates/media/tests/it/downmix.rs +++ b/crates/media/tests/it/downmix.rs @@ -168,22 +168,23 @@ fn each_channel_reaches_stereo_at_its_bs775_gain() { } } -/// AAC 7.1 in channel configuration 12 (back surrounds), which FilmCraft reorders where it -/// leaves ffmpeg's configuration 7 as coded: ffmpeg's 7.1 with its configuration rewritten (both -/// code the same elements). -#[test] -fn seven_one_aac_in_configuration_12() { - let c = cases().into_iter().find(|c| c.file == "dm71.m4a").expect("the 7.1 AAC case"); - let freqs: Vec = FREQS.iter().copied().take(c.names.len()).collect(); - let Some(p) = fixture(c.file, c.layout, c.names, &freqs, c.codec) else { return }; - let mut bytes = std::fs::read(&p).expect("read"); - let (asc, _) = aac_entry(&bytes).expect("an AAC track"); - assert_eq!(asc.get(..2), Some(&[0x11, 0xB8][..]), "AAC-LC 48 kHz, configuration 7"); - let at = bytes.windows(asc.len()).position(|w| w == asc).expect("the configuration in the file"); - bytes[at + 1] = 0xE0; - let p12 = fixtures().join("dm71c12.m4a"); - std::fs::write(&p12, bytes).expect("write"); - check(&Case { file: "dm71c12.m4a", ..c }, &p12); +/// `p`, or a copy whose AAC sample entry names all `channels` of its PCE layout: ffmpeg 4.2 writes +/// 2 there for a PCE layout (8.1 the real count), and FilmCraft decodes as many channels as the +/// sample entry names, so the other five of a 6.1 would never reach the mix. +fn whole_pce_entry(p: &Path, channels: usize) -> PathBuf { + let mut bytes = std::fs::read(p).expect("read"); + let Some((asc, 2)) = aac_entry(&bytes) else { return p.to_path_buf() }; + if channels <= 2 || filmcraft_aac::AudioSpecificConfig::parse(&asc).map(|a| a.channel_config).ok() != Some(0) { + return p.to_path_buf(); + } + // the `mp4a` entry's channelcount follows its 6 reserved bytes, data reference index and + // 8 reserved bytes + let at = bytes.windows(4).position(|w| w == b"mp4a").expect("an mp4a sample entry") + 4 + 6 + 2 + 8; + bytes[at..at + 2].copy_from_slice(&(channels as u16).to_be_bytes()); + eprintln!("{}: its sample entry names 2 of {channels} channels (ffmpeg 4.2): reading a copy naming all", p.display()); + let out = p.with_extension("entry.m4a"); + std::fs::write(&out, bytes).expect("write"); + out } /// The AudioSpecificConfig and sample entry channel count of an MP4's first AAC track. @@ -196,16 +197,7 @@ fn aac_entry(bytes: &[u8]) -> Option<(Vec, u32)> { } fn check(c: &Case, p: &Path) { - let pce_as_stereo = aac_entry(&std::fs::read(p).expect("read")).is_some_and(|(asc, entry)| { - let config = filmcraft_aac::AudioSpecificConfig::parse(&asc).map(|a| a.channel_config); - config.ok() == Some(0) && entry == 2 - }); - if pce_as_stereo && c.names.len() > 2 { - // ffmpeg 4.2 writes 2 in the sample entry of an AAC PCE layout (8.1 the real count), and - // that is all FilmCraft decodes. - eprintln!("{}: FilmCraft reads 2 of its {} channels: skipping", c.file, c.names.len()); - return; - } + let p = &whole_pce_entry(p, c.names.len()); let s = ours(p); let freqs: Vec = FREQS.iter().copied().take(c.names.len()).collect(); let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3) From 4daff9a74adc5c875273abcbc11bf4b53438b7cc Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 18:20:09 -0400 Subject: [PATCH 081/112] Export tests: say why the H.264 pin moved with FilmCraft 09aaf9b (#1701) --- crates/export/tests/it/movie_bytes.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/export/tests/it/movie_bytes.rs b/crates/export/tests/it/movie_bytes.rs index 7a3fba611..f72d6fbf7 100644 --- a/crates/export/tests/it/movie_bytes.rs +++ b/crates/export/tests/it/movie_bytes.rs @@ -94,6 +94,8 @@ fn export_bytes(format: OutputFormat, name: &str) -> Vec { #[test] fn h264_movie_bytes_are_pinned() { let data = export_bytes(OutputFormat::H264, "/out/pinned.mp4"); + // FilmCraft 09aaf9b's encoder lists its settings in the first frame's user-data SEI (211 + // bytes where 0.1.1's name took 50): 161 bytes more than the FilmCraft 2c5ba7a pin. assert_eq!((data.len(), fnv1a(&data)), (104_341, 17_655_680_090_264_218_914), "H.264 MP4"); } From 0ca3f72ac2175521c1a109dabb3f8a2242528709 Mon Sep 17 00:00:00 2001 From: becauseno7 <95076423+becauseno7@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:34:30 +0600 Subject: [PATCH 082/112] CC Bubbles: bubbles refract the layer's edge pixels on padded buffers too (#2024) Refraction samples are clamped to the layer rectangle instead of the buffer, so a sample past the layer's edge in a buffer with transparent padding (a half-resolution preview's, a text or shape layer's) repeats the layer's edge pixels rather than reaching the pale colour that stands in for transparent ones. Transparent pixels inside the layer still show it. Same bounds on the GPU path. Re-pins the four half-resolution CC Bubbles golden hashes (aarch64 macOS); the full-resolution ones are unchanged. Adds a CPU test and a GPU-vs-CPU parity test on padded buffers. --- crates/effects/src/lib.rs | 2 +- crates/effects/src/sim.rs | 2 +- crates/effects/src/sim/tests.rs | 39 +++++++++++++++++++++++++++ crates/effects/src/sim/weather.rs | 14 +++++++++- crates/effects/tests/it/sim_golden.rs | 8 +++--- crates/gpu/src/fx_sim.rs | 1 + crates/gpu/src/shaders/fx_sim.wgsl | 7 +++-- crates/gpu/src/tests/fx_sim.rs | 28 +++++++++++++++++++ 8 files changed, 92 insertions(+), 9 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 32e662fec..a5534bf86 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -117,7 +117,7 @@ pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; -pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; +pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan, wave_world_plan, diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 79a225f75..333549e7d 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -38,7 +38,7 @@ use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys}; pub use registry::{specs, sprite_plan}; pub use sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint}; pub(crate) use sprites::{combine, raster, splat}; -pub use weather::{Bubble, bubble_list, drizzle_drops}; +pub use weather::{Bubble, bubble_list, bubble_sample_bounds, drizzle_drops}; #[cfg(test)] mod tests; diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs index d5de4cb74..06ce973b2 100644 --- a/crates/effects/src/sim/tests.rs +++ b/crates/effects/src/sim/tests.rs @@ -112,6 +112,45 @@ fn bubbles_near_edges_show_the_layer() { assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)"); } +/// CC Bubbles on a 40 × 40 layer `img` at `scale`, in a buffer padded by 6 transparent pixels +/// (a half-resolution preview's layout). +fn padded_bubbles(img: Image, scale: f64, reflection: u32) -> Image { + let size = 40.0; + let s = crate::find("ec.sim.ccbubbles").unwrap(); + let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [size; 2]))).collect() }; + for (k, v) in [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))] { + params.values.insert(k.into(), v); + } + let ctx = EffectCtx { params: ¶ms, time: 0.3, layer_size: [size; 2], seed: 1, adjustment: false, env: EffectEnv::default() }; + let mut b = Buf { img, offset: [0.0; 2], scale }; + b.pad(6); + crate::apply(s, &ctx, b).img +} + +/// #2024: the same when the buffer has transparent padding around the layer: samples past the +/// layer's edges repeat its edge pixels, not the padding, and a clear hole inside the layer is +/// still refracted as the pale colour. +#[test] +fn bubbles_near_padded_edges_show_the_layer() { + let blue = |side| Image::filled(side, side, [0.0, 0.0, 1.0, 1.0]); + let pale = |img: &Image| img.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count(); + for (scale, side) in [(1.0, 40), (0.5, 20)] { + let counts = [0, 1].map(|reflection| { + let a = padded_bubbles(blue(side), scale, reflection); + assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn"); + pale(&a) + }); + assert_eq!(counts, [0, 0], "pale bubble pixels at scale {scale} (liquid, metal)"); + } + let mut holed = blue(40); + for y in 12..28 { + for x in 12..28 { + holed.set(x, y, [0.0; 4]); + } + } + assert!(pale(&padded_bubbles(holed, 1.0, 0)) > 0, "the hole refracts as the pale colour"); +} + #[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 b58b39b5e..347ab4939 100644 --- a/crates/effects/src/sim/weather.rs +++ b/crates/effects/src/sim/weather.rs @@ -1,6 +1,7 @@ use super::common::{h, hs}; use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster}; use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul}; +use crate::util::layer_rect_at; // ------------------------------------------------------------------ CC Rainfall / CC Snowfall @@ -160,11 +161,21 @@ pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec { .collect() } +/// CC Bubbles' refraction sample bounds in the pixels of a buffer with `offset` and `scale`: the +/// layer rectangle's first and last pixel centres (x0, y0, x1, y1). Samples clamped to them repeat +/// the layer's edge pixels instead of reaching transparent padding around it (#2024). +pub fn bubble_sample_bounds(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> [f32; 4] { + let (lx, ly, lw, lh) = layer_rect_at(ctx, offset, scale); + let (x0, y0) = (lx + 0.5, ly + 0.5); + [x0 as f32, y0 as f32, (lx + lw - 0.5).max(x0) as f32, (ly + lh - 0.5).max(y0) as f32] +} + pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let metal = pr.e("reflectionType") == 1; let shading = pr.e("shadingType"); let list = bubble_list(ctx, &b); + let [x0, y0, x1, y1] = bubble_sample_bounds(ctx, b.offset, b.scale); let src = b.img.clone(); let mut out = Image::new(src.width, src.height); raster( @@ -182,7 +193,8 @@ 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_clamped((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64); + let (sx, sy) = ((q.sx + dx * q.r * k).max(x0).min(x1), (q.sy + dy * q.r * k).max(y0).min(y1)); + let p = src.sample_bilinear_clamped(sx as f64, sy 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 3932418ad..388790901 100644 --- a/crates/effects/tests/it/sim_golden.rs +++ b/crates/effects/tests/it/sim_golden.rs @@ -441,10 +441,10 @@ const GOLDEN: &[(&str, u64)] = &[ ("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/0/8bpc/half/t0.5", 0x020755e48bdf8f43), + ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x4aac0f1332e6001e), + ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0xdc2ab5e04c4a95a2), + ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0x30f78adc5d1dd715), ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980), ("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0), ("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c), diff --git a/crates/gpu/src/fx_sim.rs b/crates/gpu/src/fx_sim.rs index a2d2c24dd..944ca6c07 100644 --- a/crates/gpu/src/fx_sim.rs +++ b/crates/gpu/src/fx_sim.rs @@ -253,6 +253,7 @@ fn bubbles(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf } let mut p = Params::default(); p.u[1] = [0, (ctx.params.e("reflectionType") == 1) as u32, ctx.params.e("shadingType"), 0]; + p.f[0] = effectcraft_effects::bubble_sample_bounds(ctx, b.offset, b.scale); let img = raster(e, items, 1, false, &b.img, None, p, w, h)?; Some(GBuf { img, ..b }) } diff --git a/crates/gpu/src/shaders/fx_sim.wgsl b/crates/gpu/src/shaders/fx_sim.wgsl index 8ef3035fe..0c6023f32 100644 --- a/crates/gpu/src/shaders/fx_sim.wgsl +++ b/crates/gpu/src/shaders/fx_sim.wgsl @@ -165,7 +165,8 @@ fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade { return FxmShade(true, vec4(rgb * a, a)); } -// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type. +// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type, f[0] = the layer +// rectangle's first and last pixel centres (x0, y0, x1, y1), which refraction samples stay within. fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade { let qx = data[at + 1u]; let qy = data[at + 2u]; @@ -180,7 +181,9 @@ 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_clamped(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k)); + let sx = min(max(data[at + 4u] + dx * qr * k, P.f[0].x), P.f[0].z); + let sy = min(max(data[at + 5u] + dy * qr * k, P.f[0].y), P.f[0].w); + let s = fxm_unpremul(sample_bilinear_clamped(src, sx, sy)); var c = s.xyz; if (s.w <= 0.0) { c = vec3(0.6, 0.7, 0.8); diff --git a/crates/gpu/src/tests/fx_sim.rs b/crates/gpu/src/tests/fx_sim.rs index dc2510bb3..4f54e07b6 100644 --- a/crates/gpu/src/tests/fx_sim.rs +++ b/crates/gpu/src/tests/fx_sim.rs @@ -105,6 +105,34 @@ fn bubbles_drizzle_mercury() { case("ec.sim.ccmrmercury", &[("birthRate", n(3.0)), ("blobInfluence", n(60.0)), ("shading/metal", n(80.0)), ("gravity", n(2.0))], 1.5, 0.0); } +/// CC Bubbles on a buffer with transparent padding around the layer: both sides clamp the +/// refraction samples to the layer rectangle, not to the buffer (#2024). +#[test] +fn bubbles_on_padded_buffers() { + let Some(g) = gpu() else { return }; + let spec = effectcraft_effects::find("ec.sim.ccbubbles").unwrap(); + let size = [70.0, 44.0]; + for reflection in [0, 1] { + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for (k, v) in [("bubbleAmount", n(80.0)), ("bubbleSize", n(4.0)), ("reflectionType", e(reflection))] { + params.values.insert(k.to_string(), v); + } + for scale in [1.0, 0.5] { + let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32); + let mut buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale }; + buf.pad(6); + let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.5, layer_size: size, seed: 11, adjustment: false, env: Default::default() }; + let cpu = (spec.render)(&ctx(), buf.clone()); + let out = + effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect"); + assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "geometry"); + let d = diff(&cpu.img, &out.img, 1e-3); + assert!(d.over == 0, "reflection {reflection} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", d.over, d.total, d.max, d.worst); + } + } +} + #[test] fn hair_wave_world_foam() { case("ec.sim.cchair", &[("density", n(300.0)), ("length", n(10.0))], 0.0, 0.0); From 2a61a05fd4556381ccacb3599ad916b90736484b Mon Sep 17 00:00:00 2001 From: Jason Luker <176962611+ZJHeepfixer@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:32:56 -0700 Subject: [PATCH 083/112] GPU: the readback deadline worker sleeps until a deadline is due instead of polling every 100 ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Readbacks::new` spawned a thread that slept 100 ms and checked for expired readbacks forever, whether or not anything was pending: ten wakeups a second for the life of every desktop session with a GPU compositor (12–13 idle wakeups/s measured, against 0.4–1.8 for the sibling apps). The worker now waits on a condition: indefinitely while nothing is pending, otherwise until the earliest deadline or until what is pending changes (start, complete, cancel, retire poke it). It owns the state behind an `Arc` rather than the `Readbacks`, so dropping the context closes it instead of leaking the thread. Timeout, exactly-once callbacks, panic isolation and the browser's timer path are unchanged. Measured on an M2 Max (release build, window on screen, 60 s samples, A/B interleaved): idle wakeups 12.15/s → 2.63/s not frontmost, 12.36/s → 2.72/s frontmost. Tests: an idle worker does not wake; a due deadline wakes it when due; dropping the context stops it; the existing panic/strand test moves deadlines through a hook that notifies the worker. Co-Authored-By: Claude Fable 5.1 --- crates/gpu/src/readback.rs | 208 ++++++++++++++++++++++++++++++------- 1 file changed, 170 insertions(+), 38 deletions(-) diff --git a/crates/gpu/src/readback.rs b/crates/gpu/src/readback.rs index 0ca011350..b9d0a28a8 100644 --- a/crates/gpu/src/readback.rs +++ b/crates/gpu/src/readback.rs @@ -2,12 +2,15 @@ // Browser callbacks stay on their worker thread. The workspace's wgpu feature still // requires Send callbacks on non-atomic wasm, and ParticleSim requires Send + Sync. use std::collections::BTreeMap; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Condvar; use std::sync::{Arc, Mutex, Weak}; use std::time::Duration; pub(crate) const TIMEOUT: Duration = Duration::from_secs(10); pub(crate) type Error = String; type Done = Box, Error>) + Send>; +const TIMED_OUT: &str = "GPU readback timed out; context retired"; struct Entry { deadline: web_time::Instant, @@ -20,9 +23,70 @@ struct State { retired: Option, #[cfg(target_arch = "wasm32")] timer_armed: bool, + /// The deadline worker is to exit: the context was dropped. + #[cfg(not(target_arch = "wasm32"))] + closed: bool, + /// How many times the deadline worker woke up (tests: it must not wake while idle). + #[cfg(all(test, not(target_arch = "wasm32")))] + worker_wakes: u64, +} +/// The state and, natively, the condition the deadline worker sleeps on. The worker owns an +/// `Arc` of this, not of the [`Readbacks`], so it can block on the condition without keeping +/// the context alive; dropping the context closes it. +#[derive(Default)] +struct Inner { + state: Mutex, + #[cfg(not(target_arch = "wasm32"))] + wake: Condvar, } #[derive(Default)] -pub(crate) struct Readbacks(Mutex); +pub(crate) struct Readbacks(Arc); + +/// Retire the context when a deadline has passed: the entries to fail, or `None` when none +/// is due (or it is retired already). Decides and drains under the lock the caller holds. +fn expire_locked(s: &mut State, now: web_time::Instant) -> Option> { + // Decide and drain atomically: a cancelled old deadline must not retire + // a newer request inserted between the deadline check and retirement. + if s.retired.is_some() || !s.pending.values().any(|e| now >= e.deadline) { + return None; + } + s.retired = Some(TIMED_OUT.to_string()); + Some(std::mem::take(&mut s.pending).into_values().collect()) +} + +/// The deadline worker. It sleeps while nothing is pending, and otherwise exactly until the +/// earliest deadline or until what is pending changes, so an idle context costs no wakeups +/// (a 100 ms polling loop here was ten wakeups a second for the life of the app). When a +/// deadline has passed it retires the context and fails every pending readback. +#[cfg(not(target_arch = "wasm32"))] +fn run_deadlines(inner: &Inner) { + let mut s = inner.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + loop { + if s.closed { + return; + } + let now = web_time::Instant::now(); + match s.pending.values().map(|e| e.deadline).min() { + None => s = inner.wake.wait(s).unwrap_or_else(std::sync::PoisonError::into_inner), + Some(deadline) if deadline > now => { + s = inner.wake.wait_timeout(s, deadline - now).unwrap_or_else(std::sync::PoisonError::into_inner).0; + } + Some(_) => { + let expired = expire_locked(&mut s, now); + drop(s); + for entry in expired.into_iter().flatten() { + notify(entry.done, Err(TIMED_OUT.into())); + } + s = inner.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + continue; + } + } + #[cfg(test)] + { + s.worker_wakes += 1; + } + } +} pub(crate) struct Ticket { owner: Weak, id: u64, @@ -50,23 +114,24 @@ impl Readbacks { let me = Arc::new(Self::default()); #[cfg(not(target_arch = "wasm32"))] { - let weak = Arc::downgrade(&me); + let inner = Arc::clone(&me.0); std::thread::Builder::new() .name("gpu-readback-deadlines".into()) - .spawn(move || { - loop { - std::thread::sleep(Duration::from_millis(100)); - let Some(me) = weak.upgrade() else { break }; - me.expire(web_time::Instant::now()); - } - }) + .spawn(move || run_deadlines(&inner)) .map_err(|e| format!("starting GPU readback deadline worker: {e}"))?; } Ok(me) } + /// Have the deadline worker look at what is pending again (natively; the browser's timer + /// re-arms itself). + fn poke(&self) { + #[cfg(not(target_arch = "wasm32"))] + self.0.wake.notify_one(); + } + pub(crate) fn check(&self) -> Result<(), Error> { - let s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); match &s.retired { Some(e) => Err(e.clone()), None => Ok(()), @@ -76,7 +141,7 @@ impl Readbacks { pub(crate) fn start(self: &Arc, done: impl FnOnce(Result, Error>) + wgpu::WasmNotSend + 'static) -> Result { let mut done: Option = Some(Box::new(done)); let result = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(e) = &s.retired { Err(e.clone()) } else if s.pending.len() >= 256 { @@ -96,8 +161,10 @@ impl Readbacks { { notify(done, Err(e.clone())); } - #[cfg(target_arch = "wasm32")] if result.is_ok() { + // The worker may be asleep with nothing pending: this is its new earliest deadline. + self.poke(); + #[cfg(target_arch = "wasm32")] self.arm_timer(); } result @@ -106,7 +173,7 @@ impl Readbacks { #[cfg(target_arch = "wasm32")] fn arm_timer(self: &Arc) { let delay = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if s.timer_armed || s.retired.is_some() { return; } @@ -123,13 +190,14 @@ impl Readbacks { pub(crate) fn retire(&self, error: Error) { let pending = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if s.retired.is_some() { return; } s.retired = Some(error.clone()); std::mem::take(&mut s.pending) }; + self.poke(); for (_, entry) in pending { notify(entry.done, Err(error.clone())); } @@ -137,34 +205,49 @@ impl Readbacks { pub(crate) fn cancel_pending(&self, error: Error) { let pending = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); std::mem::take(&mut s.pending) }; + self.poke(); for (_, entry) in pending { notify(entry.done, Err(error.clone())); } } + /// Retire the context when a deadline has passed (the browser's timer and tests; natively + /// the deadline worker does this itself). + #[cfg(any(test, target_arch = "wasm32"))] fn expire(&self, now: web_time::Instant) { - let error = "GPU readback timed out; context retired".to_string(); - let pending = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - // Decide and drain atomically: a cancelled old deadline must not retire - // a newer request inserted between the deadline check and retirement. - if s.retired.is_some() || !s.pending.values().any(|e| now >= e.deadline) { - return; - } - s.retired = Some(error.clone()); - std::mem::take(&mut s.pending) + let expired = { + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + expire_locked(&mut s, now) }; - for (_, entry) in pending { - notify(entry.done, Err(error.clone())); + for entry in expired.into_iter().flatten() { + notify(entry.done, Err(TIMED_OUT.into())); } } + + /// Move every pending deadline to `deadline` and wake the worker, as a test's stand-in for + /// waiting out the production timeout. + #[cfg(all(test, not(target_arch = "wasm32")))] + fn move_deadlines_for_test(&self, deadline: web_time::Instant) { + { + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for entry in s.pending.values_mut() { + entry.deadline = deadline; + } + } + self.poke(); + } } impl Drop for Readbacks { fn drop(&mut self) { self.retire("GPU readback context dropped".into()); + #[cfg(not(target_arch = "wasm32"))] + { + self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).closed = true; + self.0.wake.notify_all(); + } } } @@ -194,12 +277,14 @@ impl Layout { impl Ticket { pub(crate) fn active(&self) -> bool { - self.owner.upgrade().is_some_and(|me| me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.contains_key(&self.id)) + self.owner.upgrade().is_some_and(|me| me.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.contains_key(&self.id)) } pub(crate) fn complete(self, result: Result, Error>) { let Some(me) = self.owner.upgrade() else { return }; - let entry = me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.remove(&self.id); + let entry = me.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.remove(&self.id); if let Some(entry) = entry { + // Its deadline is gone: the worker sleeps until the next one, or until something new. + me.poke(); notify(entry.done, result); } } @@ -215,7 +300,7 @@ fn schedule(me: Weak, delay: Duration) -> Result<(), Error> { .map_err(|_| "GPU deadline timer unavailable".to_string())?; let callback = Closure::once(move || { if let Some(me) = me.upgrade() { - me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).timer_armed = false; + me.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).timer_armed = false; me.expire(web_time::Instant::now()); // If the earliest request completed, arm for the next live deadline. me.arm_timer(); @@ -377,7 +462,7 @@ mod tests { assert_eq!(deferred.in_flight(), 0); assert_eq!(wakes.0.load(Ordering::SeqCst), 1); assert!(matches!(settled.as_mut().poll(&mut Context::from_waker(&waker)), Poll::Ready(()))); - assert!(me.0.lock().unwrap().pending.is_empty()); + assert!(me.0.state.lock().unwrap().pending.is_empty()); rx.recv_timeout(Duration::from_secs(2)).unwrap(); rx.recv_timeout(Duration::from_secs(2)).unwrap(); @@ -390,17 +475,64 @@ mod tests { tx2.send(()).unwrap(); }) .unwrap(); - { - let mut state = me.0.lock().unwrap(); - for entry in state.pending.values_mut() { - entry.deadline = web_time::Instant::now(); - } - } + me.move_deadlines_for_test(web_time::Instant::now()); rx.recv_timeout(Duration::from_secs(2)).unwrap(); - assert!(me.0.lock().unwrap().pending.is_empty()); + assert!(me.0.state.lock().unwrap().pending.is_empty()); assert!(me.check().is_err()); } + /// With nothing pending the deadline worker sleeps: no periodic wakeups (it used to poll + /// every 100 ms for the life of the app, ten wakeups a second while idle). + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn an_idle_deadline_worker_does_not_wake_up() { + let me = Readbacks::new().unwrap(); + std::thread::sleep(Duration::from_millis(350)); + let wakes = me.0.state.lock().unwrap().worker_wakes; + assert!(wakes <= 1, "the worker woke {wakes} times while nothing was pending"); + // A readback that completes in time: the worker is told, and goes back to sleep. + let ticket = me.start(|r| assert!(r.is_ok())).unwrap(); + ticket.complete(Ok(vec![1])); + std::thread::sleep(Duration::from_millis(350)); + let wakes = me.0.state.lock().unwrap().worker_wakes; + assert!(wakes <= 3, "the worker woke {wakes} times for one completed readback"); + assert!(me.check().is_ok()); + } + + /// A deadline that passes wakes the worker exactly then, without the old polling loop. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn a_pending_deadline_wakes_the_worker_when_it_is_due() { + let me = Readbacks::new().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let _ticket = me + .start(move |r| { + assert!(r.is_err()); + tx.send(web_time::Instant::now()).unwrap(); + }) + .unwrap(); + let due = web_time::Instant::now() + Duration::from_millis(200); + me.move_deadlines_for_test(due); + let fired = rx.recv_timeout(Duration::from_secs(5)).expect("the deadline fires"); + assert!(fired >= due, "fired {:?} early", due - fired); + assert!(fired < due + Duration::from_secs(2), "fired {:?} late", fired - due); + assert!(me.check().is_err()); + } + + /// Dropping the last handle lets the worker exit: it holds the state, not the context. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn dropping_the_context_stops_the_deadline_worker() { + let me = Readbacks::new().unwrap(); + let inner = Arc::downgrade(&me.0); + drop(me); + let t0 = web_time::Instant::now(); + while inner.upgrade().is_some() { + assert!(t0.elapsed() < Duration::from_secs(5), "the worker is still holding the state"); + std::thread::sleep(Duration::from_millis(10)); + } + } + #[test] fn missing_callback_times_out_once_and_rejects_late_success() { let me = Arc::new(Readbacks::default()); From 26e6145cee673fe145e3772c7aa239126bfde644 Mon Sep 17 00:00:00 2001 From: Jason Luker <176962611+ZJHeepfixer@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:32:56 -0700 Subject: [PATCH 084/112] Footage: one watcher thread that wakes the interface only when a file changed, instead of a 2 s poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings ▸ Import ▸ Reload Footage Changed on Disk used to spawn a stat thread every two seconds from the UI thread, request a repaint 100 ms later to collect it, and request another repaint every 2 s in between, for every project with items, focused or not: the whole interface redrew on a timer for footage that hadn't changed. `footage_reload::Watcher` is one long-lived worker: the interface tells it what to watch (recomputed when the project revision or the preference changes) and whether the window is in front; it stamps the files every 2 s while the window is in front, every 10 s while another app has the front, at once on focus regain and on a new set, and hands the stamps over — calling `ctx.request_repaint()` — only when one differs from its last look. Nothing watched means the thread sleeps. `apply_stamps` and the reload semantics are unchanged; `start_scan`/`poll_scan`/`Session::footage_scan` are gone. No new dependencies: the stat loop is also what footage on a network share relies on, where FSEvents would not report another machine's writes. Tests: the watcher wakes the interface for the first stamps and for each change and never for an unchanged file; behind another app it rests until the window is back; nothing watched is nothing stamped and a new set is reported whole (and dropping it ends the thread); the host test `the_watcher_notices_a_footage_file_changed_on_disk` saves a PSD from "another app" and sees it reloaded once. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/footage/footage_reload.rs | 281 ++++++++++++++++--- crates/engine/src/lib.rs | 3 - crates/engine/src/settings/prefs/sections.rs | 3 +- crates/host/tests/it/footage_reload.rs | 51 +++- crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/app/frame.rs | 45 +-- crates/ui-egui/src/lib.rs | 5 + docs/preferences.md | 8 +- 8 files changed, 328 insertions(+), 70 deletions(-) diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index 267985e37..66a2dcf82 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -9,31 +9,35 @@ //! render reads the new pixels. //! //! The automatic part compares each footage file's size and modification time with what they -//! were when the file was last seen. The desktop app takes those stamps every two seconds (and -//! when its window comes back to the front) on a worker thread, so a file on a slow network share -//! never holds up the interface ([`start_scan`], [`poll_scan`]); `footage.reloadChanged` takes them -//! on the spot (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload -//! records the state of the disk: it is not an undo step and doesn't modify the project. +//! were when the file was last seen. The desktop app keeps a [`Watcher`]: a worker thread that +//! stamps the files every two seconds while the window is in front (every ten seconds while it +//! is not, and at once when it comes back to the front) and wakes the interface only when a stamp +//! changed, so a file on a slow network share never holds up the interface and an idle app never +//! redraws for footage that didn't change; `footage.reloadChanged` takes the stamps on the spot +//! (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload records +//! the state of the disk: it is not an undo step and doesn't modify the project. use std::collections::BTreeSet; -use std::sync::{Arc, Mutex}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, SourceLayer}; use serde_json::{Value, json}; -use crate::Session; +use crate::{Services, Session}; /// A file's size and modification time (see [`crate::Services::stamp`]). pub type Stamp = (u64, u128); -/// Stamps taken on a worker thread, waiting for [`poll_scan`]. -pub type Scan = Arc)>>>>; +/// The stamps of a set of files, in path order. +pub type Stamps = Vec<(String, Option)>; /// The files footage items read: each item's file and every frame of an image sequence. Image /// sequences count only with Automatically Reload Footage set to All Footage (After Effects' /// default, Non-Sequence Footage, leaves them alone: thousands of frames on a share add up). /// Items with a stored path that is not followed ([`crate::doc_path`]) are not watched. -fn watched(s: &Session) -> BTreeSet { +pub fn watched(s: &Session) -> BTreeSet { let sequences = s.prefs.import.auto_reload_footage == "all"; let mut paths = BTreeSet::new(); for it in s.project.items.values() { @@ -267,31 +271,244 @@ pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result { apply_stamps(s, now) } -/// Start stamping the watched files on a worker thread, unless a scan is still running (or the -/// build has no threads: the web app, whose footage lives in browser storage). -pub fn start_scan(s: &mut Session) { - if s.footage_scan.is_some() || cfg!(target_arch = "wasm32") { - return; +/// How often the watched files are stamped while the window is in front. +pub const FOCUSED_INTERVAL: Duration = Duration::from_secs(2); +/// How often they are stamped while another app has the front: a file saved there is noticed +/// as soon as the window comes back, so this only covers a second screen. +pub const UNFOCUSED_INTERVAL: Duration = Duration::from_secs(10); + +enum Watch { + Paths(BTreeSet), + Focused(bool), +} + +/// The desktop app's footage watcher: a worker thread that stamps the watched files and hands +/// the stamps over ([`Watcher::take`]) only when one of them changed since it last looked, +/// waking the interface through the callback given to [`Watcher::start`]. The interface tells it +/// what to watch and whether the window is in front; nothing watched means the thread sleeps. +/// Dropping the watcher ends the thread. +pub struct Watcher { + tx: Sender, + result: Arc>>, + paths: BTreeSet, + focused: Option, +} + +impl Watcher { + /// Start the worker, stamping through `services`; `wake` runs on the worker whenever new + /// stamps are ready to [`Watcher::take`]. `None` where there are no threads (the web app, + /// whose footage lives in browser storage) or one couldn't be started. + pub fn start(services: Arc, wake: impl Fn() + Send + Sync + 'static) -> Option { + Self::start_with(services, wake, FOCUSED_INTERVAL, UNFOCUSED_INTERVAL) } - let paths = watched(s); - if paths.is_empty() { - return; + + /// [`Watcher::start`] with its own stamping intervals (tests). + pub fn start_with(services: Arc, wake: impl Fn() + Send + Sync + 'static, focused: Duration, unfocused: Duration) -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + let (tx, rx) = std::sync::mpsc::channel(); + let result: Arc>> = Arc::default(); + let out = Arc::clone(&result); + std::thread::Builder::new().name("footage-watch".into()).spawn(move || watch(&rx, &*services, &out, &wake, focused, unfocused)).ok()?; + Some(Watcher { tx, result, paths: BTreeSet::new(), focused: None }) } - let services = s.services.clone(); - let slot: Scan = Arc::default(); - let out = slot.clone(); - let spawned = std::thread::Builder::new().name("footage-watch".into()).spawn(move || { - let stamps = paths.into_iter().map(|p| (p.clone(), services.stamp(&p))).collect(); - *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stamps); - }); - if spawned.is_ok() { - s.footage_scan = Some(slot); + + /// The files to watch ([`watched`]); a changed set is stamped at once. + pub fn set_watched(&mut self, paths: BTreeSet) { + if paths != self.paths { + self.paths = paths.clone(); + let _ = self.tx.send(Watch::Paths(paths)); + } + } + + /// Whether the window is in front; coming back to the front stamps the files at once. + pub fn set_focused(&mut self, focused: bool) { + if self.focused != Some(focused) { + self.focused = Some(focused); + let _ = self.tx.send(Watch::Focused(focused)); + } + } + + /// Stamps the worker found changed (or the first ones for a new set of files), once; for + /// [`apply_stamps`]. `None` while nothing changed. + pub fn take(&self) -> Option { + self.result.lock().unwrap_or_else(PoisonError::into_inner).take() } } -/// Apply a finished scan (`None` while there is none, or it is still running). -pub fn poll_scan(s: &mut Session) -> Option> { - let stamps = s.footage_scan.as_ref()?.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?; - s.footage_scan = None; - Some(apply_stamps(s, stamps)) +/// The watcher's worker: wait for a message or the interval, stamp, report only changes. +fn watch( + rx: &Receiver, + services: &dyn Services, + out: &Mutex>, + wake: &(dyn Fn() + Send + Sync), + focused_every: Duration, + unfocused_every: Duration, +) { + let mut paths = BTreeSet::new(); + let mut focused = true; + let mut last: Option = None; + loop { + let msg = if paths.is_empty() { + rx.recv().map_err(|_| RecvTimeoutError::Disconnected) + } else { + rx.recv_timeout(if focused { focused_every } else { unfocused_every }) + }; + match msg { + Ok(Watch::Paths(p)) => { + paths = p; + // A new set is reported whole, so the session forgets files no longer watched. + last = None; + } + Ok(Watch::Focused(f)) => { + let regained = f && !focused; + focused = f; + if !regained { + continue; + } + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => return, + } + if paths.is_empty() { + continue; + } + let stamps: Stamps = paths.iter().map(|p| (p.clone(), services.stamp(p))).collect(); + if last.as_ref() != Some(&stamps) { + last = Some(stamps.clone()); + *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps); + wake(); + } + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod watcher_tests { + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + /// Files whose stamps the test sets; counts how often they were stamped. + #[derive(Default)] + struct Disk { + stamps: Mutex>, + looks: AtomicUsize, + } + impl Disk { + fn set(&self, path: &str, stamp: Stamp) { + self.stamps.lock().unwrap_or_else(PoisonError::into_inner).insert(path.into(), stamp); + } + } + impl Services for Disk { + fn read_file(&self, path: &str) -> std::io::Result> { + Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string())) + } + fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::other("read-only")) + } + fn stamp(&self, path: &str) -> Option { + self.looks.fetch_add(1, Ordering::SeqCst); + self.stamps.lock().unwrap_or_else(PoisonError::into_inner).get(path).copied() + } + } + + const TICK: Duration = Duration::from_millis(20); + const LONG: Duration = Duration::from_secs(60); + + fn wait_for(w: &Watcher, wakes: &AtomicUsize, n: usize) -> Stamps { + let t0 = std::time::Instant::now(); + loop { + if wakes.load(Ordering::SeqCst) >= n + && let Some(s) = w.take() + { + return s; + } + assert!(t0.elapsed() < Duration::from_secs(10), "no report {n} within 10 s"); + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn start(disk: &Arc, focused: Duration, unfocused: Duration) -> Option<(Watcher, Arc)> { + let wakes = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&wakes); + let w = Watcher::start_with( + Arc::clone(disk) as Arc, + move || { + counter.fetch_add(1, Ordering::SeqCst); + }, + focused, + unfocused, + )?; + Some((w, wakes)) + } + + /// The interface is woken for the first stamps of a set and for every change, and for + /// nothing else: an unchanged file polled many times is no wakeup at all. + #[test] + fn the_watcher_wakes_the_interface_only_when_a_stamp_changes() { + let disk = Arc::new(Disk::default()); + disk.set("/a.psd", (10, 1)); + let (mut w, wakes) = start(&disk, TICK, LONG).unwrap(); + w.set_focused(true); + w.set_watched(["/a.psd".to_string()].into()); + assert_eq!(wait_for(&w, &wakes, 1), vec![("/a.psd".to_string(), Some((10, 1)))]); + let looked = disk.looks.load(Ordering::SeqCst); + std::thread::sleep(TICK * 15); + assert!(disk.looks.load(Ordering::SeqCst) > looked + 3, "it keeps stamping while the window is in front"); + assert_eq!(wakes.load(Ordering::SeqCst), 1, "an unchanged file is no wakeup"); + assert!(w.take().is_none()); + disk.set("/a.psd", (12, 2)); + assert_eq!(wait_for(&w, &wakes, 2), vec![("/a.psd".to_string(), Some((12, 2)))]); + assert_eq!(wakes.load(Ordering::SeqCst), 2); + } + + /// Behind another app the files are stamped only at the slow interval; the window coming + /// back to the front stamps them at once. + #[test] + fn behind_another_app_the_watcher_rests_until_the_window_is_back() { + let disk = Arc::new(Disk::default()); + disk.set("/b.psd", (1, 1)); + let (mut w, wakes) = start(&disk, TICK, LONG).unwrap(); + w.set_focused(false); + w.set_watched(["/b.psd".to_string()].into()); + wait_for(&w, &wakes, 1); + let looked = disk.looks.load(Ordering::SeqCst); + disk.set("/b.psd", (2, 2)); + std::thread::sleep(TICK * 15); + assert_eq!(disk.looks.load(Ordering::SeqCst), looked, "no stamping at the front interval while behind"); + assert_eq!(wakes.load(Ordering::SeqCst), 1); + w.set_focused(true); + assert_eq!(wait_for(&w, &wakes, 2), vec![("/b.psd".to_string(), Some((2, 2)))]); + } + + /// Nothing to watch: the worker sleeps, stamping nothing; a new set is stamped at once and + /// reported whole, and dropping the watcher ends the worker. + #[test] + fn nothing_watched_is_nothing_stamped_and_a_new_set_is_reported_whole() { + let disk = Arc::new(Disk::default()); + disk.set("/c.psd", (1, 1)); + disk.set("/d.psd", (2, 2)); + let (mut w, wakes) = start(&disk, TICK, TICK).unwrap(); + w.set_focused(true); + std::thread::sleep(TICK * 10); + assert_eq!(disk.looks.load(Ordering::SeqCst), 0); + w.set_watched(["/c.psd".to_string(), "/d.psd".to_string()].into()); + assert_eq!(wait_for(&w, &wakes, 1), vec![("/c.psd".to_string(), Some((1, 1))), ("/d.psd".to_string(), Some((2, 2)))]); + w.set_watched(["/d.psd".to_string()].into()); + assert_eq!(wait_for(&w, &wakes, 2), vec![("/d.psd".to_string(), Some((2, 2)))], "the set is reported whole, so the session forgets /c.psd"); + w.set_watched(BTreeSet::new()); + std::thread::sleep(TICK * 3); + let looked = disk.looks.load(Ordering::SeqCst); + std::thread::sleep(TICK * 10); + assert_eq!(disk.looks.load(Ordering::SeqCst), looked, "nothing watched, nothing stamped"); + let result = Arc::downgrade(&w.result); + drop(w); + let t0 = std::time::Instant::now(); + while result.upgrade().is_some() { + assert!(t0.elapsed() < Duration::from_secs(5), "the worker is still running"); + std::thread::sleep(Duration::from_millis(5)); + } + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index db9e02cda..a7484a3f8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -241,8 +241,6 @@ pub struct Session { /// Each footage file's size and modification time when last seen (Settings ▸ Import ▸ /// Reload Footage Changed on Disk, [`footage_reload`]). pub footage_stamps: std::collections::HashMap, - /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). - pub footage_scan: Option, /// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]). pub disk_salt: Option<(u64, u64)>, /// How many script-issued commands are running ([`Session::execute_for_script`]): while one @@ -351,7 +349,6 @@ impl Default for Session { stored_paths: Default::default(), footage_stamps: Default::default(), disk_salt: None, - footage_scan: None, script_calls: ScriptCalls::default(), } } diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index aa683e2b8..4cba41851 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -153,7 +153,8 @@ page!(Import { sequence_fps: f64 = 30.0, report_missing_frames: bool = true, /// Automatically Reload Footage whose file another app changed (size or modification time), - /// checked every two seconds and when the window comes back to the front: `off`, + /// checked every two seconds while the window is in front, every ten seconds while it is + /// not, and when it comes back to the front: `off`, /// `nonSequence` (the default, as in After Effects: image sequences are left alone) or `all`. auto_reload_footage: String = "nonSequence".into(), /// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`. diff --git a/crates/host/tests/it/footage_reload.rs b/crates/host/tests/it/footage_reload.rs index faf2b09e3..323087906 100644 --- a/crates/host/tests/it/footage_reload.rs +++ b/crates/host/tests/it/footage_reload.rs @@ -146,27 +146,54 @@ fn a_file_still_being_written_is_tried_again() { assert!(near(pixel(&s, cid, 32, 24), BLUE)); } +/// The desktop app's watcher ([`footage_reload::Watcher`]) notices a footage file another app +/// saved, hands its stamps over once, and reloads it through [`footage_reload::apply_stamps`]; +/// while nothing changes it hands over nothing (so the interface is never woken for it). #[test] -fn background_scan_reloads_changed_footage() { - let path = tmp("scan.psd"); +fn the_watcher_notices_a_footage_file_changed_on_disk() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + let path = tmp("watched.psd"); std::fs::write(&path, flat(RED, false)).unwrap(); let (mut s, _, cid) = flat_in_comp(&path); - let scan = |s: &mut Session| { - footage_reload::start_scan(s); - let t0 = std::time::Instant::now(); + let wakes = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&wakes); + let fast = Duration::from_millis(20); + let mut w = footage_reload::Watcher::start_with( + s.services.clone(), + move || { + counter.fetch_add(1, Ordering::SeqCst); + }, + fast, + Duration::from_secs(60), + ) + .unwrap(); + w.set_focused(true); + w.set_watched(footage_reload::watched(&s)); + let next = |w: &footage_reload::Watcher| { + let t0 = Instant::now(); loop { - if let Some(r) = footage_reload::poll_scan(s) { - return r.unwrap(); + if let Some(stamps) = w.take() { + return stamps; } - assert!(t0.elapsed().as_secs() < 10, "scan never finished"); - std::thread::yield_now(); + assert!(t0.elapsed() < Duration::from_secs(10), "the watcher never reported"); + std::thread::sleep(Duration::from_millis(5)); } }; - assert_eq!(scan(&mut s), json!({"files": 0, "reloaded": 0})); + // First look: remembered, nothing to reload. + assert_eq!(footage_reload::apply_stamps(&mut s, next(&w)).unwrap(), json!({"files": 0, "reloaded": 0})); + assert_eq!(wakes.load(Ordering::SeqCst), 1); + std::thread::sleep(fast * 15); + assert!(w.take().is_none(), "an unchanged file is never reported"); + assert_eq!(wakes.load(Ordering::SeqCst), 1, "and never wakes the interface"); + // Saved in another app: reported once, reloaded, shown. save(&path, &flat(GREEN, true)); - assert_eq!(scan(&mut s), json!({"files": 1, "reloaded": 1})); + assert_eq!(footage_reload::apply_stamps(&mut s, next(&w)).unwrap(), json!({"files": 1, "reloaded": 1})); assert!(near(pixel(&s, cid, 32, 24), GREEN)); - assert!(s.footage_scan.is_none()); + assert_eq!(wakes.load(Ordering::SeqCst), 2); + std::thread::sleep(fast * 10); + assert!(w.take().is_none()); } #[test] diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 115c5f33a..61eef75a9 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -76,6 +76,8 @@ impl EffectcraftApp { gpu_checked: false, gpu_failures: None, gpu_failure_applied: None, + footage_watcher: None, + footage_watch_key: None, device_lost_close: None, dialog_answers: std::sync::mpsc::channel(), file_dialogs: FileDialogsUp::default(), diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index a3263a677..abc4b8a62 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -524,33 +524,40 @@ impl EffectcraftApp { } ctx.request_repaint_after(std::time::Duration::from_secs(10)); } - self.tick_footage_reload(ctx, now); + self.tick_footage_reload(ctx); } - /// Settings ▸ Import ▸ Reload Footage Changed on Disk: every two seconds, and as soon as the - /// window comes back to the front (after saving in another app), stamp the footage files on a - /// worker thread and reload the ones that changed. - fn tick_footage_reload(&mut self, ctx: &egui::Context, now: f64) { + /// Settings ▸ Import ▸ Reload Footage Changed on Disk: a worker thread stamps the footage + /// files (every two seconds while the window is in front, and as soon as it comes back to + /// the front after saving in another app) and wakes this frame only when a stamp changed; + /// the changed files are reloaded here. Nothing here asks for a repaint: an idle app with + /// unchanged footage stays asleep. + fn tick_footage_reload(&mut self, ctx: &egui::Context) { use effectcraft_engine::footage_reload; - if let Some(Err(e)) = footage_reload::poll_scan(&mut self.session) { + if let Some(stamps) = self.footage_watcher.as_ref().and_then(footage_reload::Watcher::take) + && let Err(e) = footage_reload::apply_stamps(&mut self.session, stamps) + { self.ui.status = e.to_string(); } - let (last_key, focus_key) = (egui::Id::unique("footageReload.lastScan"), egui::Id::unique("footageReload.focused")); - let focused = self.focused; - let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); - ctx.data_mut(|d| d.insert_temp(focus_key, focused)); - if self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty() { + let off = self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty(); + if off { + if let Some(w) = &mut self.footage_watcher { + w.set_watched(Default::default()); + } + self.footage_watch_key = None; return; } - let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(0.0); - if now - last >= 2.0 || (focused && !was_focused) { - ctx.data_mut(|d| d.insert_temp(last_key, now)); - footage_reload::start_scan(&mut self.session); + if self.footage_watcher.is_none() { + let wake = ctx.clone(); + self.footage_watcher = footage_reload::Watcher::start(self.session.services.clone(), move || wake.request_repaint()); } - if self.session.footage_scan.is_some() { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } else { - ctx.request_repaint_after(std::time::Duration::from_secs(2)); + let Some(watcher) = &mut self.footage_watcher else { return }; + watcher.set_focused(self.focused); + // Which files are watched follows the project and the preference, not the frame. + let key = (self.session.revision, self.session.prefs.import.auto_reload_footage.clone()); + if self.footage_watch_key.as_ref() != Some(&key) { + self.footage_watch_key = Some(key); + watcher.set_watched(footage_reload::watched(&self.session)); } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b259bfde..65c68ac52 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -312,6 +312,11 @@ pub struct EffectcraftApp { gpu_checked: bool, gpu_failures: Option, gpu_failure_applied: Option, + /// Settings ▸ Import ▸ Reload Footage Changed on Disk: the worker that stamps the footage + /// files, started with the first project that has items ([`Self::tick_footage_reload`]). + footage_watcher: Option, + /// The project revision and preference the watched set was last computed for. + footage_watch_key: Option<(u64, String)>, /// The graphics device was lost and the window is closing (see `close_after_device_loss`); /// `true` keeps crash recovery for the next launch. device_lost_close: Option, diff --git a/docs/preferences.md b/docs/preferences.md index 2402b9a32..c8ce09b4d 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -120,9 +120,11 @@ Commands (CLI, MCP, control channel): - `import.reportMissingFrames`: gaps in an image sequence's numbering are reported on import (they show as colour-bar placeholders, as in After Effects; see [footage.md](footage.md)) - `import.autoReloadFootage`: Automatically Reload Footage, `off`, `nonSequence` (the default, as - in After Effects) or `all` (image sequences too). Every two seconds, - and as soon as the window comes back to the front, the desktop app compares each footage file's - size and modification time with what they were, and reloads what another app saved: a + in After Effects) or `all` (image sequences too). Every two seconds while its window is in + front (every ten seconds while another app has the front), and as soon as the window comes back + to the front, the desktop app compares each footage file's size and modification time with what + they were, on a worker thread that only wakes the interface when something changed, and reloads + what another app saved: a Photoshop file edited in PhotoCraft or Photoshop shows its new pixels without File ▸ Reload Footage. It is not an undo step and doesn't modify the project; a layer item stays on its layer (found again by name), and a file that can't be read yet (still being written) is tried again once it changes again. From 4c8b9cfc2656bb24f747cefcc4454b82f6fc53cc Mon Sep 17 00:00:00 2001 From: Jason Luker <176962611+ZJHeepfixer@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:36:06 -0700 Subject: [PATCH 085/112] Footage watcher tests: wait for the stamps, and bound the empty set by what can still come MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two engine tests leaned on elapsed time. "It keeps stamping while the window is in front" slept 300 ms and wanted more than three stamps: a worker held up on a loaded machine stamps fewer. "Nothing watched, nothing stamped" slept 60 ms after sending the empty set and then wanted no stamp at all, but a worker already past its wait with the old set stamps that round once more, whenever it gets to run. The first now waits (at most 10 s) until the stamps arrive. The second checks the invariant instead: after `set_watched(∅)` at most one round of the old set (one file) can still come, so the count may grow by one and no more; a worker that ignored the empty set stamps every 20 ms and still fails it. Checked with the worker slowed (100 ms after each wait, 50 ms between the report and the wake): these tests pass; the previous ones fail at "it keeps stamping while the window is in front". Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/footage/footage_reload.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index 66a2dcf82..16d7c1d5d 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -430,6 +430,15 @@ mod watcher_tests { } } + /// Wait (at most 10 s) until `ready`: a slow machine's worker gets there late, never early. + fn wait_until(what: &str, mut ready: impl FnMut() -> bool) { + let t0 = std::time::Instant::now(); + while !ready() { + assert!(t0.elapsed() < Duration::from_secs(10), "{what} within 10 s"); + std::thread::sleep(Duration::from_millis(5)); + } + } + fn start(disk: &Arc, focused: Duration, unfocused: Duration) -> Option<(Watcher, Arc)> { let wakes = Arc::new(AtomicUsize::new(0)); let counter = Arc::clone(&wakes); @@ -455,8 +464,7 @@ mod watcher_tests { w.set_watched(["/a.psd".to_string()].into()); assert_eq!(wait_for(&w, &wakes, 1), vec![("/a.psd".to_string(), Some((10, 1)))]); let looked = disk.looks.load(Ordering::SeqCst); - std::thread::sleep(TICK * 15); - assert!(disk.looks.load(Ordering::SeqCst) > looked + 3, "it keeps stamping while the window is in front"); + wait_until("it keeps stamping while the window is in front", || disk.looks.load(Ordering::SeqCst) > looked + 3); assert_eq!(wakes.load(Ordering::SeqCst), 1, "an unchanged file is no wakeup"); assert!(w.take().is_none()); disk.set("/a.psd", (12, 2)); @@ -499,10 +507,11 @@ mod watcher_tests { w.set_watched(["/d.psd".to_string()].into()); assert_eq!(wait_for(&w, &wakes, 2), vec![("/d.psd".to_string(), Some((2, 2)))], "the set is reported whole, so the session forgets /c.psd"); w.set_watched(BTreeSet::new()); - std::thread::sleep(TICK * 3); + // The worker may already be past its wait with the old set: that one round of stamps + // (one file) can still come, and nothing after it. let looked = disk.looks.load(Ordering::SeqCst); std::thread::sleep(TICK * 10); - assert_eq!(disk.looks.load(Ordering::SeqCst), looked, "nothing watched, nothing stamped"); + assert!(disk.looks.load(Ordering::SeqCst) <= looked + 1, "nothing watched, nothing stamped"); let result = Arc::downgrade(&w.result); drop(w); let t0 = std::time::Instant::now(); From 99da7e25684c5f4f1ea71d183cdb26c6b3d40c43 Mon Sep 17 00:00:00 2001 From: Jason Luker <176962611+ZJHeepfixer@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:36:06 -0700 Subject: [PATCH 086/112] Footage watcher host test: wait for each wake before taking its stamps The worker stores a report and then wakes the interface; the test took the report as soon as it was there and asserted the wake count right after, which can run before the wake. `next` now waits for the n-th wake and then takes the stamps, as the engine tests' `wait_for` does. Checked with 50 ms between the worker's report and its wake: this test passes; the previous one fails at `wakes == 1`. Co-Authored-By: Claude Opus 5.5 --- crates/host/tests/it/footage_reload.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/host/tests/it/footage_reload.rs b/crates/host/tests/it/footage_reload.rs index 323087906..876c543ec 100644 --- a/crates/host/tests/it/footage_reload.rs +++ b/crates/host/tests/it/footage_reload.rs @@ -171,10 +171,14 @@ fn the_watcher_notices_a_footage_file_changed_on_disk() { .unwrap(); w.set_focused(true); w.set_watched(footage_reload::watched(&s)); - let next = |w: &footage_reload::Watcher| { + // Report `n`: the worker stores the stamps and then wakes the interface, so wait for the + // wake too, not only for the stamps. + let next = |w: &footage_reload::Watcher, n: usize| { let t0 = Instant::now(); loop { - if let Some(stamps) = w.take() { + if wakes.load(Ordering::SeqCst) >= n + && let Some(stamps) = w.take() + { return stamps; } assert!(t0.elapsed() < Duration::from_secs(10), "the watcher never reported"); @@ -182,14 +186,14 @@ fn the_watcher_notices_a_footage_file_changed_on_disk() { } }; // First look: remembered, nothing to reload. - assert_eq!(footage_reload::apply_stamps(&mut s, next(&w)).unwrap(), json!({"files": 0, "reloaded": 0})); + assert_eq!(footage_reload::apply_stamps(&mut s, next(&w, 1)).unwrap(), json!({"files": 0, "reloaded": 0})); assert_eq!(wakes.load(Ordering::SeqCst), 1); std::thread::sleep(fast * 15); assert!(w.take().is_none(), "an unchanged file is never reported"); assert_eq!(wakes.load(Ordering::SeqCst), 1, "and never wakes the interface"); // Saved in another app: reported once, reloaded, shown. save(&path, &flat(GREEN, true)); - assert_eq!(footage_reload::apply_stamps(&mut s, next(&w)).unwrap(), json!({"files": 1, "reloaded": 1})); + assert_eq!(footage_reload::apply_stamps(&mut s, next(&w, 2)).unwrap(), json!({"files": 1, "reloaded": 1})); assert!(near(pixel(&s, cid, 32, 24), GREEN)); assert_eq!(wakes.load(Ordering::SeqCst), 2); std::thread::sleep(fast * 10); From 57adb3e3ed0dd99e76eb0ce6f293ec358b8e1612 Mon Sep 17 00:00:00 2001 From: Jason Luker <176962611+ZJHeepfixer@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:45:06 -0700 Subject: [PATCH 087/112] UI: open the window before the GPU compositor is built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `init_gpu` built the compositor on the UI thread in the first frame, and eframe shows the window only after that frame has painted. The compositor's ~235 compute pipelines take tens of seconds to compile on a cold Metal shader cache (first launch, after an OS update, after an update that touched a shader): about 40 s of no window at all, measured 39–41 s on an M2 Max. On the desktop the compositor is now built on a worker thread (`gpu-compositor`), inside the same `catch_unwind`, and attached when its result arrives; the worker requests a repaint, so nothing polls. Until then the viewer composites on the CPU, as it already does without a GPU, and the status line and `help.systemInfo` say "Preparing the GPU compositor…". A device failure during the build discards the result; a thread that can't be spawned, and the browser, build in place as before. `Hooks::before_gpu_build` lets tests hold or fail the build; `gpu_initializing()` exposes the pending state. Measured (release, cold cache, A/B interleaved): time to the window 39.0/40.9 s → 1.9/0.5 s, and the control channel answers right after; with a heavy project opened at launch 38.3 s → 0.64 s (first frame 88 ms after the app was created). Warm: 776 → 355 ms. The compile itself is not faster; #1705 (effect kernels on first use) is the complementary half. Tests: the interface draws, composites a CPU frame and reports the preparing state while the build is held on a gate, then attaches the compositor; a build that panics leaves the viewer on the CPU with the reason; the existing GPU/CPU parity test waits for the asynchronous hand-over. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/app.rs | 3 + crates/ui-egui/src/app/frame.rs | 2 +- crates/ui-egui/src/app/gpu.rs | 113 +++++++++++++++++++++++++++++- crates/ui-egui/src/hooks.rs | 4 ++ crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/tests/it/ui_gpu.rs | 91 +++++++++++++++++++++++- 6 files changed, 212 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 115c5f33a..cbcb5bcec 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -15,6 +15,7 @@ mod screenshot; mod viewer_frames; mod window; +pub use gpu::{GPU_PREPARING, GpuBuild}; #[cfg(test)] pub(crate) use screenshot::{MAX_PENDING_SHOTS, SCREENSHOT_SETTLE_PASSES, SCREENSHOT_SETTLE_S, SCREENSHOT_TIMEOUT_S}; @@ -74,6 +75,8 @@ impl EffectcraftApp { wgpu: None, gpu: None, gpu_checked: false, + #[cfg(not(target_arch = "wasm32"))] + gpu_build: None, gpu_failures: None, gpu_failure_applied: None, device_lost_close: None, diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index a3263a677..6c6c737c0 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -127,7 +127,7 @@ impl EffectcraftApp { self.now = self.clock.now(); // The compositor exists before the first paint and while hidden (device-loss handling // must run there too). - self.init_gpu(frame); + self.init_gpu(ctx, frame); self.apply_gpu_failure(ctx); if self.gpu_failure_applied == Some(true) { self.close_after_device_loss(ctx); diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs index 9febfa413..225a8727c 100644 --- a/crates/ui-egui/src/app/gpu.rs +++ b/crates/ui-egui/src/app/gpu.rs @@ -1,5 +1,12 @@ use super::{Arc, EffectcraftApp, RenderSource, gpu_failure, menus, panels}; +/// The status line while the GPU compositor's worker thread is still building its pipelines +/// (see [`EffectcraftApp::init_gpu`]). +pub const GPU_PREPARING: &str = "Preparing the GPU compositor… (compositing on the CPU until it is ready)"; +/// What the compositor's worker thread hands back: the compositor, why the device can't run it, +/// or the payload of a panic in the graphics backend. +pub type GpuBuild = std::thread::Result>; + impl EffectcraftApp { pub fn render_source(&self) -> RenderSource { RenderSource { @@ -124,7 +131,19 @@ impl EffectcraftApp { /// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs /// compute shaders (WebGL2, software GL) everything stays on the CPU. - pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) { + /// + /// On the desktop the compositor is built on a worker thread. Its compute pipelines (about + /// 235 of them) take tens of seconds to compile on a cold shader cache (first launch, after + /// an OS update, after an update that changed a shader), and eframe shows the window only + /// once the first frame has painted, so building them here kept the window from appearing + /// at all. Until the worker is done the viewer composites on the CPU, as it does without a + /// GPU, and the status bar says so; the browser has no threads and builds it in place. + pub(crate) fn init_gpu(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { + #[cfg(not(target_arch = "wasm32"))] + if self.gpu_build.is_some() { + self.finish_gpu_build(frame.wgpu_render_state()); + return; + } if self.gpu_checked { return; } @@ -142,10 +161,80 @@ impl EffectcraftApp { info.device_type, if info.driver.is_empty() { String::new() } else { format!(", {} {}", info.driver, info.driver_info) } )); + #[cfg(not(target_arch = "wasm32"))] + if self.spawn_gpu_build(ctx, rs) { + return; + } + // In place (the browser, or no thread to be had): nothing to wake later. + let _ = ctx; // Backend validation can panic on shader compilation even when device creation // succeeded. Keep the document and the egui renderer, and use CPU compositing. - let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()))); - match gpu { + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()))); + self.attach_gpu(built, Some(rs)); + } + + /// Start building the compositor on a `gpu-compositor` worker thread, which hands its result + /// back to [`Self::finish_gpu_build`] and wakes the UI. False when no thread could be spawned + /// (the caller then builds it in place). + #[cfg(not(target_arch = "wasm32"))] + fn spawn_gpu_build(&mut self, ctx: &egui::Context, rs: &eframe::egui_wgpu::RenderState) -> bool { + let (tx, rx) = std::sync::mpsc::channel(); + let (adapter, device, queue) = (rs.adapter.clone(), rs.device.clone(), rs.queue.clone()); + let gate = self.hooks.before_gpu_build.take(); + let wake = ctx.clone(); + let spawned = std::thread::Builder::new().name("gpu-compositor".into()).spawn(move || { + // Backend validation can panic on shader compilation even when device creation + // succeeded: the panic stays on this thread and arrives as a result. + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Some(gate) = gate { + gate(); + } + effectcraft_gpu::Gpu::new(&adapter, device, queue) + })); + // No receiver: the window closed, or the device failed, while this was running. + let _ = tx.send(built); + wake.request_repaint(); + }); + if let Err(e) = spawned { + log::warn!("no worker thread for the GPU compositor ({e}); building it on the UI thread"); + return false; + } + self.gpu_build = Some(rx); + self.session.accel_note = Some(GPU_PREPARING.into()); + if self.ui.status.is_empty() { + self.ui.status = GPU_PREPARING.into(); + } + true + } + + /// Take the worker's result when it has arrived ([`Self::init_gpu`]). `rs`: the window's + /// device, which a compositor that built is attached to. + #[cfg(not(target_arch = "wasm32"))] + fn finish_gpu_build(&mut self, rs: Option<&eframe::egui_wgpu::RenderState>) { + let Some(rx) = &self.gpu_build else { return }; + let built = match rx.try_recv() { + Ok(built) => built, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + Err(Box::new("the compositor's worker thread ended without a result") as Box) + } + }; + self.gpu_build = None; + if self.ui.status == GPU_PREPARING { + self.ui.status.clear(); + } + // The device failed while the compositor was being built: it stays detached, and + // `apply_gpu_failure` has already said why the viewer is on the CPU. + if self.gpu_failure_applied.is_some() || self.gpu_failures.as_ref().is_some_and(|bridge| bridge.failure().is_some()) { + return; + } + self.attach_gpu(built, rs); + } + + /// Attach a built compositor to the viewer, or record why there is none. Keeps the document + /// and the egui renderer either way; without a compositor the viewer composites on the CPU. + fn attach_gpu(&mut self, built: GpuBuild, rs: Option<&eframe::egui_wgpu::RenderState>) { + match built { Ok(Ok(g)) => { if let Some(bridge) = &self.gpu_failures { bridge.forward_to(g.context().device_failure_notifier()); @@ -153,7 +242,12 @@ impl EffectcraftApp { return; } } + let Some(rs) = rs else { + self.session.accel_note = Some("GPU compositor unavailable: the window has no wgpu device".into()); + return; + }; log::info!("GPU compositor: {}", effectcraft_engine::render::Accelerator::name(&g)); + self.session.accel_note = None; self.session.accel = Some(Arc::new(g.clone())); self.gpu = Some(g); self.wgpu = Some(rs.clone()); @@ -170,6 +264,19 @@ impl EffectcraftApp { } } + /// Whether the GPU compositor is still being built on its worker thread (the viewer is on + /// the CPU meanwhile). Always false in the browser, which builds it with the first frame. + pub fn gpu_initializing(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + self.gpu_build.is_some() + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + /// The viewer shows a frame composited on the GPU (drawn from its wgpu texture). pub fn viewer_on_gpu(&self) -> bool { matches!((&self.viewer_shown, &self.viewer_native), (Some(s), Some(n)) if s.1 == n.1) diff --git a/crates/ui-egui/src/hooks.rs b/crates/ui-egui/src/hooks.rs index 8d7dcb704..457aeb99a 100644 --- a/crates/ui-egui/src/hooks.rs +++ b/crates/ui-egui/src/hooks.rs @@ -62,6 +62,10 @@ pub struct Hooks { /// Tells the user the graphics device was lost and the window is closing, once. `saved` is /// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed. pub device_lost: Option>)>>, + /// Runs on the worker thread right before the GPU compositor is built (desktop). Tests hold + /// it to look at the interface while the compositor is still being prepared, or make it + /// panic to check that a failing build never reaches the UI thread. + pub before_gpu_build: Option>, } impl Hooks { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b259bfde..ae00f7ac9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,7 @@ mod app; mod clock; mod hooks; +pub use app::{GPU_PREPARING, GpuBuild}; #[cfg(test)] use app::{MAX_PENDING_SHOTS, SCREENSHOT_SETTLE_PASSES, SCREENSHOT_SETTLE_S, SCREENSHOT_TIMEOUT_S}; pub use clock::AppClock; @@ -310,6 +311,10 @@ pub struct EffectcraftApp { /// The GPU compositor on that device (None: no usable adapter → CPU only). pub(crate) gpu: Option, gpu_checked: bool, + /// The GPU compositor being built on a worker thread (desktop): the window opens at once and + /// composites on the CPU until the worker's result lands ([`Self::init_gpu`]). + #[cfg(not(target_arch = "wasm32"))] + gpu_build: Option>, gpu_failures: Option, gpu_failure_applied: Option, /// The graphics device was lost and the window is closing (see `close_after_device_loss`); diff --git a/crates/ui-egui/tests/it/ui_gpu.rs b/crates/ui-egui/tests/it/ui_gpu.rs index 355c35bf5..c5f7084a1 100644 --- a/crates/ui-egui/tests/it/ui_gpu.rs +++ b/crates/ui-egui/tests/it/ui_gpu.rs @@ -6,10 +6,11 @@ use effectcraft_ui_egui::EffectcraftApp; use egui_kittest::Harness; use serde_json::json; +/// Step until the GPU compositor's worker is done and a frame has landed in the viewer. fn settle(h: &mut Harness<'_, EffectcraftApp>) { - for _ in 0..600 { + for _ in 0..3000 { h.step(); - if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + if !h.state().gpu_initializing() && h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { break; } std::thread::sleep(std::time::Duration::from_millis(20)); @@ -19,6 +20,92 @@ fn settle(h: &mut Harness<'_, EffectcraftApp>) { } } +/// Whether wgpu finds any adapter at all (egui_kittest's wgpu harness needs one; CI runners +/// without a GPU skip). +fn any_adapter() -> bool { + let instance = eframe::wgpu::Instance::new(eframe::wgpu::InstanceDescriptor::new_without_display_handle()); + !pollster::block_on(instance.enumerate_adapters(eframe::wgpu::Backends::all())).is_empty() +} + +fn small_session() -> Session { + let mut s = Session::default(); + let comp = s.execute("comp.new", json!({"name": "First", "width": 64, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.newSolid", json!({"comp": comp, "color": "#ff0000"})).unwrap(); + s +} + +/// The window opens before the GPU compositor exists: its pipelines compile on a worker thread +/// (tens of seconds on a cold shader cache), and until then the interface draws, says so, and +/// composites on the CPU. The worker is held on a gate so the "preparing" state is observable. +#[test] +fn the_interface_draws_and_composites_on_the_cpu_while_the_gpu_compositor_is_built() { + if !any_adapter() { + eprintln!("no wgpu adapter: skipping"); + return; + } + let (release, gate) = std::sync::mpsc::channel::<()>(); + let s = small_session(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).wgpu().build_eframe(move |_| { + let mut app = EffectcraftApp::new(s); + app.hooks.before_gpu_build = Some(Box::new(move || { + let _ = gate.recv(); + })); + app + }); + // The first frames paint, and a CPU frame reaches the viewer, while the worker still waits. + for _ in 0..600 { + h.step(); + if h.state().is_ready() && !h.output().shapes.is_empty() && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(h.state().gpu_initializing(), "the compositor is still being built"); + assert!(!h.output().shapes.is_empty(), "the interface draws before the compositor exists"); + assert!(h.state().session.accel.is_none(), "nothing renders on a compositor that doesn't exist yet"); + assert_eq!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the status line is honest about it"); + assert_eq!(h.state().session.accel_note.as_deref(), Some(effectcraft_ui_egui::GPU_PREPARING)); + assert!(h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false), "a frame was composited on the CPU meanwhile"); + assert!(h.state_mut().viewer_pixels().is_some(), "the viewer shows that frame"); + release.send(()).unwrap(); + settle(&mut h); + assert!(!h.state().gpu_initializing()); + assert_ne!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the preparing status goes away"); + match h.state().gpu_adapter() { + Some(adapter) => { + eprintln!("compositor attached after the first frames: {adapter}"); + assert!(h.state().session.accel.is_some()); + assert!(h.state().session.accel_note.is_none()); + } + // An adapter that can't run the compositor (software GL): the reason is recorded. + None => assert!(h.state().session.accel_note.as_deref().is_some_and(|n| n != effectcraft_ui_egui::GPU_PREPARING)), + } +} + +/// A graphics backend that panics while the compositor's pipelines compile panics on the worker +/// thread, not under the UI: the window keeps drawing on the CPU and says why. +#[test] +fn a_compositor_build_that_panics_leaves_the_viewer_on_the_cpu() { + if !any_adapter() { + eprintln!("no wgpu adapter: skipping"); + return; + } + let s = small_session(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).wgpu().build_eframe(move |_| { + let mut app = EffectcraftApp::new(s); + app.hooks.before_gpu_build = Some(Box::new(|| panic!("synthetic shader compiler panic"))); + app + }); + settle(&mut h); + assert!(!h.state().gpu_initializing()); + assert!(h.state().gpu_adapter().is_none()); + assert!(h.state().session.accel.is_none()); + let note = h.state().session.accel_note.clone().unwrap_or_default(); + assert!(note.contains("synthetic shader compiler panic"), "{note}"); + assert!(!h.output().shapes.is_empty(), "the interface still draws"); + assert!(h.state_mut().viewer_pixels().is_some(), "the viewer shows a CPU frame"); +} + /// egui_kittest's wgpu setup on the adapter the headless compositor picked (`name (Backend)`), /// like the desktop app's window. Left alone, egui_kittest prefers a CPU adapter, which on /// Windows is WARP on DirectX 12, where FXC fails to compile the compositor's kernels: the From 800b555e514e541e25aecc84585d06d2aeb62da0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 18:44:05 -0400 Subject: [PATCH 088/112] Media: read the AAC configuration of the MP4 or Matroska track FilmCraft plays, not an empty one before it (#1701) --- crates/media/src/aac.rs | 40 ++++++++++++++++++------ crates/media/tests/it/aac_layout.rs | 48 ++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/crates/media/src/aac.rs b/crates/media/src/aac.rs index a2d7de37b..cd33eaf7b 100644 --- a/crates/media/src/aac.rs +++ b/crates/media/src/aac.rs @@ -1,4 +1,4 @@ -//! The AudioSpecificConfig a movie's first AAC track decodes with, from the containers that carry +//! The AudioSpecificConfig of the AAC track FilmCraft plays from a movie, from the containers that carry //! it outside the audio frames: MP4 / MOV (`esds`), Matroska (`CodecPrivate`) and LATM in MPEG //! TS / PS (its StreamMuxConfig). FilmCraft's other AAC is ADTS (in MPEG TS / PS or a raw `.aac`), //! whose channel configurations FilmCraft decodes (1 to 7) its channel count tells apart @@ -13,10 +13,11 @@ use filmcraft_media::ByteReader; /// as far as this, not through the whole file. const MPEG_HEAD: u64 = 8 << 20; -/// The configuration of the first audio track, if it is AAC: from an MP4 / MOV `header` (its -/// ftyp and moov, or the whole file), or else from `file`, read as Matroska or MPEG TS / PS. +/// The configuration of the audio track FilmCraft plays, if it is AAC: from an MP4 / MOV `header` +/// (its ftyp and moov, or the whole file) and `file`, or else from `file`, read as Matroska or +/// MPEG TS / PS. pub(crate) fn config(header: Option<&[u8]>, file: Option<&dyn ByteReader>) -> Option { - let asc = match header.and_then(mp4) { + let asc = match header.and_then(|h| mp4(h, file)) { Some(asc) => asc, None => { let file = file?; @@ -28,19 +29,29 @@ pub(crate) fn config(header: Option<&[u8]>, file: Option<&dyn ByteReader>) -> Op AudioSpecificConfig::parse(&asc).ok() } -fn mp4(bytes: &[u8]) -> Option> { - let file = filmcraft_isobmff::open(bytes).ok()?; - let track = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Audio)?; +/// The track FilmCraft's MP4 source plays: the first audio track with samples, a sample +/// description and a timescale, read from the whole file as it is (a fragmented movie's samples +/// are in its fragments, not the `moov`). +fn mp4(header: &[u8], file: Option<&dyn ByteReader>) -> Option> { + let mp4 = match file { + Some(f) => filmcraft_isobmff::open(Head(f, f.len())), + None => filmcraft_isobmff::open(header), + }; + let mp4 = mp4.ok()?; + let playable = + |t: &&filmcraft_isobmff::Track| t.kind == filmcraft_isobmff::TrackKind::Audio && !t.samples.is_empty() && !t.entries.is_empty() && t.timescale > 0; + let track = mp4.tracks.iter().find(playable)?; match &track.entries.first()?.codec { filmcraft_isobmff::CodecConfig::Aac(a) => Some(a.asc.clone()), _ => None, } } +/// The track FilmCraft's Matroska source plays: the first audio track with frames, found by +/// indexing the clusters as that source does. fn matroska(file: &dyn ByteReader) -> Option> { - let opts = filmcraft_matroska::OpenOptions { index: false, ..Default::default() }; - let mkv = filmcraft_matroska::open_with(Head(file, file.len()), &opts).ok()?; - match &mkv.tracks.iter().find(|t| t.kind == filmcraft_matroska::TrackKind::Audio)?.codec { + let mkv = filmcraft_matroska::open(Head(file, file.len())).ok()?; + match &mkv.tracks.iter().find(|t| t.kind == filmcraft_matroska::TrackKind::Audio && !t.samples.is_empty())?.codec { filmcraft_matroska::Codec::Aac { asc } => Some(asc.clone()), _ => None, } @@ -66,6 +77,15 @@ impl Head<'_> { } } +impl filmcraft_isobmff::ByteSource for Head<'_> { + fn len(&self) -> u64 { + self.1 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.read(offset, buf) + } +} + impl filmcraft_matroska::ByteSource for Head<'_> { fn len(&self) -> u64 { self.1 diff --git a/crates/media/tests/it/aac_layout.rs b/crates/media/tests/it/aac_layout.rs index 9e5ddbb23..c6403667b 100644 --- a/crates/media/tests/it/aac_layout.rs +++ b/crates/media/tests/it/aac_layout.rs @@ -109,8 +109,17 @@ fn encode(content: &[Ch], adts: bool) -> (Vec, Vec>) { } fn mp4(asc: Vec, channels: u32, units: &[Vec]) -> Vec { + mp4_after(None, asc, channels, units) +} + +/// As [`mp4`], after an audio track with the configuration and channel count `empty` and no +/// samples, if any. +fn mp4_after(empty: Option<(Vec, u32)>, asc: Vec, channels: u32, units: &[Vec]) -> Vec { use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).expect("writer"); + if let Some((asc, channels)) = empty { + w.add_track(TrackConfig::new(SampleEntry::aac(asc, channels, RATE), RATE)).expect("empty track"); + } let t = w.add_track(TrackConfig::new(SampleEntry::aac(asc, channels, RATE), RATE)).expect("track"); for u in units { w.write_sample(t, WriteSample { data: u, duration: 1024, composition_offset: 0, is_sync: true }).expect("sample"); @@ -119,14 +128,25 @@ fn mp4(asc: Vec, channels: u32, units: &[Vec]) -> Vec { } fn mkv(asc: Vec, channels: u32, units: &[Vec]) -> Vec { + mkv_after(None, asc, channels, units) +} + +/// As [`mkv`], after an audio track with the configuration and channel count `empty` and no +/// frames, if any. +fn mkv_after(empty: Option<(Vec, u32)>, asc: Vec, channels: u32, units: &[Vec]) -> Vec { use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec}; - let mut t = TrackSpec::new(TrackKind::Audio, "A_AAC"); - t.codec_private = asc; - t.audio = Some((f64::from(RATE), u64::from(channels), None)); - let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![t], MuxOptions::default()).expect("writer"); + let track = |asc: Vec, channels: u32| { + let mut t = TrackSpec::new(TrackKind::Audio, "A_AAC"); + t.codec_private = asc; + t.audio = Some((f64::from(RATE), u64::from(channels), None)); + t + }; + let tracks: Vec = empty.into_iter().map(|(asc, n)| track(asc, n)).chain([track(asc, channels)]).collect(); + let played = tracks.len() - 1; + let mut w = MkvWriter::new(Cursor::new(Vec::new()), tracks, MuxOptions::default()).expect("writer"); let frame_ns = 1_000_000_000 * 1024 / i64::from(RATE); for (i, u) in units.iter().enumerate() { - w.write_frame(0, i as i64 * frame_ns, true, u, Some(frame_ns as u64)).expect("frame"); + w.write_frame(played, i as i64 * frame_ns, true, u, Some(frame_ns as u64)).expect("frame"); } w.finish().expect("finish").into_inner() } @@ -222,3 +242,21 @@ fn adts_aac_layouts_mix_by_speaker() { check(case.name, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); } } + +/// Audio plays from the first track FilmCraft can play, so an audio track with no samples before +/// it lends it no configuration. Each case decodes in another order than the empty track's +/// configuration would (FilmCraft reorders 12 to WAVE / SMPTE but not 7, and 11 but not a PCE). +#[test] +fn an_empty_audio_track_lends_its_configuration_to_no_other() { + for (name, empty) in [("config12", 7), ("pce61-centre-first", 11)] { + let case = cases().into_iter().find(|c| c.name == name).expect("case"); + let n = case.content.len() as u32; + let empty = Some((AudioSpecificConfig::lc(3, empty).to_bytes(), n)); + let (asc, units) = encode(case.content, false); + let asc = case.asc.as_ref().map_or(asc, AudioSpecificConfig::to_bytes); + let p = write(&format!("{name}-after-empty.mp4"), &mp4_after(empty.clone(), asc.clone(), n, &units)); + check(&format!("{name} MP4"), case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + let p = write(&format!("{name}-after-empty.mkv"), &mkv_after(empty, asc, n, &units)); + check(&format!("{name} Matroska"), case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + } +} From aecdffecb09ef33c43d3e5350cf5537bfa2067aa Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 18:44:34 -0400 Subject: [PATCH 089/112] Media: read the LATM configuration only when FilmCraft plays that MPEG stream, not an ADTS one before it (#1701) --- crates/media/src/aac.rs | 21 +++- crates/media/tests/it/aac_layout.rs | 160 ++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 5 deletions(-) diff --git a/crates/media/src/aac.rs b/crates/media/src/aac.rs index cd33eaf7b..625c37b43 100644 --- a/crates/media/src/aac.rs +++ b/crates/media/src/aac.rs @@ -9,8 +9,8 @@ use std::io; use filmcraft_aac::AudioSpecificConfig; use filmcraft_media::ByteReader; -/// How far into an MPEG TS / PS file its first LATM frame is looked for: the stream is indexed -/// as far as this, not through the whole file. +/// How far into an MPEG TS / PS file the audio streams are looked for: the stream is indexed as +/// far as this, not through the whole file. const MPEG_HEAD: u64 = 8 << 20; /// The configuration of the audio track FilmCraft plays, if it is AAC: from an MP4 / MOV `header` @@ -57,12 +57,23 @@ fn matroska(file: &dyn ByteReader) -> Option> { } } +/// The configuration of the stream FilmCraft's MPEG source plays: the first audio stream whose +/// first unit it can read. Only AAC is asked about, so the streams before that one were refused +/// whatever their codec, and an ADTS stream played has no configuration outside its frames. fn latm(file: &dyn ByteReader) -> Option> { + use filmcraft_mpegts::Codec; let head = Head(file, file.len().min(MPEG_HEAD)); let mpeg = filmcraft_mpegts::open(&head).ok()?; - let stream = mpeg.find(filmcraft_mpegts::Kind::Audio, |c| *c == filmcraft_mpegts::Codec::AacLatm)?; - let frame = mpeg.read_unit(&head, stream, 0).ok()?; - Some(filmcraft_codecs::audio::latm_config(&frame)?.asc) + let audio = mpeg.streams.iter().enumerate().filter(|(_, s)| s.kind() == filmcraft_mpegts::Kind::Audio && !s.units.is_empty()); + let played = audio.take(filmcraft_media::MAX_AUDIO_STREAMS).find_map(|(i, s)| { + let first = mpeg.read_unit(&head, i, 0).ok()?; + match s.codec { + Codec::AacAdts => filmcraft_mpegts::frame_info(&s.codec, &first).map(|_| None), + Codec::AacLatm => filmcraft_codecs::audio::latm_config(&first).map(|c| Some(c.asc)), + _ => None, + } + }); + played? } /// The first `.1` bytes of a file, as the byte source FilmCraft's demuxers read. diff --git a/crates/media/tests/it/aac_layout.rs b/crates/media/tests/it/aac_layout.rs index c6403667b..fb3e078a6 100644 --- a/crates/media/tests/it/aac_layout.rs +++ b/crates/media/tests/it/aac_layout.rs @@ -260,3 +260,163 @@ fn an_empty_audio_track_lends_its_configuration_to_no_other() { check(&format!("{name} Matroska"), case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); } } + +/// A transport stream plays its first audio stream and mixes by that stream's configuration, +/// whatever follows it. ADTS of configuration 7 decodes in element order, LATM of configuration +/// 12 reordered to WAVE / SMPTE; LATM of configuration 7 names 7 channels, which mix as +/// configuration 7's rather than as 7 channels by count. (FilmCraft decodes LATM of configuration +/// 12 as 12 channels, so it isn't played here.) +#[test] +fn mpeg_aac_mixes_by_the_stream_played() { + let case = cases().into_iter().find(|c| c.name == "config7").expect("case"); + let (_, adts) = encode(case.content, true); + let (_, raw) = encode(case.content, false); + for (label, latm_first, latm_config) in [("ADTS before LATM", false, 12), ("LATM before ADTS", true, 7)] { + let (adts_pid, latm_pid) = if latm_first { (0x102, 0x101) } else { (0x101, 0x102) }; + let mut ts = Ts::default(); + let mut streams = [(0x0F, adts_pid), (0x11, latm_pid)]; + streams.sort_by_key(|s| s.1); + ts.psi(&streams); + for (i, (a, r)) in adts.iter().zip(&raw).enumerate() { + let pts = 1920 * i as i64; + let mut units = [(adts_pid, pes(pts, a)), (latm_pid, pes(pts, &loas(latm_config, r)))]; + units.sort_by_key(|u| u.0); + for (pid, unit) in units { + ts.pes(pid, &unit); + } + } + let p = write(&format!("latm-first-{latm_first}.ts"), &ts.out); + check(label, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + } +} + +/// A LOAS frame of `payload` with its StreamMuxConfig: AAC LC, 48 kHz, channel configuration +/// `config`. +fn loas(config: u32, payload: &[u8]) -> Vec { + let mut bits = Bits::default(); + // useSameStreamMux, audioMuxVersion, allStreamsSameTimeFraming, numSubFrames, numProgram, numLayer + bits.put(0b001, 3); + bits.put(0, 13); + // AudioSpecificConfig and GASpecificConfig + bits.put(2, 5); + bits.put(3, 4); + bits.put(config, 4); + bits.put(0, 3); + // frameLengthType, latmBufferFullness, otherDataPresent, crcCheckPresent + bits.put(0, 3); + bits.put(0xFF, 8); + bits.put(0, 2); + let mut len = payload.len(); + while len >= 255 { + bits.put(255, 8); + len -= 255; + } + bits.put(len as u32, 8); + for &b in payload { + bits.put(u32::from(b), 8); + } + let body = bits.finish(); + let mut v = vec![0x56, 0xE0 | (body.len() >> 8) as u8, body.len() as u8]; + v.extend(body); + v +} + +/// A big-endian bit writer. +#[derive(Default)] +struct Bits { + out: Vec, + acc: u64, + n: u32, +} + +impl Bits { + fn put(&mut self, v: u32, n: u32) { + self.acc = self.acc << n | u64::from(v); + self.n += n; + while self.n >= 8 { + self.n -= 8; + self.out.push((self.acc >> self.n) as u8); + } + } + fn finish(mut self) -> Vec { + if self.n > 0 { + self.put(0, 8 - self.n); + } + self.out + } +} + +/// An audio PES packet (stream id 0xC0) presented at `pts` (90 kHz). +fn pes(pts: i64, payload: &[u8]) -> Vec { + let t = [0x21 | ((pts >> 29) & 0x0E) as u8, (pts >> 22) as u8, ((pts >> 14) & 0xFE) as u8 | 1, (pts >> 7) as u8, ((pts << 1) & 0xFE) as u8 | 1]; + let len = 8 + payload.len(); + let mut v = vec![0, 0, 1, 0xC0, (len >> 8) as u8, len as u8, 0x80, 0x80, 5]; + v.extend(t); + v.extend_from_slice(payload); + v +} + +/// A transport stream of one program (its PMT on PID 0x100), PES cut into 188-byte packets. +#[derive(Default)] +struct Ts { + out: Vec, + cc: std::collections::HashMap, +} + +impl Ts { + fn packet(&mut self, pid: u16, start: bool, payload: &[u8]) -> usize { + let cc = self.cc.entry(pid).or_insert(0); + let mut p = vec![0x47, u8::from(start) << 6 | (pid >> 8) as u8, pid as u8]; + let n = payload.len().min(184); + if n < 184 { + let stuffing = 183 - n; + p.extend([0x30 | *cc, stuffing as u8]); + if stuffing > 0 { + p.push(0); + p.extend(std::iter::repeat_n(0xFF, stuffing - 1)); + } + } else { + p.push(0x10 | *cc); + } + *cc = (*cc + 1) & 15; + p.extend_from_slice(&payload[..n]); + self.out.extend(p); + n + } + fn section(&mut self, pid: u16, body: &[u8]) { + let mut s = vec![0]; + s.extend_from_slice(body); + s.extend(crc32_mpeg(body).to_be_bytes()); + self.packet(pid, true, &s); + } + /// The PAT and a PMT of `streams` (stream type, PID). + fn psi(&mut self, streams: &[(u8, u16)]) { + self.section(0, &[0x00, 0xB0, 13, 0, 1, 0xC1, 0, 0, 0, 1, 0xE1, 0x00]); + let mut pmt = vec![0x02, 0xB0, 0, 0, 1, 0xC1, 0, 0, 0xE1, 0x01, 0xF0, 0]; + for &(kind, pid) in streams { + pmt.extend([kind, 0xE0 | (pid >> 8) as u8, pid as u8, 0xF0, 0]); + } + pmt[2] = (pmt.len() + 1) as u8; + self.section(0x100, &pmt); + } + fn pes(&mut self, pid: u16, data: &[u8]) { + let (mut rest, mut start) = (data, true); + while !rest.is_empty() { + let n = self.packet(pid, start, rest); + rest = &rest[n..]; + start = false; + } + } +} + +/// CRC-32 of PSI sections (MSB first, polynomial 0x04C11DB7, initial value all ones). +fn crc32_mpeg(data: &[u8]) -> u32 { + let mut crc = u32::MAX; + for &b in data { + crc ^= u32::from(b) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { crc << 1 ^ 0x04C1_1DB7 } else { crc << 1 }; + } + } + crc +} From 047ffbaba516c6ec536ade1f63ea208ddf8ca19e Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 19:28:40 +0200 Subject: [PATCH 090/112] 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 091/112] 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 883171eecebe1f9a435550b15e125501688c6612 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 05:59:40 +0700 Subject: [PATCH 092/112] 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 83bb74d21ce516d895e0ec50a8b9682b8556825e Mon Sep 17 00:00:00 2001 From: Jason Luker <176962611+ZJHeepfixer@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:29:25 -0700 Subject: [PATCH 093/112] =?UTF-8?q?UI:=20"Preparing=20the=20GPU=20composit?= =?UTF-8?q?or=E2=80=A6"=20stays=20until=20the=20compositor=20is=20attached?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status line is the toast that clears itself after 5 s, so the preparing notice went blank while the viewer was still on the CPU for the rest of a cold-cache build (tens of seconds), though `init_gpu` says the status bar tells the user so. draw_toast now leaves that one status up while `gpu_initializing()`; `finish_gpu_build` clears it when the compositor lands, and the worker's repaint brings that frame, so the toast doesn't tick meanwhile. A click still dismisses it; every other status clears as before. Test: with the build held on its gate, 30 more steps (7.5 s) leave the status as it was; before this change it was empty by then. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/frame.rs | 7 +++++++ crates/ui-egui/tests/it/ui_gpu.rs | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 6c6c737c0..057c5ce22 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -461,6 +461,13 @@ impl EffectcraftApp { self.ui.status.clear(); self.toast = None; } + // "Preparing the GPU compositor…" stays until the compositor is attached (tens of + // seconds on a cold shader cache), not 5 s: `finish_gpu_build` clears it, and the + // worker's repaint brings that frame, so nothing needs to tick meanwhile. A click + // still dismisses it. + if self.ui.status == crate::GPU_PREPARING && self.gpu_initializing() { + return; + } ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); if !self.ui.status.is_empty() { let since: f64 = ui.data(|d| d.get_temp(egui::Id::unique("status-since")).unwrap_or(now)); diff --git a/crates/ui-egui/tests/it/ui_gpu.rs b/crates/ui-egui/tests/it/ui_gpu.rs index c5f7084a1..e74d0ebd5 100644 --- a/crates/ui-egui/tests/it/ui_gpu.rs +++ b/crates/ui-egui/tests/it/ui_gpu.rs @@ -47,6 +47,9 @@ fn the_interface_draws_and_composites_on_the_cpu_while_the_gpu_compositor_is_bui let s = small_session(); let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).wgpu().build_eframe(move |_| { let mut app = EffectcraftApp::new(s); + // The status line's 5 s expiry reads the app clock: a manual one makes the steps below + // stand for a known span of time. + app.use_manual_clock(); app.hooks.before_gpu_build = Some(Box::new(move || { let _ = gate.recv(); })); @@ -67,6 +70,14 @@ fn the_interface_draws_and_composites_on_the_cpu_while_the_gpu_compositor_is_bui assert_eq!(h.state().session.accel_note.as_deref(), Some(effectcraft_ui_egui::GPU_PREPARING)); assert!(h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false), "a frame was composited on the CPU meanwhile"); assert!(h.state_mut().viewer_pixels().is_some(), "the viewer shows that frame"); + // Other statuses clear after 5 s; this one stays for as long as the build takes (each step + // is a quarter of a second). + for _ in 0..30 { + h.state_mut().clock_mut().advance(0.25); + h.step(); + } + assert!(h.state().gpu_initializing()); + assert_eq!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the status line still says so 7.5 s later"); release.send(()).unwrap(); settle(&mut h); assert!(!h.state().gpu_initializing()); From 9eb127d22353d870239635a1d03921a1a7d76551 Mon Sep 17 00:00:00 2001 From: Jason Luker <176962611+ZJHeepfixer@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:29:26 -0700 Subject: [PATCH 094/112] UI test: compare the viewer's GPU frame only once the compositor is attached With the compositor built on a worker thread, the parity test's first frame could be composited on the CPU while the build ran. Frame keys don't name the compositor (by design: the disk cache serves frames from either), so after the hand-over the viewer could keep showing that CPU frame, and the GPU-vs-CPU comparison would pass comparing the CPU with itself. The test now waits for the compositor, then opens the project and sets the time, so every frame it compares was requested with the compositor attached. (`viewer_on_gpu()` can't tell here: on the desktop GPU frames are read back into CPU images, and only the browser draws them from their texture.) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/it/ui_gpu.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/tests/it/ui_gpu.rs b/crates/ui-egui/tests/it/ui_gpu.rs index e74d0ebd5..52f89aa18 100644 --- a/crates/ui-egui/tests/it/ui_gpu.rs +++ b/crates/ui-egui/tests/it/ui_gpu.rs @@ -20,6 +20,20 @@ fn settle(h: &mut Harness<'_, EffectcraftApp>) { } } +/// Step until the GPU compositor's worker has handed its result over (attached, or why not). +/// The first step starts the build. +fn wait_for_compositor(h: &mut Harness<'_, EffectcraftApp>) { + h.step(); + for _ in 0..3000 { + if !h.state().gpu_initializing() { + return; + } + h.step(); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("the GPU compositor's worker gave no result within a minute"); +} + /// Whether wgpu finds any adapter at all (egui_kittest's wgpu harness needs one; CI runners /// without a GPU skip). fn any_adapter() -> bool { @@ -148,21 +162,24 @@ fn viewer_draws_gpu_frames_that_match_the_cpu() { // egui-wgpu creates its GL device with WebGL2's limits, which the compositor declines. let gl = adapter.ends_with("(Gl)"); drop(probe); - let mut s = Session::default(); - s.execute("file.openDemoProject", json!({})).unwrap(); - s.execute("time.set", json!({"time": 2.0})).unwrap(); let mut h = Harness::builder() .with_size(egui::vec2(1400.0, 900.0)) .with_pixels_per_point(1.0) .wgpu_setup(same_adapter(adapter)) - .build_eframe(|_| EffectcraftApp::new(s)); - settle(&mut h); + .build_eframe(|_| EffectcraftApp::new(Session::default())); + // The project opens once the compositor (built on a worker thread) is attached. A frame the + // CPU composited while it was being built has the GPU frame's key, so the viewer would keep + // showing it, and the comparison below would be the CPU against itself. + wait_for_compositor(&mut h); if gl && h.state().gpu_adapter().is_none() { eprintln!("egui-wgpu's GL device has WebGL2 limits: the viewer stays on the CPU, skipping"); return; } assert!(h.state().gpu_adapter().is_some(), "GPU compositor on egui-wgpu's device"); eprintln!("viewer compositor adapter: {:?}", h.state().gpu_adapter()); + h.state_mut().session.execute("file.openDemoProject", json!({})).unwrap(); + h.state_mut().session.execute("time.set", json!({"time": 2.0})).unwrap(); + settle(&mut h); let gpu_px = h.state_mut().viewer_pixels().expect("read back the GPU frame"); let gpu_shot = h.render().expect("render"); // Mercury Software Only: the same frame from the CPU. From dcbb806139fc3ab2425bb77c52e4142741af52be Mon Sep 17 00:00:00 2001 From: Krueels <90205419+Krueels@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:44:05 -0300 Subject: [PATCH 095/112] #986: add Blender scene export for 3D Camera Tracker --- contributors/people.toml | 3 + crates/engine/src/cmd.rs | 1 + crates/engine/src/cmd/blender.rs | 295 ++++++++++++++++++ crates/engine/src/cmd/blender/validate.rs | 71 +++++ crates/engine/src/cmd/blender_scene.py | 206 ++++++++++++ crates/engine/src/cmd/camera_cmds.rs | 14 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/blender.rs | 261 ++++++++++++++++ crates/engine/tests/blender_scene_oracle.py | 61 ++++ crates/ui-egui/src/i18n/ui.rs | 1 + crates/ui-egui/src/menus/frontend.rs | 1 + .../ui-egui/src/panels/camera_tracker_ui.rs | 7 +- crates/ui-egui/tests/it/ui_camera_tracker.rs | 61 +++- docs/agents.md | 7 +- docs/blender-camera-export.md | 101 ++++++ 15 files changed, 1086 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/blender.rs create mode 100644 crates/engine/src/cmd/blender/validate.rs create mode 100644 crates/engine/src/cmd/blender_scene.py create mode 100644 crates/engine/src/tests/blender.rs create mode 100644 crates/engine/tests/blender_scene_oracle.py create mode 100644 docs/blender-camera-export.md diff --git a/contributors/people.toml b/contributors/people.toml index 8654c7a10..df9d7fd80 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,9 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.Krueels] +sync_github_name = true + [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas" diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index cba6df6ef..bd089b52b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -10,6 +10,7 @@ mod animation; pub(crate) mod app_more; mod autotrace; mod batch; +mod blender; mod camera_cmds; mod comp; pub(crate) mod comp_more; diff --git a/crates/engine/src/cmd/blender.rs b/crates/engine/src/cmd/blender.rs new file mode 100644 index 000000000..e6c678101 --- /dev/null +++ b/crates/engine/src/cmd/blender.rs @@ -0,0 +1,295 @@ +//! Camera-solve interchange through Blender's public Python API. The generated script is +//! original MIT OR Apache-2.0 code; no Blender implementation is read or embedded. + +use std::path::Path; + +use effectcraft_effects::camera_tracker as ct; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Uid}; +use effectcraft_render::EvalCtx; +use effectcraft_time::Tick; +use effectcraft_track::camtrack::{CameraSolve, linalg}; +use serde_json::{Value, json}; + +mod validate; + +use super::{b_p, bad, f_p, str_p}; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "camera.exportBlender"; +const MAX_FRAMES: usize = 100_000; +const MAX_POINTS: usize = 100_000; +const MAX_JSON: usize = 64 * 1024 * 1024; +const MAX_FRAME: i64 = 1_048_574; + +fn number(p: &Value, key: &str, default: f64, min: f64, max: f64) -> Result { + let v = match p.get(key) { + None => default, + Some(_) => f_p(p, key).ok_or_else(|| bad(CMD, format!("{key}: expected a number")))?, + }; + if !v.is_finite() || !(min..=max).contains(&v) { + return Err(bad(CMD, format!("{key}: expected {min}..{max}"))); + } + Ok(v) +} + +fn finite(values: impl IntoIterator) -> Result<()> { + if values.into_iter().any(|v| !v.is_finite() || v.abs() > 1e12) { + return Err(bad(CMD, "invalid or excessive numeric camera/scene data; re-analyze the tracker")); + } + Ok(()) +} + +/// Right-handed EffectCraft world (right, down, forward) → Blender (right, forward, up). +fn position(p: [f64; 3], scale: f64) -> [f64; 3] { + [p[0] * scale, p[2] * scale, -p[1] * scale] +} + +fn media_path(s: &Session, path: &str) -> Result { + if path.is_empty() || path.contains('\0') { + return Err(bad(CMD, "the source footage has no valid file path; relink it before exporting")); + } + let path = Path::new(path); + if path.is_absolute() || cfg!(target_arch = "wasm32") { + return Ok(path.to_string_lossy().into_owned()); + } + let cwd = std::env::current_dir().map_err(|e| bad(CMD, format!("cannot resolve footage path: {e}")))?; + let base = s.path.as_deref().and_then(|p| Path::new(p).parent()).map(|p| cwd.join(p)).unwrap_or(cwd); + Ok(base.join(path).to_string_lossy().into_owned()) +} + +pub(super) fn export(s: &mut Session, p: &Value, cid: ItemId, lid: LayerId, uid: Uid) -> Result { + if s.running_for_script() && !s.prefs.scripting.allow_scripts_write_files { + return Err(bad(CMD, "scripts can't export files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on")); + } + let options = Options::parse(p)?; + let Options { path, sensor, units, start, include_points } = options; + if s.camera_target().is_some_and(|(c, l, u)| (c, l, u) == (cid, lid, uid)) && s.is_camera_analyzing() || s.camera_pending.contains(&(cid, lid, uid)) { + return Err(bad(CMD, "wait for this tracker's analysis to finish before exporting")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or_else(|| bad(CMD, "tracked layer missing"))?; + let g = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "tracker missing"))?; + for name in [ct::SOLVE, ct::TRACKS] { + if g.prop(name).is_some_and(|p| matches!(&p.value, effectcraft_keyframe::Value::Str(v) if v.len() > MAX_JSON)) { + return Err(bad(CMD, "tracking data exceeds the 64 MiB export limit")); + } + } + let params = crate::camera_track::static_params(g); + let solve = ct::solve(¶ms).ok_or_else(|| bad(CMD, "the camera is not solved yet; analyze the tracker first"))?; + let (lo, end) = validate::timing(comp, layer, &solve, start)?; + let Source { footage, path: source_path, fps: source_fps, count: source_count } = source(s, layer, &solve, path)?; + validate::geometry(&solve, sensor)?; + let camera = params + .get(ct::CAMERA) + .map(|v| v.as_f64()) + .filter(|v| v.is_finite() && *v > 0.0 && *v <= u64::MAX as f64) + .and_then(|id| comp.layer(LayerId(id as u64))) + .filter(|l| l.is_camera()); + let warnings = scene_warnings(camera, &solve, footage); + let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, lo) }; + let (frames, world) = bake_frames(&ctx, layer, &solve, camera, &options, footage, source_count)?; + let mut points = Vec::new(); + for point in &solve.points { + finite(point.pos.into_iter().chain([point.error as f64]))?; + if point.first > point.last || point.last as usize >= solve.frames.len() { + return Err(bad(CMD, "invalid solved point frame range; re-analyze the tracker")); + } + let location = position(world.apply(point.pos), units); + finite(location)?; + if include_points { + points.push(json!({"id": point.id, "location": location, "error": point.error, + "first": start.saturating_add(point.first as i64), "last": start.saturating_add(point.last as i64)})); + } + } + let tracks = ct::tracks(¶ms); + if tracks.as_ref().is_some_and(|t| t.tracks.len() > MAX_POINTS || t.tracks.iter().any(|t| t.pts.len() > MAX_FRAMES)) { + return Err(bad(CMD, "2D tracks exceed export limits")); + } + let markers: Vec = comp + .markers + .iter() + .filter_map(|m| { + let frame = start.saturating_add(comp.frame_rate.frame_at(m.time).saturating_sub(comp.frame_rate.frame_at(lo))); + if (start..=end).contains(&frame) { Some(json!({"name": m.comment, "frame": frame})) } else { None } + }) + .collect(); + let payload = json!({"version": 1, "name": format!("{} — {}", s.project.item(cid).map(|i| i.name.as_str()).unwrap_or("Shot"), layer.name), + "resolution": solve.size, "fpsNumerator": comp.frame_rate.num, "fpsDenominator": comp.frame_rate.den, + "startFrame": start, "endFrame": end, "sensorWidth": sensor, "unitScale": units, + "media": {"path": source_path, "kind": if footage.kind == FootageKind::Still {"FILE"} else {"MOVIE"}, + "fps": source_fps, "nativeRate": footage.native_rate, "pixelAspect": footage.pixel_aspect, "timecode": footage.start_timecode, + "frameCount": source_count, "loopCount": footage.loop_count}, + "frames": frames, "points": points, "markers": markers, "ground": solve.ground.is_some(), + "groundSize": world.s * units, "shutter": comp.shutter_angle / 360.0, "shutterPhase": comp.shutter_phase, + "solve": solve, "tracks": if include_points { tracks.as_deref() } else { None }, + "warnings": warnings}); + let data = serde_json::to_string(&payload).map_err(|e| bad(CMD, format!("cannot serialize scene: {e}")))?; + if data.len() > MAX_JSON { + return Err(bad(CMD, "scene exceeds the 64 MiB script limit; disable includePoints or split the shot")); + } + let quoted = serde_json::to_string(&data).map_err(|e| bad(CMD, format!("cannot encode scene: {e}")))?; + let script = include_str!("blender_scene.py").replace("__EFFECTCRAFT_DATA__", "ed); + s.services.write_file(path, script.as_bytes()).map_err(|e| bad(CMD, format!("cannot write {path}: {e}")))?; + Ok(json!({"path": path, "bytes": script.len(), "frames": frames.len(), "points": points.len(), "warnings": warnings})) +} + +struct Options<'a> { + path: &'a str, + sensor: f64, + units: f64, + start: i64, + include_points: bool, +} + +impl<'a> Options<'a> { + fn parse(p: &'a Value) -> Result { + let path = str_p(p, "path").filter(|p| !p.is_empty() && !p.contains('\0')).ok_or_else(|| bad(CMD, "missing `path` (.py)"))?; + if !Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("py")) { + return Err(bad(CMD, "the export path must end in .py")); + } + let sensor = number(p, "sensorWidth", 36.0, 1.0, 1000.0)?; + let units = number(p, "unitScale", 0.01, 0.000_001, 1000.0)?; + let start = match p.get("startFrame") { + None => 1, + Some(v) => v.as_i64().filter(|n| (1..=MAX_FRAME).contains(n)).ok_or_else(|| bad(CMD, "startFrame: expected a positive Blender frame number"))?, + }; + let include_points = match p.get("includePoints") { + Some(v) if !v.is_boolean() => return Err(bad(CMD, "includePoints: expected a boolean")), + _ => b_p(p, "includePoints").unwrap_or(true), + }; + Ok(Self { path, sensor, units, start, include_points }) + } +} + +struct Source<'a> { + footage: &'a Footage, + path: String, + fps: f64, + count: i64, +} + +fn source<'a>(s: &'a Session, layer: &Layer, solve: &CameraSolve, path: &str) -> Result> { + let footage = match layer.source { + LayerSource::Footage { item } => s.project.item(item).and_then(|i| match &i.kind { + ItemKind::Footage(f) => Some(f), + _ => None, + }), + _ => None, + } + .filter(|f| f.has_video && matches!(f.kind, FootageKind::Video | FootageKind::Still)); + let footage = footage + .ok_or_else(|| bad(CMD, "Blender background export needs a video or still-footage layer; render precomps/sequences to a video and track that video"))?; + let source_path = media_path(s, &footage.path)?; + if Path::new(path) == Path::new(&source_path) { + return Err(bad(CMD, "the export path must differ from the source footage")); + } + if footage.width as f64 != solve.size[0] || footage.height as f64 != solve.size[1] { + return Err(bad(CMD, "the analysis image differs from the original footage dimensions; render the tracker's input to a video and track that video")); + } + let source_fps = footage.frame_rate.as_f64(); + if footage.frame_rate.num <= 0 || footage.frame_rate.den <= 0 || !source_fps.is_finite() || source_fps <= 0.0 { + return Err(bad(CMD, "invalid source footage frame rate")); + } + // Same frame selection as the media pool: interpreted FPS, rounded per-loop frame count, + // clamp to the total loop range, then wrap. Native FPS does not change the frame index. + let divisor = (effectcraft_time::TICKS_PER_SECOND as i128).saturating_mul(footage.frame_rate.den as i128); + let source_count = (footage.duration.0 as i128).saturating_mul(footage.frame_rate.num as i128).saturating_add(divisor / 2) / divisor; + let source_count = if footage.kind == FootageKind::Still { 1 } else { source_count }; + if !(1..=MAX_FRAME as i128).contains(&source_count) { + return Err(bad(CMD, "invalid footage duration or footage exceeds Blender's frame range; trim/render the source video first")); + } + let source_count = source_count as i64; + Ok(Source { footage, path: source_path, fps: source_fps, count: source_count }) +} + +fn scene_warnings(camera: Option<&Layer>, solve: &CameraSolve, footage: &Footage) -> Vec { + let mut warnings = vec![ + "Sensor width is assumed (36 mm by default); monocular tracking cannot recover physical sensor size or absolute scene scale.".to_string(), + "The original footage is referenced, not embedded or rendered: masks and effects before the tracker are not baked. Relink the video if moving this script.".to_string(), + ]; + if camera.is_none() { + warnings + .push("No linked tracker camera: focus uses the default scene depth, DOF is off; optical focus cannot be recovered from feature tracks.".into()); + } else { + warnings.push("Focus/iris settings are sampled from the linked camera; pose and lens come from the tracker solve. Blender's physical DOF is an approximation of EffectCraft's blur model.".into()); + } + if solve.distortion.is_some_and(|d| !d.is_none()) { + warnings.push("Radial distortion is preserved as metadata (Brown k1/k2, half-diagonal normalization), not applied to Blender's pinhole camera. Use an undistorted plate for exact alignment.".into()); + } + if (footage.pixel_aspect - 1.0).abs() > 1e-6 { + warnings.push("The tracker solves square pixels: export uses square pixels; original footage pixel aspect is retained in metadata.".into()); + } + warnings +} + +/// Bake the solved camera and the linked camera's optical settings at exact composition ticks. +fn bake_frames( + ctx0: &EvalCtx<'_>, + layer: &Layer, + solve: &CameraSolve, + camera: Option<&Layer>, + options: &Options<'_>, + footage: &Footage, + source_count: i64, +) -> Result<(Vec, linalg::Similarity)> { + let comp = ctx0.comp; + let lo = ctx0.time; + let fd = comp.frame_duration(); + let sensor = options.sensor; + let units = options.units; + let start = options.start; + let first = solve.frames.first().ok_or_else(|| bad(CMD, "the camera solve is empty"))?; + let m = ctx0.world_matrix(layer).0; + let layer_scale = m[0][0].hypot(m[1][0]); + finite([layer_scale])?; + if layer_scale <= 1e-9 { + return Err(bad(CMD, "the tracked layer has zero scale")); + } + let world = solve.world([comp.width as f64, comp.height as f64], first.focal * layer_scale); + finite([world.s * units])?; + let mut frames = Vec::with_capacity(solve.frames.len()); + for (i, frame) in solve.frames.iter().enumerate() { + let time = Tick(lo.0.saturating_add(fd.0.saturating_mul(i as i64))); + let ctx = ctx0.at(time); + let pose = linalg::mm(&world.r, &linalg::mt(&frame.r())); + let right = position(linalg::mv(&pose, [1.0, 0.0, 0.0]), 1.0); + let up = position(linalg::mv(&pose, [0.0, -1.0, 0.0]), 1.0); + let back = position(linalg::mv(&pose, [0.0, 0.0, -1.0]), 1.0); + let location = position(world.apply(frame.center), units); + let lens = frame.focal * sensor / solve.size[0]; + let opts = camera.and_then(|l| l.props.sub("cameraOptions").map(|g| (l, g))); + let value = |key: &str, fallback: f64| opts.map(|(l, g)| ctx.f(l, g, key, fallback)).unwrap_or(fallback); + let focus = value("focusDistance", world.s) * units; + let aperture = value("aperture", 0.0); + let zoom = value("zoom", frame.focal * layer_scale); + // Match Camera Settings' f-stop conversion: focal_for_zoom uses a 36 mm film width, + // and the aperture UI is mm scaled by comp_width / 1920 (not a world-space diameter). + let aperture_mm = aperture * 1920.0 / comp.width as f64; + let fstop = if aperture_mm > 0.0 { (zoom * 36.0 / comp.width as f64) / aperture_mm } else { 2.8 }; + let rotation = value("irisRotation", 0.0).to_radians(); + let ratio = value("irisAspectRatio", 1.0); + let shape = opts.map(|(l, g)| ctx.e(l, g, "irisShape")).unwrap_or(0); + let blades = match shape { + 0 => 4, + 1..=8 => shape.saturating_add(2), + _ => return Err(bad(CMD, "invalid iris shape")), + }; + let dof = opts.is_some_and(|(l, g)| ctx.b(l, g, "dof")) && aperture > 0.0 && value("blurLevel", 100.0) > 0.0; + let source_time = ctx.source_time(layer); + let source_frame = if footage.kind == FootageKind::Still { + 0 + } else { + footage.frame_rate.frame_at(source_time).clamp(0, source_count.saturating_mul(footage.loop_count.max(1) as i64).saturating_sub(1)) % source_count + }; + finite(location.into_iter().chain([focus, aperture, zoom, fstop, rotation, ratio, value("blurLevel", 100.0), value("irisRoundness", 0.0)]))?; + if focus <= 0.0 || !(0.01..=100.0).contains(&ratio) || !(0.01..=1e6).contains(&fstop) { + return Err(bad(CMD, "focus, f-stop or iris aspect is outside Blender's supported range")); + } + frames.push(json!({"frame": start + i as i64, "timeTicks": time.0, "sourceFrame": source_frame, + "location": location, "rotation": [right, up, back], "lens": lens, "focus": focus, "dof": dof, + "fstop": fstop, "blades": blades, "irisRotation": rotation, "irisRatio": ratio, + "sourceAperture": aperture, "sourceZoom": zoom, + "blurLevel": value("blurLevel", 100.0), "irisRoundness": value("irisRoundness", 0.0)})); + } + Ok((frames, world)) +} diff --git a/crates/engine/src/cmd/blender/validate.rs b/crates/engine/src/cmd/blender/validate.rs new file mode 100644 index 000000000..a9454861f --- /dev/null +++ b/crates/engine/src/cmd/blender/validate.rs @@ -0,0 +1,71 @@ +//! Validate solved geometry and its correspondence to the current layer timing. + +use effectcraft_project::{Comp, Layer}; +use effectcraft_time::Tick; +use effectcraft_track::camtrack::{CameraSolve, linalg}; + +use super::{CMD, MAX_FRAME, MAX_FRAMES, MAX_POINTS, bad, finite}; +use crate::Result; + +pub(super) fn timing(comp: &Comp, layer: &Layer, solve: &CameraSolve, start: i64) -> Result<(Tick, i64)> { + if solve.frames.len() > MAX_FRAMES || solve.points.len() > MAX_POINTS { + return Err(bad(CMD, "export is limited to 100,000 frames and 100,000 points; split the shot")); + } + if solve.size.iter().any(|v| !v.is_finite() || *v < 4.0 || *v > 65_536.0 || v.fract() != 0.0) { + return Err(bad(CMD, "invalid tracking resolution (expected 4..65536 whole pixels)")); + } + let fps = comp.frame_rate.as_f64(); + if comp.frame_rate.num <= 0 || comp.frame_rate.den <= 0 || !fps.is_finite() || !(1.0..=1000.0).contains(&fps) { + return Err(bad(CMD, "invalid composition frame rate (expected 1..1000 fps)")); + } + let end = start + .checked_add(solve.frames.len() as i64) + .and_then(|n| n.checked_sub(1)) + .filter(|n| *n <= MAX_FRAME) + .ok_or_else(|| bad(CMD, "the baked animation exceeds Blender's frame range"))?; + finite([solve.start, solve.frame_duration, solve.average_error, layer.stretch, comp.shutter_angle, comp.shutter_phase])?; + if layer.stretch == 0.0 || comp.width == 0 || comp.height == 0 { + return Err(bad(CMD, "invalid composition dimensions or zero layer stretch")); + } + let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO)); + let hi = layer.out_point.min(comp.duration); + let fd = comp.frame_duration(); + if fd.0 <= 0 || lo >= hi { + return Err(bad(CMD, "the tracked layer has no active frames")); + } + let expected = ((hi.0 as i128 - lo.0 as i128 + fd.0 as i128 - 1) / fd.0 as i128) as u128; + if expected != solve.frames.len() as u128 { + return Err(bad(CMD, "the solve no longer matches the layer's frame range; re-analyze the tracker")); + } + let dt = (layer.layer_time(lo + fd) - layer.layer_time(lo)).seconds(); + if (solve.start - layer.layer_time(lo).seconds()).abs() > 1e-6 || solve.frames.len() > 1 && (solve.frame_duration - dt).abs() > 1e-6 { + return Err(bad(CMD, "the solve no longer matches the layer's timing; re-analyze the tracker")); + } + Ok((lo, end)) +} + +pub(super) fn geometry(solve: &CameraSolve, sensor: f64) -> Result<()> { + for frame in &solve.frames { + finite(frame.rot.into_iter().chain(frame.center).chain([frame.focal]))?; + if !frame.solved || frame.focal <= 0.0 { + return Err(bad(CMD, "the solve contains unsolved frames or invalid focal lengths; re-analyze the tracker")); + } + let mm = sensor * frame.focal / solve.size[0]; + if !(1.0..=5000.0).contains(&mm) { + return Err(bad(CMD, "lens outside Blender's 1..5000 mm range; choose another sensorWidth")); + } + } + if let Some(ground) = &solve.ground { + finite(ground.origin.into_iter().chain(ground.normal).chain(ground.x_axis))?; + if (linalg::norm(ground.normal) - 1.0).abs() > 1e-4 + || (linalg::norm(ground.x_axis) - 1.0).abs() > 1e-4 + || linalg::dot(ground.normal, ground.x_axis).abs() > 1e-4 + { + return Err(bad(CMD, "invalid ground-plane basis; set the ground plane again")); + } + } + if let Some(d) = &solve.distortion { + finite([d.k1, d.k2, d.radius])?; + } + Ok(()) +} diff --git a/crates/engine/src/cmd/blender_scene.py b/crates/engine/src/cmd/blender_scene.py new file mode 100644 index 000000000..7240cb1ca --- /dev/null +++ b/crates/engine/src/cmd/blender_scene.py @@ -0,0 +1,206 @@ +# EffectCraft camera tracker → Blender, original code under MIT OR Apache-2.0. +# Blender scene export contributor: Krueels — https://github.com/Krueels +# Run in Blender's Scripting workspace (Open, Run Script), or: +# blender --background --python "Tracked Scene.py" +# Creates a NEW scene; existing scenes and objects are preserved. Save the .blend yourself. +# Public API references: https://docs.blender.org/api/4.5/bpy.types.Camera.html +# https://docs.blender.org/api/4.5/bpy.types.ImageUser.html +import json +import math +import os + +import bpy +from mathutils import Matrix + +DATA = json.loads(__EFFECTCRAFT_DATA__) + + +def _curves(owner): + """Blender 4.0–4.3 legacy actions and 4.4+/5.x layered actions.""" + anim = owner.animation_data + if anim is None or anim.action is None: + return + action = anim.action + if getattr(action, "is_action_layered", False): + for layer in action.layers: + for strip in layer.strips: + if strip.type == 'KEYFRAME': + bag = strip.channelbag(anim.action_slot) + if bag is not None: + yield from bag.fcurves + else: + yield from action.fcurves + + +def _interpolation(owner): + for curve in _curves(owner): + # Footage indices and discrete switches must never interpolate between frames. + stepped = ("frame_offset" in curve.data_path or "use_dof" in curve.data_path + or "aperture_blades" in curve.data_path) + for key in curve.keyframe_points: + key.interpolation = 'CONSTANT' if stepped else 'LINEAR' + + +def _media_path(): + original = DATA["media"]["path"] + candidates = [original] + # When sharing, place the video alongside the script or the .blend and run again. + script_path = globals().get("__file__", "") + if script_path: + candidates.append(os.path.join(os.path.dirname(os.path.abspath(script_path)), + os.path.basename(original))) + if bpy.data.filepath: + candidates.append(os.path.join(os.path.dirname(bpy.data.filepath), + os.path.basename(original))) + return next((p for p in candidates if os.path.isfile(p)), None) + + +def import_scene(): + if bpy.app.version < (4, 0, 0): + raise RuntimeError("EffectCraft scene import requires Blender 4.0 or newer") + scene = bpy.data.scenes.new(DATA["name"]) + scene.frame_start = DATA["startFrame"] + scene.frame_end = DATA["endFrame"] + fps = DATA["fpsNumerator"] / DATA["fpsDenominator"] + scene.render.fps = max(1, round(fps)) + scene.render.fps_base = scene.render.fps / fps + scene.render.resolution_x, scene.render.resolution_y = map(int, DATA["resolution"]) + scene.render.resolution_percentage = 100 + # The tracker assumes square pixels, regardless of the source's interpretation metadata. + scene.render.pixel_aspect_x = scene.render.pixel_aspect_y = 1.0 + scene.render.film_transparent = True + scene.render.motion_blur_shutter = max(0.0, min(2.0, DATA["shutter"])) + scene.unit_settings.system = 'METRIC' + scene.unit_settings.scale_length = 1.0 + scene["effectcraft_unit_scale"] = DATA["unitScale"] + scene["effectcraft_scale_is_arbitrary"] = True + scene["effectcraft_shutter_phase_degrees"] = DATA["shutterPhase"] + + collection = bpy.data.collections.new("EffectCraft Tracking") + scene.collection.children.link(collection) + camera_data = bpy.data.cameras.new("3D Tracker Camera") + camera = bpy.data.objects.new("3D Tracker Camera", camera_data) + collection.objects.link(camera) + scene.camera = camera + camera.rotation_mode = 'QUATERNION' + camera_data.type = 'PERSP' + camera_data.sensor_fit = 'HORIZONTAL' + camera_data.sensor_width = DATA["sensorWidth"] + camera_data.sensor_height = (DATA["sensorWidth"] * DATA["resolution"][1] + / DATA["resolution"][0]) + camera_data.show_background_images = True + camera_data.show_limits = True + extent = max([DATA["groundSize"], 1.0] + + [math.dist(p["location"], f["location"]) + for p in DATA["points"][::max(1, len(DATA["points"]) // 100)] + for f in DATA["frames"][::max(1, len(DATA["frames"]) // 10)]]) + camera_data.clip_start = max(0.00001, min(0.1, extent / 10000.0)) + camera_data.clip_end = max(1000.0, extent * 10.0) + + previous = None + for sample in DATA["frames"]: + frame = sample["frame"] + camera.location = sample["location"] + # Exported right/up/back vectors are COLUMNS of Blender's local → world basis. + quaternion = Matrix(sample["rotation"]).transposed().to_quaternion() + if previous is not None and previous.dot(quaternion) < 0: + quaternion.negate() + camera.rotation_quaternion = quaternion + previous = quaternion.copy() + camera.keyframe_insert(data_path="location", frame=frame) + camera.keyframe_insert(data_path="rotation_quaternion", frame=frame) + camera_data.lens = sample["lens"] + camera_data.keyframe_insert(data_path="lens", frame=frame) + camera_data.dof.use_dof = sample["dof"] + camera_data.dof.focus_distance = sample["focus"] + camera_data.dof.aperture_fstop = sample["fstop"] + camera_data.dof.aperture_blades = sample["blades"] + camera_data.dof.aperture_rotation = (sample["irisRotation"] + math.pi) % math.tau - math.pi + camera_data.dof.aperture_ratio = sample["irisRatio"] + for prop in ("use_dof", "focus_distance", "aperture_fstop", "aperture_blades", + "aperture_rotation", "aperture_ratio"): + camera_data.keyframe_insert(data_path="dof." + prop, frame=frame) + + path = _media_path() + if path is not None: + try: + image = bpy.data.images.load(path, check_existing=False) + image.source = DATA["media"]["kind"] + background = camera_data.background_images.new() + background.source = 'IMAGE' + background.image = image + background.alpha = 1.0 + background.display_depth = 'BACK' + background.frame_method = 'STRETCH' + user = background.image_user + user.use_auto_refresh = True + user.use_cyclic = False + user.frame_start = DATA["startFrame"] + user.frame_duration = max(1, min(1048574, image.frame_duration)) + if image.source == 'MOVIE': + for sample in DATA["frames"]: + # Blender uses 1-based image frames; sourceFrame is 0-based. + user.frame_offset = sample["sourceFrame"] - (sample["frame"] - user.frame_start) + camera_data.keyframe_insert( + data_path="background_images[0].image_user.frame_offset", + frame=sample["frame"]) + except (RuntimeError, ValueError, OSError) as error: + DATA["warnings"].append("Blender could not load the background video: " + str(error)) + else: + DATA["warnings"].append("Video not found: " + DATA["media"]["path"] + + ". Place it beside this script or relink the camera background.") + + if DATA["points"]: + mesh = bpy.data.meshes.new("Solved Track Points") + mesh.from_pydata([p["location"] for p in DATA["points"]], [], []) + mesh.update() + cloud = bpy.data.objects.new("Solved Track Points", mesh) + collection.objects.link(cloud) + cloud.show_in_front = True + cloud.hide_render = True + for name, kind, key in (("track_id", 'INT', "id"), ("reprojection_error", 'FLOAT', "error"), + ("first_frame", 'INT', "first"), ("last_frame", 'INT', "last")): + attr = mesh.attributes.new(name=name, type=kind, domain='POINT') + for dest, point in zip(attr.data, DATA["points"]): + # Track IDs are u32 in EffectCraft; keep the full IDs in the JSON text too. + dest.value = point[key] if kind == 'FLOAT' else min(2147483647, point[key]) + + if DATA["ground"]: + size = DATA["groundSize"] + mesh = bpy.data.meshes.new("Solved Ground Plane") + mesh.from_pydata([(-size, -size, 0), (size, -size, 0), + (size, size, 0), (-size, size, 0)], [], [(0, 1, 2, 3)]) + mesh.update() + ground = bpy.data.objects.new("Solved Ground Plane", mesh) + collection.objects.link(ground) + ground.display_type = 'WIRE' + ground.hide_render = True + for marker in DATA["markers"]: + scene.timeline_markers.new(marker["name"], frame=marker["frame"]) + + _interpolation(camera) + _interpolation(camera_data) + metadata = bpy.data.texts.new("EffectCraft Tracking Metadata.json") + metadata.write(json.dumps(DATA, ensure_ascii=False, indent=2)) + scene["effectcraft_metadata_text"] = metadata.name + camera_data["effectcraft_average_error_pixels"] = DATA["solve"]["averageError"] + camera_data["effectcraft_solve_method"] = DATA["solve"]["methodUsed"] + camera_data["effectcraft_focus_source"] = "See warnings in " + metadata.name + distortion = DATA["solve"].get("distortion") + if distortion: + for key in ("k1", "k2", "radius"): + camera_data["effectcraft_distortion_" + key] = distortion[key] + if bpy.context.window is not None: + bpy.context.window.scene = scene + for area in bpy.context.window.screen.areas: + if area.type == 'VIEW_3D': + area.spaces.active.region_3d.view_perspective = 'CAMERA' + scene.frame_set(scene.frame_start) + for warning in DATA["warnings"]: + print("EffectCraft: " + warning) + print("EffectCraft: imported", len(DATA["frames"]), "baked camera frames into", scene.name) + return scene + + +if __name__ == "__main__": + EFFECTCRAFT_SCENE = import_scene() diff --git a/crates/engine/src/cmd/camera_cmds.rs b/crates/engine/src/cmd/camera_cmds.rs index 68cd14fb9..e717d51cc 100644 --- a/crates/engine/src/cmd/camera_cmds.rs +++ b/crates/engine/src/cmd/camera_cmds.rs @@ -42,11 +42,25 @@ pub fn specs() -> Vec { create_from_solve ), cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))), + cmd!( + "camera.exportBlender", + "Export Scene to Blender...", + [], + None, + "{path: .py, comp?, layer?, effect?, sensorWidth?: mm (default 36), unitScale?: Blender units per comp pixel (default 0.01), startFrame?: positive integer (default 1), includePoints?: true}", + has_comp, + export_blender + ), cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground), cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points), ] } +fn export_blender(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = ct_p(s, p, "camera.exportBlender")?; + super::blender::export(s, p, cid, lid, uid) +} + fn with_kind(p: &Value, kind: &str) -> Value { let mut q = p.clone(); if !q.is_object() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 25d05b903..ac887f438 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,5 +1,6 @@ mod anim_presets; mod anim_tools; +mod blender; mod camera_track; mod codec_options; mod color_view; diff --git a/crates/engine/src/tests/blender.rs b/crates/engine/src/tests/blender.rs new file mode 100644 index 000000000..84e939055 --- /dev/null +++ b/crates/engine/src/tests/blender.rs @@ -0,0 +1,261 @@ +//! Synthetic camera interchange: projection, timing, DOF and hostile inputs. Optional public +//! Blender-API oracle: EFFECTCRAFT_BLENDER=/path/to/blender cargo test -p effectcraft-engine tests::blender -- --nocapture. + +use effectcraft_effects::camera_tracker as ct; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Footage, FootageKind, ItemKind, LayerId, LayerSource, build}; +use effectcraft_time::{FrameRate, Tick}; +use effectcraft_track::camtrack::{CameraSolve, Distortion, Ground, ShotType, SolveMethod, SolvedFrame, SolvedPoint}; +use serde_json::{Value, json}; + +use crate::Session; + +fn path(name: &str) -> String { + let dir = std::env::temp_dir().join(format!("effectcraft-blender-tests-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name).to_string_lossy().into_owned() +} + +fn fixture(ground: bool) -> (Session, LayerId, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Shot '\\ — câmera", "width": 640, "height": 360, "duration": 6})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let rate = FrameRate::new(30000, 1001); + let lid = s + .edit("fixture", None, |p, _| { + p.comp_mut(cid).unwrap().frame_rate = rate; + let item = p.add_item( + "Plate", + Default::default(), + None, + ItemKind::Footage(Footage { + path: std::env::var("EFFECTCRAFT_BLENDER_PLATE").unwrap_or_else(|_| path("plate '\\ — vídeo.mp4")), + width: 640, + height: 360, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(6.0), + kind: FootageKind::Video, + has_video: true, + ..Default::default() + }), + ); + let comp = p.comp(cid).unwrap().clone(); + let mut layer = build::layer(p, &comp, "Tracked Video", LayerSource::Footage { item }, (640, 360), None); + layer.in_point = rate.tick_of(60); + layer.out_point = rate.tick_of(63); + layer.start_time = rate.tick_of(30); + layer.stretch = 200.0; + let id = layer.id; + p.comp_mut(cid).unwrap().layers.push(layer); + Ok(id) + }) + .unwrap(); + let uid = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [lid.0]})).unwrap()["effects"][0].as_u64().unwrap(); + let solve = CameraSolve { + version: 1, + size: [640.0, 360.0], + start: rate.tick_of(30).seconds() / 2.0, + frame_duration: rate.frame_duration().seconds() / 2.0, + shot: ShotType::VariableZoom, + method_used: SolveMethod::Typical, + average_error: 0.12, + frames: (0..3) + .map(|i| SolvedFrame { + rot: [0.02 * i as f64, -0.03 * i as f64, 0.01 * i as f64], + center: [0.1 * i as f64, -0.01 * i as f64, 0.02 * i as f64], + focal: 600.0 + i as f64 * 25.0, + solved: true, + }) + .collect(), + points: vec![ + SolvedPoint { id: 7, pos: [0.1, 0.2, 2.0], error: 0.13, first: 0, last: 2 }, + SolvedPoint { id: 9, pos: [-0.3, -0.1, 1.2], error: 0.11, first: 0, last: 1 }, + ], + ground: ground.then_some(Ground { origin: [0.1, 0.2, 2.0], normal: [0.0, -1.0, 0.0], x_axis: [1.0, 0.0, 0.0] }), + distortion: ground.then_some(Distortion { k1: 0.01, k2: -0.002, radius: 320.0_f64.hypot(180.0) }), + }; + // Directly install a deterministic solve; no analysis or decoder is needed by these tests. + s.execute("prop.set", json!({"layer": lid.0, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap(); + s.camera_pending.clear(); + s.execute("layer.select", json!({"layers": [lid.0]})).unwrap(); + (s, lid, uid) +} + +fn data(script: &str) -> Value { + let line = script.lines().find_map(|l| l.strip_prefix("DATA = json.loads(")).unwrap().strip_suffix(')').unwrap(); + let encoded: String = serde_json::from_str(line).unwrap(); + serde_json::from_str(&encoded).unwrap() +} + +#[test] +fn blender_export_bakes_projection_focus_and_retimed_background() { + let (mut s, lid, uid) = fixture(true); + let result = s.execute("camera.create", json!({"layer": lid.0, "effect": uid})).unwrap(); + let camera = result["camera"].as_u64().unwrap(); + s.execute("layer.cameraSettings", json!({"layer": camera, "focusDistance": 800.0, "dof": true, "fStop": 4.0, "lockToZoom": false})).unwrap(); + for (frame, value) in [(60, 800.0), (62, 1000.0)] { + let time = FrameRate::new(30000, 1001).tick_of(frame).seconds(); + s.execute("prop.addKey", json!({"layer": camera, "path": "cameraOptions/focusDistance", "time": time, "value": value})).unwrap(); + } + let camera_layer = s.active_comp().unwrap().layer(LayerId(camera)).unwrap(); + assert!(camera_layer.props.prop("cameraOptions/dof").unwrap().value.as_bool()); + let output = path("oracle-scene.py"); + let before = s.project.to_json(); + let r = s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "effect": uid, "path": output, "startFrame": 101})).unwrap(); + assert_eq!(s.project.to_json(), before, "export must not edit the document"); + assert_eq!(r["frames"], 3); + assert_eq!(r["points"], 2); + let script = std::fs::read_to_string(&output).unwrap(); + let d = data(&script); + assert_eq!(d["resolution"], json!([640.0, 360.0])); + assert_eq!(d["fpsNumerator"], 30000); + assert_eq!(d["fpsDenominator"], 1001); + assert_eq!(d["frames"][0]["sourceFrame"], 15, "trim + 200% stretch, not scene frame 101"); + assert_eq!(d["frames"][2]["sourceFrame"], 16); + assert_eq!(d["frames"][0]["focus"], 8.0); + assert_eq!(d["frames"][2]["focus"], 10.0); + assert_eq!(d["frames"][0]["dof"], true); + assert!((d["frames"][0]["fstop"].as_f64().unwrap() - 4.0).abs() < 1e-6); + assert_eq!(d["frames"][0]["lens"], 33.75); + assert_eq!(d["frames"][2]["lens"], 36.5625); + assert_eq!(d["ground"], true); + assert_eq!(d["solve"]["distortion"]["k1"], 0.01); + assert_eq!(d["points"][0]["location"][2], 0.0, "ground point lies on Blender XY plane"); + + // Execute the actual exported artifact in a separately installed Blender, when requested. + if let Ok(blender) = std::env::var("EFFECTCRAFT_BLENDER") { + let oracle = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/blender_scene_oracle.py"); + let output = std::process::Command::new(blender) + .args(["--background", "--factory-startup", "--python-exit-code", "1", "--python"]) + .arg(oracle) + .arg("--") + .arg(&output) + .output() + .unwrap(); + assert!(output.status.success(), "Blender oracle failed:\n{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).contains("EFFECTCRAFT_BLENDER_ORACLE_OK")); + } +} + +#[test] +fn blender_export_bakes_reverse_time_remap_and_footage_loops() { + let (mut s, lid, _) = fixture(false); + let cid = s.active_comp_id().unwrap(); + let rate = FrameRate::new(30000, 1001); + s.edit("retimed fixture", None, |p, _| { + let layer = p.comp(cid).unwrap().layer(lid).unwrap(); + let item = layer.source.item().unwrap(); + let keys = [(60, 8.0), (61, 6.0), (62, -1.0)] + .map(|(frame, time)| effectcraft_keyframe::Keyframe::new(layer.layer_time(rate.tick_of(frame)), KV::Scalar(time))); + let mut remap = build::Ids(&mut p.next_id).prop("timeRemap", "Time Remap", KV::Scalar(8.0)); + remap.keys = keys.to_vec(); + p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.children.push(remap.into()); + if let ItemKind::Footage(f) = &mut p.item_mut(item).unwrap().kind { + f.loop_count = 2; + } + Ok(()) + }) + .unwrap(); + let output = path("remap.py"); + s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": output})).unwrap(); + let d = data(&std::fs::read_to_string(output).unwrap()); + let indices: Vec = d["frames"].as_array().unwrap().iter().map(|f| f["sourceFrame"].as_i64().unwrap()).collect(); + assert_eq!(indices, vec![60, 0, 0], "reverse remap wraps the 6-second video and clamps negative source time, as the media pool does"); +} + +#[test] +fn blender_export_defaults_and_script_escaping() { + let (mut s, lid, _) = fixture(false); + let output = path("escaped.py"); + let r = s + .execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": output, "includePoints": false, "sensorWidth": 24.0, "unitScale": 0.1})) + .unwrap(); + assert_eq!(r["points"], 0); + let script = std::fs::read_to_string(&output).unwrap(); + let d = data(&script); + assert!(d["name"].as_str().unwrap().contains("'\\ — câmera")); + assert_eq!(d["frames"][0]["lens"], 22.5); + assert_eq!(d["frames"][0]["dof"], false); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("cannot be recovered"))); + match std::process::Command::new("python3").args(["-c", "import ast,sys; ast.parse(open(sys.argv[1], encoding='utf-8').read())", &output]).status() { + Ok(status) => assert!(status.success(), "generated script must parse even with quoted/Unicode paths"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => eprintln!("Python AST oracle skipped: python3 is not installed"), + Err(e) => panic!("Python AST oracle failed: {e}"), + } +} + +#[test] +fn blender_export_rejects_invalid_arguments_and_write_failures() { + let (mut s, lid, _) = fixture(false); + for params in [ + json!({"sensorWidth": 0}), + json!({"sensorWidth": "wide"}), + json!({"unitScale": -1}), + json!({"startFrame": -1}), + json!({"startFrame": 1.5}), + json!({"startFrame": 1048574}), + json!({"includePoints": "yes"}), + json!({"bogus": true}), + json!({"path": path("bad.blend")}), + ] { + let mut p = json!({"layer": lid.0, "path": path("invalid.py")}); + p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone()); + assert!(s.execute_checked("camera.exportBlender", p.clone()).is_err(), "must reject {p}"); + } + let blocker = path("blocker"); + std::fs::write(&blocker, b"not a directory").unwrap(); + let p = format!("{blocker}/scene.py"); + assert!(s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": p})).unwrap_err().to_string().contains("cannot write")); +} + +#[test] +fn blender_export_rejects_malformed_solves_without_panicking() { + for variant in ["empty", "unsolved", "zero-focal", "bad-ground", "huge-resolution", "mismatched-range"] { + let (mut s, lid, uid) = fixture(false); + let cid = s.active_comp_id().unwrap(); + let mut solve = ct::solve(&crate::camera_track::static_params(s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap())) + .unwrap() + .as_ref() + .clone(); + match variant { + "empty" => solve.frames.clear(), + "unsolved" => solve.frames[0].solved = false, + "zero-focal" => solve.frames[0].focal = 0.0, + "bad-ground" => solve.ground = Some(Ground::default()), + "huge-resolution" => solve.size[0] = 1e10, + _ => { + solve.frames.pop(); + } + } + s.edit("corrupt solve", None, |p, _| { + p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.find_group_mut(uid).unwrap().prop_mut(ct::SOLVE).unwrap().value = KV::Str(solve.to_json()); + Ok(()) + }) + .unwrap(); + s.camera_pending.clear(); + let result = s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": path("corrupt.py")})); + assert!(result.is_err(), "must reject {variant}"); + } +} + +#[test] +fn blender_export_respects_script_write_permission() { + let (mut s, lid, _) = fixture(false); + let output = path("script-permissions.py"); + let params = json!({"layer": lid.0, "path": output}); + for (from_script, allowed) in [(true, false), (true, true), (false, false), (false, true)] { + std::fs::write(&output, b"existing file").unwrap(); + s.prefs.scripting.allow_scripts_write_files = allowed; + let result = + if from_script { s.execute_for_script("camera.exportBlender", params.clone()) } else { s.execute_checked("camera.exportBlender", params.clone()) }; + assert!(!s.running_for_script(), "script origin must be restored after success or error"); + let contents = std::fs::read_to_string(&output).unwrap(); + if from_script && !allowed { + assert!(result.unwrap_err().to_string().contains("Allow Scripts to Write Files")); + assert_eq!(contents, "existing file", "blocked scripts must not replace an existing file"); + } else { + assert_eq!(result.unwrap()["frames"], 3); + assert!(contents.contains("def import_scene():")); + } + } +} diff --git a/crates/engine/tests/blender_scene_oracle.py b/crates/engine/tests/blender_scene_oracle.py new file mode 100644 index 000000000..3d1cbeda7 --- /dev/null +++ b/crates/engine/tests/blender_scene_oracle.py @@ -0,0 +1,61 @@ +"""Public-API oracle for scripts generated by src/tests/blender.rs; no Blender source dependency.""" +import json +import math +import runpy +import sys + +import bpy +from bpy_extras.object_utils import world_to_camera_view +from mathutils import Quaternion, Vector + +path = sys.argv[sys.argv.index("--") + 1] +before = set(bpy.data.scenes) +exported = runpy.run_path(path, run_name="__main__") +scene = exported["EFFECTCRAFT_SCENE"] +data = exported["DATA"] +assert before.issubset(set(bpy.data.scenes)), "existing scenes were removed" +assert scene not in before +assert scene.frame_start == 101 and scene.frame_end == 103 +assert abs(scene.render.fps / scene.render.fps_base - 30000 / 1001) < 1e-4 +assert (scene.render.resolution_x, scene.render.resolution_y) == (640, 360) +camera = scene.camera +assert camera.animation_data.action is not None +assert camera.data.animation_data.action is not None +assert camera.data.sensor_fit == 'HORIZONTAL' +metadata = json.loads(bpy.data.texts[scene["effectcraft_metadata_text"]].as_string()) +assert metadata["solve"]["averageError"] == 0.12 +cloud = scene.objects["Solved Track Points"] +assert len(cloud.data.vertices) == 2 +assert cloud.hide_render +assert cloud.data.attributes["track_id"].data[0].value == 7 +assert any(obj.name.startswith("Solved Ground Plane") for obj in scene.objects) + +for i, sample in enumerate(data["frames"]): + scene.frame_set(sample["frame"]) + bpy.context.view_layer.update() + assert abs(camera.data.lens - sample["lens"]) < 1e-5 + assert abs(camera.data.dof.focus_distance - sample["focus"]) < 1e-5 + assert abs(camera.data.dof.aperture_fstop - sample["fstop"]) < 1e-5 + assert camera.data.dof.use_dof + assert (camera.location - Vector(sample["location"])).length < 1e-5 + raw = data["solve"]["frames"][i] + rot = Vector(raw["rot"]) + rotation = Quaternion(rot.normalized(), rot.length).to_matrix() if rot.length else Quaternion().to_matrix() + for vertex, point in zip(cloud.data.vertices, data["solve"]["points"]): + relative = rotation @ (Vector(point["pos"]) - Vector(raw["center"])) + expected = (raw["focal"] * relative.x / relative.z + 320, + raw["focal"] * relative.y / relative.z + 180) + projected = world_to_camera_view(scene, camera, vertex.co) + actual = (projected.x * 640, (1.0 - projected.y) * 360) + assert math.dist(actual, expected) < 0.002, (sample["frame"], actual, expected) + + if "Video not found" not in " ".join(data["warnings"]): + background = camera.data.background_images[0] + assert background.image.source == 'MOVIE' + assert tuple(background.image.size) == (640, 360) + user = background.image_user + actual_source = sample["frame"] - user.frame_start + user.frame_offset + assert actual_source == sample["sourceFrame"], (actual_source, sample["sourceFrame"]) + assert user.use_auto_refresh + +print("EFFECTCRAFT_BLENDER_ORACLE_OK") diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 061090871..23926864d 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -2996,6 +2996,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ // Brazilian Portuguese. const PORTUGUESE: &[(&str, &str)] = &[ + ("Export Scene to Blender...", "Exportar cena para o Blender..."), (" OK ", " OK "), (" Delete ", " Excluir "), (" Open Auto-Save ", " Abrir salvamento automático "), diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index 5d65ffb8f..a5d616714 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -560,6 +560,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> "anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])), "view.exportGuides" => ("path", Ask::Save("Guides.json")), "view.importGuides" => ("path", Ask::Open(&["json"])), + "camera.exportBlender" => ("path", Ask::Save("Tracked Scene.py")), "file.exportLottie" => ("path", Ask::Save("Animation.json")), "file.importLottie" => ("path", Ask::Open(&["json", "lottie"])), "file.importProject" => ("path", Ask::Open(&["ecproj", "ecprojx"])), diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index bca98ea4b..481577398 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -430,6 +430,11 @@ pub fn editor( actions.push(("camera.create".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera"); + let export = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 70.0), vec2(218.0, 22.0)); + if widgets::text_button(ui, export, tr("Export Scene to Blender..."), false, &t, egui::Id::unique(("ct-blender", g.uid))).clicked() && !busy { + actions.push(("camera.exportBlender".into(), me.clone())); + } + app.auto.add(&format!("effectControls.cameraTracker.{}.exportBlender", g.uid), export, "Export Scene to Blender..."); } let status = match app.session.camera_progress() { Some(pr) if running => pr.banner(), @@ -457,4 +462,4 @@ pub fn editor( } /// Height of [`editor`]. -pub const EDITOR_HEIGHT: f32 = 68.0; +pub const EDITOR_HEIGHT: f32 = 100.0; diff --git a/crates/ui-egui/tests/it/ui_camera_tracker.rs b/crates/ui-egui/tests/it/ui_camera_tracker.rs index f7d349342..8c4be61a7 100644 --- a/crates/ui-egui/tests/it/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/it/ui_camera_tracker.rs @@ -16,8 +16,9 @@ const H: f64 = 360.0; fn app() -> (EffectcraftApp, u64, u64) { let mut s = Session::default(); - s.execute("comp.new", json!({"name": "Shot", "width": W, "height": H, "duration": 2})).unwrap(); - let plate = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Shot", "width": W as u32, "height": H as u32, "duration": 2, "frameRate": 30})).unwrap(); + let plate = + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": W as u32, "height": H as u32})).unwrap()["layer"].as_u64().unwrap(); let r = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [plate]})).unwrap(); let uid = r["effects"][0].as_u64().unwrap(); // A synthetic solve: a static camera (focal 600) looking at a 6 × 4 grid of points on z = 1. @@ -135,7 +136,11 @@ fn viewer_points_selection_and_effect_controls() { h.state_mut().show_panel(PanelKind::EffectControls); h.run_steps(4); let all = ids(&h); - for want in [format!("effectControls.cameraTracker.{uid}.analyze"), format!("effectControls.cameraTracker.{uid}.createCamera")] { + for want in [ + format!("effectControls.cameraTracker.{uid}.analyze"), + format!("effectControls.cameraTracker.{uid}.createCamera"), + format!("effectControls.cameraTracker.{uid}.exportBlender"), + ] { assert!(all.contains(&want), "missing {want}"); } // Tracker panel: Track Camera is available for the selected layer. @@ -145,6 +150,56 @@ fn viewer_points_selection_and_effect_controls() { assert!(h.state().session.is_enabled("track.camera")); } +#[test] +fn blender_export_button_opens_python_dialog_and_writes_scene() { + use effectcraft_engine::project::{Footage, FootageKind, ItemKind, LayerId, LayerSource}; + let (mut a, plate, uid) = app(); + let cid = a.session.active_comp_id().unwrap(); + a.session + .edit("video fixture", None, |p, _| { + let item = p.add_item( + "Video", + Default::default(), + None, + ItemKind::Footage(Footage { + path: "/tmp/effectcraft-ui-synthetic.mp4".into(), + kind: FootageKind::Video, + width: W as u32, + height: H as u32, + has_video: true, + duration: effectcraft_engine::time::Tick::from_seconds_f64(2.0), + ..Default::default() + }), + ); + p.comp_mut(cid).unwrap().layer_mut(LayerId(plate)).unwrap().source = LayerSource::Footage { item }; + Ok(()) + }) + .unwrap(); + a.session.camera_pending.clear(); + let output = std::env::temp_dir().join(format!("effectcraft-ui-blender-{}.py", std::process::id())); + let selected_path = output.clone(); + a.hooks.pick_save_file = Some(Box::new(move |name, ext| { + assert!(name.ends_with(".py")); + assert_eq!(ext, "py"); + Some(selected_path.to_string_lossy().into_owned()) + })); + let shot = std::env::var("EFFECTCRAFT_BLENDER_UI_SNAPSHOT").ok(); + let builder = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)); + let builder = if shot.is_some() { builder.wgpu() } else { builder }; + let mut h = builder.build_eframe(|_| a); + h.state_mut().show_panel(PanelKind::EffectControls); + h.run_steps(5); + let button = rect(&h, &format!("effectControls.cameraTracker.{uid}.exportBlender")); + if let Some(path) = shot { + h.render().expect("render export button").save(path).unwrap(); + } + click(&mut h, button.center(), Default::default()); + assert!(output.exists(), "UI export failed: {}", h.state().ui.status); + let script = std::fs::read_to_string(output).expect("clicking the export button saves a Python scene"); + assert!(script.contains("def import_scene():")); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 1, "export does not create a camera in the source project"); +} + /// Drag with `button` from `from` to `to` in steps, then release. fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: egui::Pos2, to: egui::Pos2) { h.input_mut().events.push(egui::Event::PointerMoved(from)); diff --git a/docs/agents.md b/docs/agents.md index 172e5c4c6..8c2d49a45 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -446,7 +446,12 @@ and Origin), `camera.createFromSolve {kind: text|solid|null|shadowCatcher, point {center, normal, size?}, multiple?}` (Create Text / Solid / Null / Shadow Catcher, Camera and Light, Create Multiple …), `camera.deletePoints {points?, wait?}` (re-solves; with Auto-delete Points Across Time the same feature's other tracks go too) and `camera.create` (the Create Camera button). -Each is one undo step; the first create adds the one-node "3D Tracker Camera" keyed on every frame. +Each edit is one undo step; the first create adds the one-node "3D Tracker Camera" keyed on every frame. + +`camera.exportBlender {path: ".py", layer?, effect?, sensorWidth?: 36, unitScale?: 0.01, +startFrame?: 1, includePoints?: true}` exports the solve as an executable Blender scene script: +baked camera/lens/focus, synced video background, points and ground. See +[Blender camera export](blender-camera-export.md) for framing, assumptions and limitations. Changing the layer's frames clears the analysis; changing `effects/#1/shotType`, `effects/#1/horizontalAngleOfView`, `effects/#1/advanced/solveMethod` re-solves the stored tracks. diff --git a/docs/blender-camera-export.md b/docs/blender-camera-export.md new file mode 100644 index 000000000..d102eef4f --- /dev/null +++ b/docs/blender-camera-export.md @@ -0,0 +1,101 @@ +# Exporting a tracked camera to Blender + +After the **3D Camera Tracker** finishes solving, click **Export Scene to Blender...** in its +Effect Controls and save the `.py` file. In Blender 4.0 or newer, open it in the Scripting +workspace's Text Editor and choose **Run Script**. The script creates and activates a new scene; +it preserves existing scenes and objects. Save the resulting `.blend` normally. + +The script uses Blender's public Python API, with no add-on or Python dependencies beyond +Blender itself. For unattended use: `blender --background --python "Tracked Scene.py"`. +That imports the scene in memory; it does not automatically save a `.blend`. + +## Transferred data + +- Camera position and quaternion rotation baked on every analyzed frame, with consistent + quaternion signs and linear interpolation; zoom/lens animation, including Variable Zoom. +- Tracking input/video resolution, horizontal sensor fit, exact rational composition FPS, + frame range and composition markers within the analyzed interval. The first analyzed frame + becomes Blender frame 1 by default; the original composition timestamps remain in metadata. +- The original video or still as the camera's background image, at full opacity, refreshed on + frame changes. Source-frame offsets are baked too, accounting for trim, stretch, source FPS + and Time Remap. This is a viewport background, not a render/compositor background. +- Focus distance, DOF switch, f-stop, iris blade count, rotation and aspect sampled from the + tracker's linked **3D Tracker Camera**, including keyframes and expressions. Create that + camera and adjust its Camera Options before exporting to transfer custom focus settings. +- Solved 3D points as a non-rendering vertex mesh, with point attributes for track ID, + reprojection error and first/last visible frame. A chosen ground plane becomes Blender's XY + plane, with its origin preserved and a non-rendering wire plane as a placement guide. +- Shutter duration (not automatically enabled), plus the original shutter phase in metadata. +- A Blender Text datablock named `EffectCraft Tracking Metadata.json` containing the complete + solve, optional original 2D tracks, per-frame timings, source interpretation, diagnostics and + export warnings. Radial distortion coefficients are also camera custom properties. + +Coordinates are converted from EffectCraft's right/down/forward world to Blender's +right/forward/up world, including the camera's local up and negative-Z viewing direction. +Export uses the tracking input's framing, not the footage layer's final 2D placement in the +composition. Camera pose and lens always come from the solve; subsequent manual pose/lens edits +on the linked camera are not substituted for the tracked motion. + +## What cannot be inferred + +Monocular tracking cannot recover absolute scene scale, physical sensor size, optical focus, +f-stop or exposure from feature tracks. The default sensor width is an assumption of **36 mm**; +the focal length in millimeters is derived from it while preserving the solved field of view. +The default scale is **0.01 Blender units per composition-world pixel**, not a measurement. +Without a linked camera, DOF is disabled and focus distance uses the default solved scene depth. +Blender's physical DOF approximates EffectCraft's blur model: Blur Level and Iris Roundness are +retained in metadata, but have no direct Blender equivalent. + +Brown-model radial `k1`/`k2` coefficients use radius normalized to half the image diagonal. +They are preserved, **not** assigned to Blender's different lens-distortion model. For a precise +match when distortion is nonzero, use an undistorted plate; the script prints a warning. +The tracker assumes square pixels, so Blender renders square pixels; any non-square source +interpretation is retained in metadata and flagged. + +The `.py` references the video; it does not contain a copy of the video's bytes. When moving it, +keep the video next to the script (same filename), or relink the background image in Blender. +Missing/unsupported media produces a warning while retaining the imported camera and geometry. +Masks and effects applied before the tracker are not baked into the original media. Export +requires a direct video/still footage layer at the analyzed size; render precomps, sequences or +size-changing preprocessing to a video and track that video first. + +## Agent / control-channel command + +```json +{ + "command": "camera.exportBlender", + "params": { + "layer": "#1", + "path": "/output/Tracked Scene.py", + "sensorWidth": 36, + "unitScale": 0.01, + "startFrame": 1, + "includePoints": true + } +} +``` + +`comp`, `layer` and `effect` select the tracker as with `camera.analyze`. Without explicit +selection, the selected tracker (or first tracker in the composition) is used. `sensorWidth`, +`unitScale`, `startFrame` and `includePoints` are optional. The reply contains file size, +exported frame/point counts and warnings. Writing uses the host's atomic file service. +Calls issued by scripts require **Allow Scripts to Write Files and Access Network** in +Settings ▸ Scripting & Expressions. Export from the UI or control channel remains available +with that preference off. + +Export is limited to 100,000 analyzed frames, 100,000 points and 64 MiB of serialized scene data; +invalid, stale or incomplete solves return an actionable error without modifying the document. + +## Verification + +Engine tests check argument validation, escaped/Unicode paths, fractional FPS, trim/stretch, +focus/f-stop, zoom, ground placement, malformed solves and write failures. To additionally run +the generated artifact against an installed Blender's public API: + +```sh +EFFECTCRAFT_BLENDER=/path/to/blender cargo test -p effectcraft-engine tests::blender -- --nocapture +``` + +Optionally set `EFFECTCRAFT_BLENDER_PLATE` to a local synthetic 640×360 video to exercise media +loading and source-frame offsets. The oracle checks every synthetic point's projection through +the imported camera on every frame, as well as animation, DOF, timing and scene preservation. From e2b56f1ba52b654c716107fdd75715f8a60d506a Mon Sep 17 00:00:00 2001 From: Krueels <90205419+Krueels@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:05:17 -0300 Subject: [PATCH 096/112] #986: remove personal credit from generated Blender scripts --- crates/engine/src/cmd/blender_scene.py | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/engine/src/cmd/blender_scene.py b/crates/engine/src/cmd/blender_scene.py index 7240cb1ca..9f8a522c5 100644 --- a/crates/engine/src/cmd/blender_scene.py +++ b/crates/engine/src/cmd/blender_scene.py @@ -1,5 +1,4 @@ # EffectCraft camera tracker → Blender, original code under MIT OR Apache-2.0. -# Blender scene export contributor: Krueels — https://github.com/Krueels # Run in Blender's Scripting workspace (Open, Run Script), or: # blender --background --python "Tracked Scene.py" # Creates a NEW scene; existing scenes and objects are preserved. Save the .blend yourself. From 73817dc3f0c37ccbf639add2f54925322dc40bea Mon Sep 17 00:00:00 2001 From: Krueels <90205419+Krueels@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:28:04 -0300 Subject: [PATCH 097/112] #986: confine Blender exports and trim generated header --- crates/automation/src/confine.rs | 1 + crates/automation/src/tests.rs | 27 ++++++++++++++++++++++++++ crates/engine/src/cmd/blender_scene.py | 3 +-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 67e5aef98..dc64084d3 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -97,6 +97,7 @@ const MIXED_WRITES: &[(&str, &[&str])] = &[("templates.create", &["projectPath", /// Commands that write files named by these parameters. const WRITES: &[(&str, &[&str])] = &[ + ("camera.exportBlender", &["path"]), ("file.save", &["path"]), ("file.saveAs", &["path"]), ("file.saveCopy", &["path"]), diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 560a527c7..49f0389f6 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1473,3 +1473,30 @@ fn automation_roots_refuse_running_script_code() { call_json(&mut s, "execute_command", json!({"command": "scriptui.list"})); let _ = std::fs::remove_dir_all(&base); } + +#[test] +fn blender_export_requires_an_absolute_path_in_a_write_root() { + use crate::confine::Confinement; + let base = std::env::temp_dir().join(format!("ec-blender-confine-{}", std::process::id())); + let (read, write) = (base.join("read"), base.join("write")); + for dir in [&read, &write] { + std::fs::create_dir_all(dir).unwrap(); + } + let confinement = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let check = |path: &std::path::Path| confinement.check_command("camera.exportBlender", &json!({"path": path})); + assert_eq!(check(&write.join("scene.py")).unwrap(), None); + for path in [read.join("scene.py"), base.join("outside.py"), write.join("../read/scene.py")] { + assert!(check(&path).is_err(), "must deny {}", path.display()); + } + for path in ["scene.py", "./scene.py", "write/scene.py", "../scene.py"] { + let error = check(std::path::Path::new(path)).unwrap_err().to_string(); + assert!(error.contains("give an absolute path"), "{path}: {error}"); + } + let read_only = Confinement::new(std::slice::from_ref(&write), &[]).unwrap(); + assert!(read_only.check_command("camera.exportBlender", &json!({"path": write.join("scene.py")})).is_err()); + for path in [json!(read.join("scene.py")), json!("scene.py"), json!("$1.path")] { + let batch = json!({"steps": [{"command": "camera.exportBlender", "params": {"path": path}}]}); + assert!(confinement.check_command("engine.batch", &batch).is_err()); + } + std::fs::remove_dir_all(&base).unwrap(); +} diff --git a/crates/engine/src/cmd/blender_scene.py b/crates/engine/src/cmd/blender_scene.py index 9f8a522c5..0a9700a00 100644 --- a/crates/engine/src/cmd/blender_scene.py +++ b/crates/engine/src/cmd/blender_scene.py @@ -2,8 +2,7 @@ # Run in Blender's Scripting workspace (Open, Run Script), or: # blender --background --python "Tracked Scene.py" # Creates a NEW scene; existing scenes and objects are preserved. Save the .blend yourself. -# Public API references: https://docs.blender.org/api/4.5/bpy.types.Camera.html -# https://docs.blender.org/api/4.5/bpy.types.ImageUser.html +# Public API types: bpy.types.Camera, bpy.types.ImageUser import json import math import os From 4a4aa28184ae4671fb209151415ab12f944d5a82 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 06:27:58 +0700 Subject: [PATCH 098/112] 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 d91b14db54848d3447b11bb5e36c29afdd904b03 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 19:30:31 -0400 Subject: [PATCH 099/112] Viewer: a precomp's undecodable proxy has its notice with Collapse on, now that a proxy keeps the flattened path (#1701) --- crates/ui-egui/src/panels/viewer.rs | 8 +++----- crates/ui-egui/tests/it/ui_unreadable_footage.rs | 8 ++++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index a255bd321..d084dd006 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -8,8 +8,8 @@ use effectcraft_engine::commands::shape_tool::PaintKind; use effectcraft_engine::effects::puppet::PinKind; use effectcraft_engine::geom::{Mat3, vec2 as gv2}; use effectcraft_engine::project::{Comp, Layer, LayerId, LayerSource}; +use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::three_d::{self, CameraState, View3D}; -use effectcraft_engine::render::{EvalCtx, Renderer}; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; @@ -721,13 +721,11 @@ fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) { } /// Up to three layers active at `time` whose footage, or the proxy in use for footage or a -/// precomp, can't be decoded, each with the reason. A collapsed precomp draws its own layers, -/// never the comp's proxy. +/// precomp, can't be decoded, each with the reason. fn footage_problems<'c>(app: &EffectcraftApp, ectx: &EvalCtx, comp: &'c Comp, time: Tick) -> Vec<(&'c Layer, String)> { - let r = Renderer::new(ectx.project, &*app.session.footage, Default::default()); comp.layers .iter() - .filter(|l| l.is_active_at(time) && l.switches.video && r.collapsed(ectx, l).is_none()) + .filter(|l| l.is_active_at(time) && l.switches.video) .filter_map(|layer| { let (LayerSource::Footage { item } | LayerSource::Comp { item }) = &layer.source else { return None }; Some((layer, ectx.project.item(*item).and_then(|i| app.session.footage.item_problem(i))?)) diff --git a/crates/ui-egui/tests/it/ui_unreadable_footage.rs b/crates/ui-egui/tests/it/ui_unreadable_footage.rs index 63e3a75c2..a62884f43 100644 --- a/crates/ui-egui/tests/it/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/it/ui_unreadable_footage.rs @@ -83,10 +83,10 @@ fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() { assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}"); } -/// A precomp whose proxy can't be decoded: the layer says so while it renders through the -/// proxy, but not with Collapse Transformations on, which draws the precomp's own layers. +/// A precomp whose proxy can't be decoded: the layer says so with Collapse Transformations off +/// or on, since a proxy in use keeps the precomp on the flattened path that draws the proxy. #[test] -fn a_collapsed_precomp_has_no_notice_for_its_proxy() { +fn a_precomp_with_an_undecodable_proxy_has_a_notice_collapsed_or_not() { for collapse in [false, true] { let mut s = Session { footage: Arc::new(Stub(|f| f.path.ends_with(".avi"))), ..Default::default() }; s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 36, "duration": 4})).unwrap(); @@ -99,6 +99,6 @@ fn a_collapsed_precomp_has_no_notice_for_its_proxy() { s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": "collapse", "value": collapse})).unwrap(); let h = open(s); - assert_eq!(h.state().auto.query("viewer.footageProblem.").is_empty(), collapse, "collapse {collapse}"); + assert!(!h.state().auto.query("viewer.footageProblem.").is_empty(), "collapse {collapse}"); } } From 63ec5f2aad6d865cd3cb75428a1f222e32753252 Mon Sep 17 00:00:00 2001 From: erythrofr <20369086+erythrofr@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:39:56 +0100 Subject: [PATCH 100/112] Export: apply crop once when calculating resize dimensions Calculate frame_size from the original rendered dimensions and effective render scale before cropping, so locked-aspect resize does not apply the crop margins a second time in its dimension calculation. Add dimension and decoded-PNG regressions for crop, resize, padding, scaled ROI, both interpolation qualities, and zero or extreme requested sizes. AI assistance: OpenAI Codex (GPT-6). --- crates/export/src/pipeline.rs | 84 ++++++++++++++++++++++- crates/export/tests/it/render_settings.rs | 23 +++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index c639ebad1..7b9e4111c 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -431,12 +431,13 @@ impl<'a> Cx<'a> { fn crop_resize(&self, img: Image) -> Image { let om = self.job.output; let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0)); + // frame_size applies crop itself: use the original frame and the same scale as edges. + let (w, h) = om.frame_size(img.width, img.height, scale); let (t, l, b, r) = om.crop.edges(img.width, img.height, scale); let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) }; if !om.resize.enabled { return img; } - let (w, h) = om.frame_size(img.width, img.height, 1.0); if (w, h) == (img.width, img.height) { return img; } @@ -1062,6 +1063,87 @@ mod tests { } } + #[test] + fn crop_resize_applies_margins_once() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + let output = OutputModule { + crop: Crop { enabled: true, left: 480, right: 480, ..Default::default() }, + resize: Resize { enabled: true, width: 1280, lock_aspect: true, quality: ResizeQuality::Low, ..Default::default() }, + ..Default::default() + }; + assert_eq!(output.frame_size(1920, 1080, 1.0), (1280, 1440)); + let img = run_crop_resize(Image::new(1920, 1080), &output, 1.0); + assert_eq!((img.width, img.height), (1280, 1440)); + assert_eq!(img.data.len(), 1280 * 1440); + } + + fn run_crop_resize(img: Image, output: &effectcraft_project::render_queue::OutputModule, scale: f64) -> Image { + use effectcraft_project::render_queue::RenderSettings; + let project = Project::default(); + let settings = RenderSettings { resolution: scale, ..Default::default() }; + let job = Job { + project: &project, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: None, + comp: effectcraft_project::ItemId(0), + settings: &settings, + output, + path: "unused.png", + sink: None, + nested_switches: true, + options: Default::default(), + }; + Cx::new(&job).crop_resize(img) + } + + #[test] + fn crop_resize_dimensions_match_the_output_module() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + let edges = Crop { enabled: true, left: 2, right: 2, ..Default::default() }; + let padding = Crop { enabled: true, left: -2, bottom: -2, ..Default::default() }; + let roi = |rect| Crop { enabled: true, use_roi: true, roi: Some(rect), ..Default::default() }; + for quality in [ResizeQuality::Low, ResizeQuality::High] { + let resize = |width| Resize { enabled: true, width, height: 1, lock_aspect: true, quality }; + for (crop, resize, scale, expected) in [ + (Crop::default(), Resize::default(), 1.0, (8, 6)), + (edges, Resize::default(), 1.0, (4, 6)), + (Crop::default(), resize(4), 1.0, (4, 3)), + (edges, resize(8), 1.0, (8, 12)), + (padding, resize(5), 1.0, (5, 4)), + (roi([4.0, 2.0, 8.0, 6.0]), resize(8), 0.5, (8, 6)), + (roi([1.0, 0.5, 2.0, 1.5]), resize(8), 2.0, (8, 6)), + (edges, Resize { lock_aspect: false, height: 2, ..resize(5) }, 1.0, (5, 2)), + ] { + let output = OutputModule { crop, resize, ..Default::default() }; + assert_eq!(output.frame_size(8, 6, scale), expected); + let img = run_crop_resize(Image::filled(8, 6, [0.25, 0.0, 0.0, 0.5]), &output, scale); + assert_eq!((img.width, img.height), expected, "{crop:?}, {resize:?}, scale {scale}"); + assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize); + } + } + } + + #[test] + fn crop_resize_zero_and_extreme_sizes_stay_bounded() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + for quality in [ResizeQuality::Low, ResizeQuality::High] { + for (size, crop, width, height, expected) in [ + ((8, 6), Crop::default(), 0, 0, (1, 1)), + ((8, 1), Crop::default(), u32::MAX, 1, (30_000, 1)), + ((1, 8), Crop::default(), 1, u32::MAX, (1, 30_000)), + ((8, 6), Crop { enabled: true, left: 20_000, right: 20_000, ..Default::default() }, 2, 3, (2, 3)), + ((0, 0), Crop { enabled: true, left: 1, top: 1, ..Default::default() }, 2, 3, (2, 3)), + ] { + let output = OutputModule { crop, resize: Resize { enabled: true, width, height, lock_aspect: false, quality }, ..Default::default() }; + let img = run_crop_resize(Image::new(size.0, size.1), &output, 1.0); + assert_eq!((img.width, img.height), expected); + assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize); + assert!(img.data.iter().all(|p| *p == [0.0; 4])); + } + } + } + #[test] fn crop_and_pad() { let mut img = Image::new(4, 3); diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index 465d8e5fe..c2d7dcbfa 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -174,6 +174,29 @@ fn crop_roi_and_resize() { assert_eq!((z.width(), z.height()), (32, 24)); } +#[test] +fn crop_resize_preserves_pixels_when_the_cropped_size_is_requested() { + let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + for (crop, scale, expected) in [ + (Crop { enabled: true, left: 16, right: 16, ..Default::default() }, 1.0, (32, 48)), + (Crop { enabled: true, left: -4, top: -2, ..Default::default() }, 1.0, (68, 50)), + (Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 0.5, (16, 12)), + (Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 2.0, (64, 48)), + ] { + let settings = RenderSettings { resolution: scale, ..custom(0.4, 0.44) }; + let mut output = OutputModule { crop, ..OutputModule::for_format(OutputFormat::PngSequence) }; + let reference = png(&run(&p, cid, &settings, &output, "/c/reference_[#####].png"), 0); + assert_eq!(reference.dimensions(), expected); + assert!(reference.pixels().any(|p| p.0[0] > 150), "the reference contains visible content"); + for quality in [ResizeQuality::Low, ResizeQuality::High] { + output.resize = Resize { enabled: true, width: expected.0, height: 1, lock_aspect: true, quality }; + let actual = png(&run(&p, cid, &settings, &output, "/c/resized_[#####].png"), 0); + assert_eq!(actual.dimensions(), expected); + assert!(actual == reference, "crop {crop:?}, scale {scale}, quality {quality:?}: pixels changed"); + } + } +} + #[test] fn alpha_only_and_premultiplied() { let (p, cid) = project(FrameRate::new(25, 1), |p, comp| { From cf912e1f61e63181eae4891d5bd32fad93845ed3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 06:42:54 +0700 Subject: [PATCH 101/112] 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 102/112] 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..."); } From 01f92b04c52b5cfa2db1ed47f906cb752f6236c5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 07:04:18 +0700 Subject: [PATCH 103/112] ScriptUI: a drag keeps its control past the window edge; MouseEvent.buttons (#2584) A drag that started on a script control stopped at the window edge: moves outside were dropped and a release outside never reached the control. While a button is held, the control it was pressed on now gets the moves (with screen and control-relative coordinates) and the release, wherever the pointer is, as in After Effects. Every MouseEvent also carries buttons (held: 1 left, 2 right, 4 middle), so a handler can tell a drag from a hover; scriptui.mouse accepts it too. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/scripts.rs | 2 +- crates/engine/src/scriptui.rs | 1 + crates/script/src/scriptui.js | 4 ++- crates/ui-egui/src/panels/scriptui_view.rs | 28 ++++++++++----- crates/ui-egui/tests/it/ui_scriptui.rs | 41 ++++++++++++++++++++++ 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index f4162043d..a523770e3 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -463,7 +463,7 @@ pub fn specs() -> Vec { "Mouse Event on Script Window Control", [], None, - "{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent", + "{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), buttons? (held: 1 left, 2 right, 4 middle), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent", always, scriptui::mouse ), diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 6d218a7bb..af89932fa 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -755,6 +755,7 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result { "screenX": num("screenX", -FAR, FAR, 0.0), "screenY": num("screenY", -FAR, FAR, 0.0), "button": num("button", 0.0, 2.0, 0.0).round(), + "buttons": num("buttons", 0.0, 7.0, 0.0).round(), "detail": num("detail", 0.0, 1000.0, clicks).round(), "altKey": flag("altKey"), "ctrlKey": flag("ctrlKey"), diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 4760d058b..178bd8be1 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -745,7 +745,8 @@ function __uiMouseWanted(c, inherited) { } // A ScriptUI MouseEvent. `v`: {x, y (relative to the target control), button (0 left, 1 middle, -// 2 right), detail (click count), screenX, screenY, altKey, ctrlKey, metaKey, shiftKey}. +// 2 right), buttons (held: 1 left, 2 right, 4 middle), detail (click count), screenX, screenY, +// altKey, ctrlKey, metaKey, shiftKey}. function __uiMouseEvent(type, target, v) { var num = function (x, d) { x = Number(x); @@ -765,6 +766,7 @@ function __uiMouseEvent(type, target, v) { timeStamp: new Date(), detail: num(v.detail, type === "mousemove" || type === "mouseover" || type === "mouseout" ? 0 : 1), button: num(v.button, 0), + buttons: num(v.buttons, 0), clientX: num(v.x, 0), clientY: num(v.y, 0), screenX: num(v.screenX, 0), diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index d12309b02..14460ed28 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -395,7 +395,11 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & return; } let mut st: MouseTrack = ui.data(|d| d.get_temp(id)).unwrap_or_default(); - let (latest, events, mods, time) = ui.input(|i| (i.pointer.latest_pos(), i.events.clone(), i.modifiers, i.time)); + let (latest, events, mods, time, held) = ui.input(|i| { + let held = |b: egui::PointerButton, bit: u8| if i.pointer.button_down(b) { bit } else { 0 }; + let held = held(egui::PointerButton::Primary, 1) | held(egui::PointerButton::Secondary, 2) | held(egui::PointerButton::Middle, 4); + (i.pointer.latest_pos(), i.events.clone(), i.modifiers, i.time, held) + }); let ctx = ui.ctx(); let layer = ui.layer_id(); let target = |p: egui::Pos2| -> Option<&Widget> { @@ -414,7 +418,7 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & w.id, json!({ "type": ty, "x": p.x - at.x, "y": p.y - at.y, "screenX": p.x, "screenY": p.y, - "button": button, "detail": detail, + "button": button, "buttons": held, "detail": detail, "altKey": mods.alt, "ctrlKey": mods.ctrl, "shiftKey": mods.shift, "metaKey": mods.mac_cmd, }), )); @@ -431,10 +435,15 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & } st.hover = now_id; } - if let Some((w, p)) = now - && st.at != Some(p) - { - send(w, "mousemove", p, 0, 0, acts); + // While a button is held, the control it was pressed on keeps getting the moves, inside or + // outside the window, as in After Effects (#2584). + let captured = st.down.and_then(|(c, _)| root.find(c)); + if let Some(p) = latest.filter(|p| st.at != Some(*p)) { + match (captured, now) { + (Some(c), _) => send(c, "mousemove", p, 0, 0, acts), + (None, Some((w, _))) => send(w, "mousemove", p, 0, 0, acts), + (None, None) => {} + } } st.at = latest; // Presses and releases. @@ -446,7 +455,9 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & egui::PointerButton::Secondary => 2, _ => continue, }; - let Some(w) = target(*pos) else { + // A release goes to the control the button was pressed on, wherever the pointer is. + let pressed_on = st.down.filter(|(_, db)| !*pressed && *db == b).and_then(|(c, _)| root.find(c)); + let Some(w) = pressed_on.or_else(|| target(*pos)) else { if !pressed { st.down = None; } @@ -460,7 +471,8 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & send(w, "mousedown", *pos, b, st.clicks, acts); } else { send(w, "mouseup", *pos, b, st.clicks, acts); - if st.down == Some((w.id, b)) && b == 0 && w.enabled && w.mouse.iter().any(|m| m == "click") { + let over = target(*pos).is_some_and(|t| t.id == w.id); + if over && st.down == Some((w.id, b)) && b == 0 && w.enabled && w.mouse.iter().any(|m| m == "click") { // The click with the pointer's details, after mousedown / mouseup, in place of // the plain click the control reported. acts.retain(|a| !matches!(a, Act::Click(aw, ac) if *aw == win && *ac == w.id)); diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs index aa439737b..2669a2631 100644 --- a/crates/ui-egui/tests/it/ui_scriptui.rs +++ b/crates/ui-egui/tests/it/ui_scriptui.rs @@ -264,6 +264,47 @@ fn script_windows_send_mouse_events_to_listeners() { h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); } +/// #2584: a drag that starts on a control keeps reaching it outside the script window (moves and +/// the release), and `MouseEvent.buttons` tells a drag from a hover. +#[test] +fn a_drag_keeps_its_control_past_the_window_edge() { + let mut h = harness(); + let code = r#" + var w = new Window("palette", "Drag"); + var pad = w.add("group", undefined, undefined, { name: "pad" }); + pad.preferredSize = [120, 80]; + var log = w.add("statictext", undefined, "", { name: "log" }); + log.preferredSize = [400, 20]; + var seen = []; + var rec = function (e) { seen.push(e.type + (e.clientX < 0 ? " out" : " in") + " B" + e.buttons); log.text = seen.join("|"); }; + pad.addEventListener("mousedown", rec); + pad.addEventListener("mousemove", rec); + pad.addEventListener("mouseup", rec); + w.show(); + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "drag.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(3); + let win = h.state().session.script_ui.windows[0].id; + let pad = rect(&h, &format!("scriptui.{win}.pad")); + let (from, to) = (pad.min + vec2(10.0, 20.0), pad.min + vec2(-300.0, 20.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + for k in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))); + h.run_steps(1); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + let log = h.state().session.script_ui.windows[0].root.lookup(&json!("log")).cloned().unwrap().text; + assert!(log.starts_with("mousemove in B0|mousedown in B1|"), "a hover move has no button held: {log}"); + assert!(log.contains("mousedown in B1|mousemove out B1"), "moves outside the window still arrive, with the button held: {log}"); + assert!(log.ends_with("|mouseup out B0"), "the release outside reaches the control: {log}"); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); +} + fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) { h.input_mut().events.push(Event::PointerMoved(c)); h.run_steps(1); From a38328cc3a31f6bdda9fa6f3196d228076ed5dd4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 07:11:40 +0700 Subject: [PATCH 104/112] Keep the PR building with main: Layers stays off psd documents; preview merge and fmt --- crates/host/tests/it/footage_reload.rs | 6 +++--- crates/project/src/lib.rs | 2 +- crates/psd/src/memory_tests.rs | 2 +- crates/psd/src/parse.rs | 2 +- crates/psd/src/tests.rs | 2 +- crates/psd/src/tests/opt.rs | 2 +- crates/ui-egui/src/panels/preview_merge.rs | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/host/tests/it/footage_reload.rs b/crates/host/tests/it/footage_reload.rs index 3d47d0783..faf2b09e3 100644 --- a/crates/host/tests/it/footage_reload.rs +++ b/crates/host/tests/it/footage_reload.rs @@ -24,7 +24,7 @@ const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; /// A one-colour document; `extra` adds a hidden layer, so the file's size changes too. fn flat(rgba: [f32; 4], extra: bool) -> Vec { let mut d = WDoc::new(64, 48); - d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), rgba)].into(); + d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), rgba)]; if extra { let mut hidden = WLayer::solid("Hidden", Rect::new(0, 0, 8, 8), RED); hidden.hidden = true; @@ -86,7 +86,7 @@ fn reload_footage_shows_the_new_pixels() { fn reload_keeps_a_photoshop_layer_item_on_its_layer() { let path = tmp("layers.psd"); let mut d = WDoc::new(64, 48); - d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED), WLayer::solid("Patch", Rect::new(0, 0, 32, 48), BLUE)].into(); + d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED), WLayer::solid("Patch", Rect::new(0, 0, 32, 48), BLUE)]; std::fs::write(&path, write(&d)).unwrap(); let mut s = effectcraft_host::session(); let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap(); @@ -100,7 +100,7 @@ fn reload_keeps_a_photoshop_layer_item_on_its_layer() { WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED), WLayer::solid("Added", Rect::new(40, 0, 8, 8), RED), WLayer::solid("Patch", Rect::new(0, 0, 24, 48), GREEN), - ].into(); + ]; save(&path, &write(&d)); let footage_items: Vec = items.iter().filter(|i| matches!(s.project.item(**i).map(|x| &x.kind), Some(ItemKind::Footage(_)))).map(|i| i.0).collect(); s.execute_checked("file.reloadFootage", json!({"items": footage_items})).unwrap(); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 384c1c7b1..3aa855835 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -39,8 +39,8 @@ mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; 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 layers::Layers; pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte}; +pub use layers::Layers; pub use settings::{ APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView, TimeDisplayStyle, is_newer_version, saved_by, saved_view, diff --git a/crates/psd/src/memory_tests.rs b/crates/psd/src/memory_tests.rs index b864aa42b..108046b66 100644 --- a/crates/psd/src/memory_tests.rs +++ b/crates/psd/src/memory_tests.rs @@ -255,7 +255,7 @@ fn doc(depth: u16, mode: ColorMode, rle: bool) -> WDoc { let mut masked = WLayer::pixels("masked", Rect::new(10, 8, 12, 12), grad(12, 12)); masked.mask = Some(WMask { rect: Rect::new(12, 10, 12, 12), data: (0..144).map(|i| (i % 12) as f32 / 11.0).collect(), default_color: 255, disabled: false }); - d.layers = vec![bg, off, solid, masked].into(); + d.layers = vec![bg, off, solid, masked]; d } diff --git a/crates/psd/src/parse.rs b/crates/psd/src/parse.rs index 6a83bb30b..d221b9044 100644 --- a/crates/psd/src/parse.rs +++ b/crates/psd/src/parse.rs @@ -184,7 +184,7 @@ impl Psd { r.skip(c.len)?; } } - self.layers = layers.into(); + self.layers = layers; Ok(()) } diff --git a/crates/psd/src/tests.rs b/crates/psd/src/tests.rs index 054aca2ed..074976510 100644 --- a/crates/psd/src/tests.rs +++ b/crates/psd/src/tests.rs @@ -25,7 +25,7 @@ fn doc_with_layers(depth: u16, mode: ColorMode, rle: bool) -> WDoc { // Mask: left half opaque, right half transparent; default (outside) black. let mrect = Rect::new(20, 10, 12, 12); masked.mask = Some(WMask { rect: mrect, data: (0..144).map(|i| if i % 12 < 6 { 1.0 } else { 0.0 }).collect(), default_color: 0, disabled: false }); - d.layers = vec![bg, red, masked].into(); + d.layers = vec![bg, red, masked]; d } diff --git a/crates/psd/src/tests/opt.rs b/crates/psd/src/tests/opt.rs index c0c07eeb6..908026d07 100644 --- a/crates/psd/src/tests/opt.rs +++ b/crates/psd/src/tests/opt.rs @@ -407,7 +407,7 @@ fn base() -> Psd { let mut masked = WLayer::solid("Masked", Rect::new(20, 10, 12, 12), [0.0, 0.5, 1.0, 1.0]); let data = (0..144).map(|i| if i % 12 < 6 { 1.0 } else { 0.25 }).collect(); masked.mask = Some(WMask { rect: Rect::new(20, 10, 12, 12), data, default_color: 0, disabled: false }); - d.layers = vec![bg, red, masked].into(); + d.layers = vec![bg, red, masked]; Psd::parse(write(&d)).unwrap() } diff --git a/crates/ui-egui/src/panels/preview_merge.rs b/crates/ui-egui/src/panels/preview_merge.rs index 4eb99f2f0..ab41e3167 100644 --- a/crates/ui-egui/src/panels/preview_merge.rs +++ b/crates/ui-egui/src/panels/preview_merge.rs @@ -124,7 +124,7 @@ fn comp(before: &Comp, after: &Comp, now: &Comp) -> Comp { layers.insert(at.min(layers.len()), back); } } - merged.layers = layers; + merged.layers = layers.into(); merged } From 0d551b672d083a03dcdb7c5639eed740074413f6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 07:18:14 +0700 Subject: [PATCH 105/112] i18n: catalogs list each source once again; Georgian panels stay registered #2054's own merge of main re-added zh-hans/zh-hant rows that main already had, so catalogs_are_unique_and_keep_the_english_shape failed on main. The catalog is the batch-tested one, with ('ka', GEORGIAN_PANELS) back in ROWS (the batch's merge helper had folded it into the first 'ka' entry). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ui.rs | 282 ++++++++++++++++------------------ 1 file changed, 136 insertions(+), 146 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 8f5b22c24..1da662aad 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1963,6 +1963,74 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "总偏移"), ("Per Layer", "每个图层"), ("Track", "跟踪"), + ("Selection Tool", "选择工具"), + ("Hand Tool", "抓手工具"), + ("Zoom Tool", "缩放工具"), + ("Orbit Around Cursor Tool", "绕行摄像机工具"), + ("Pan Under Cursor Tool", "平移摄像机工具"), + ("Dolly Towards Cursor Tool", "推拉摄像机工具"), + ("Rotation Tool", "旋转工具"), + ("Pan Behind (Anchor Point) Tool", "向后平移工具"), + ("Rectangle Tool", "矩形工具"), + ("Rounded Rectangle Tool", "圆角矩形工具"), + ("Ellipse Tool", "椭圆工具"), + ("Polygon Tool", "多边形工具"), + ("Star Tool", "星形工具"), + ("Pen Tool", "钢笔工具"), + ("Add Vertex Tool", "添加顶点工具"), + ("Delete Vertex Tool", "删除顶点工具"), + ("Convert Vertex Tool", "转换顶点工具"), + ("Mask Feather Tool", "蒙版羽化工具"), + ("Horizontal Type Tool", "横排文字工具"), + ("Vertical Type Tool", "直排文字工具"), + ("Brush Tool", "画笔工具"), + ("Clone Stamp Tool", "仿制图章工具"), + ("Eraser Tool", "橡皮擦工具"), + ("Roto Brush Tool", "Roto 笔刷工具"), + ("Refine Edge Tool", "调整边缘工具"), + ("Object Matte Tool", "对象遮罩工具"), + ("Puppet Position Pin Tool", "人偶位置控点工具"), + ("Puppet Starch Pin Tool", "人偶扑粉控点工具"), + ("Puppet Bend Pin Tool", "人偶弯曲控点工具"), + ("Puppet Advanced Pin Tool", "人偶高级控点工具"), + ("Puppet Overlap Pin Tool", "人偶重叠控点工具"), + ("Shortcut", "快捷键"), + ("Range", "范围"), + ("Play From", "从以下位置播放"), + ("Frame Rate", "帧速率"), + ("Resolution", "分辨率"), + ("Overlays", "叠加"), + ("Layer Controls", "图层控制"), + ("Loop", "循环"), + ("Cache Before Playback", "播放前缓存"), + ("Pre-roll", "预卷"), + ("Post-roll", "后卷"), + ("If caching, play cached frames", "缓存时播放已缓存帧"), + ("Apply", "应用"), + ("Analyze", "分析"), + ("Create Camera", "创建摄像机"), + ("Add Point", "添加点"), + ("Remove Point", "移除点"), + ("Render", "渲染"), + ("Save Current as Template…", "将当前项存为模板…"), + ("New Composition From Footage", "从素材新建合成"), + ("Search effects and presets", "搜索效果和预设"), + ("Search project", "搜索项目"), + ("Search", "搜索"), + ("(no composition)", "没有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步"), + ("Building waveform…", "正在生成波形…"), + ("No effects. Drag one from Effects & Presets.", "没有效果。从效果和预设中拖入一个。"), + ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "点击色环添加颜色;按住 Alt 点击色标(或将其拖出)可移除"), + ("Start from a ready-made project. Opening a template makes an untitled copy.", "从现成项目开始。打开模板会创建一份未命名副本。"), + ("Square Pixels", "方形像素"), + ("Basic", "基本"), + ("3D Renderer", "3D 渲染器"), + ("Black", "黑色"), + ("White", "白色"), + ("Composition Settings", "合成设置"), ("Force alphabetical order", "强制按字母顺序"), ("Frame rate (fps)", "帧速率 (fps)"), ("Import Kind", "导入类型"), @@ -2112,7 +2180,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Create Nulls From Paths", "从路径创建空对象"), ("VR Comp Editor", "VR 合成编辑器"), ("Media Duration", "媒体持续时间"), - ("Frame Rate", "帧速率"), ("File Path", "文件路径"), ("Interpret Footage", "解释素材"), ("Create a new Folder", "新建文件夹"), @@ -2134,14 +2201,12 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Real name", "真实姓名"), ("ArtCraft website", "ArtCraft 网站"), ("Source code on GitHub", "GitHub 上的源代码"), - ("3D Renderer", "3D 渲染器"), ("Preset Name", "预设名称"), ("Save Preset…", "保存预设…"), ("Delete Preset", "删除预设"), ("Lock Aspect Ratio", "锁定长宽比"), ("Frame Rate presets", "帧速率预设"), ("Background Color", "背景颜色"), - ("Basic", "基本"), ("Shutter Angle", "快门角度"), ("Shutter Phase", "快门相位"), ("Samples Per Frame", "每帧采样数"), @@ -2226,7 +2291,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Show Effects for All Color Depths", "显示所有颜色深度的效果"), ("* Animation Presets", "* 动画预设"), ("Select a layer to apply to", "请选择要应用到的图层"), - ("Search effects and presets", "搜索效果和预设"), ("Alpha Expansion:", "Alpha 扩展:"), ("Fill Method:", "填充方式:"), ("Edge Blend", "边缘混合"), @@ -2241,7 +2305,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Create Shadow Catcher, Camera and Light", "创建阴影捕捉器、摄像机和灯光"), ("Unable to solve camera", "无法求解摄像机"), ("Waiting to solve", "等待求解"), - ("Create Camera", "创建摄像机"), ("track point", "跟踪点"), ("Ruler zero point", "标尺零点"), ("Vertical ruler", "垂直标尺"), @@ -2264,74 +2327,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Blend Mode", "混合模式"), ("Show mesh", "显示网格"), ("Record Options...", "记录选项..."), - ("Selection Tool", "选择工具"), - ("Hand Tool", "抓手工具"), - ("Zoom Tool", "缩放工具"), - ("Orbit Around Cursor Tool", "绕行摄像机工具"), - ("Pan Under Cursor Tool", "平移摄像机工具"), - ("Dolly Towards Cursor Tool", "推拉摄像机工具"), - ("Rotation Tool", "旋转工具"), - ("Pan Behind (Anchor Point) Tool", "向后平移工具"), - ("Rectangle Tool", "矩形工具"), - ("Rounded Rectangle Tool", "圆角矩形工具"), - ("Ellipse Tool", "椭圆工具"), - ("Polygon Tool", "多边形工具"), - ("Star Tool", "星形工具"), - ("Pen Tool", "钢笔工具"), - ("Add Vertex Tool", "添加顶点工具"), - ("Delete Vertex Tool", "删除顶点工具"), - ("Convert Vertex Tool", "转换顶点工具"), - ("Mask Feather Tool", "蒙版羽化工具"), - ("Horizontal Type Tool", "横排文字工具"), - ("Vertical Type Tool", "直排文字工具"), - ("Brush Tool", "画笔工具"), - ("Clone Stamp Tool", "仿制图章工具"), - ("Eraser Tool", "橡皮擦工具"), - ("Roto Brush Tool", "Roto 笔刷工具"), - ("Refine Edge Tool", "调整边缘工具"), - ("Object Matte Tool", "对象遮罩工具"), - ("Puppet Position Pin Tool", "人偶位置控点工具"), - ("Puppet Starch Pin Tool", "人偶扑粉控点工具"), - ("Puppet Bend Pin Tool", "人偶弯曲控点工具"), - ("Puppet Advanced Pin Tool", "人偶高级控点工具"), - ("Puppet Overlap Pin Tool", "人偶重叠控点工具"), - ("Shortcut", "快捷键"), - ("Range", "范围"), - ("Play From", "从以下位置播放"), - ("Resolution", "分辨率"), - ("Overlays", "叠加"), - ("Layer Controls", "图层控制"), - ("Loop", "循环"), - ("Cache Before Playback", "播放前缓存"), - ("Pre-roll", "预卷"), - ("Post-roll", "后卷"), - ("If caching, play cached frames", "缓存时播放已缓存帧"), - ("Apply", "应用"), - ("Analyze", "分析"), - ("Create Camera", "创建摄像机"), - ("Add Point", "添加点"), - ("Remove Point", "移除点"), - ("Render", "渲染"), - ("Save Current as Template…", "将当前项存为模板…"), - ("New Composition From Footage", "从素材新建合成"), - ("Search effects and presets", "搜索效果和预设"), - ("Search project", "搜索项目"), - ("Search", "搜索"), - ("(no composition)", "没有合成"), - ("per second", "每秒"), - ("Waveform", "波形"), - ("steps", "步"), - ("Building waveform…", "正在生成波形…"), - ("No effects. Drag one from Effects & Presets.", "没有效果。从效果和预设中拖入一个。"), - ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "点击色环添加颜色;按住 Alt 点击色标(或将其拖出)可移除"), - ("Start from a ready-made project. Opening a template makes an untitled copy.", "从现成项目开始。打开模板会创建一份未命名副本。"), - ("Square Pixels", "方形像素"), - ("Basic", "基本"), - ("Advanced", "高级"), - ("3D Renderer", "3D 渲染器"), - ("Black", "黑色"), - ("White", "白色"), - ("Composition Settings", "合成设置"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -3022,6 +3017,74 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "總偏移"), ("Per Layer", "每個圖層"), ("Track", "追蹤"), + ("Selection Tool", "選取工具"), + ("Hand Tool", "手形工具"), + ("Zoom Tool", "縮放工具"), + ("Orbit Around Cursor Tool", "繞行攝影機工具"), + ("Pan Under Cursor Tool", "平移攝影機工具"), + ("Dolly Towards Cursor Tool", "推拉攝影機工具"), + ("Rotation Tool", "旋轉工具"), + ("Pan Behind (Anchor Point) Tool", "向後平移工具"), + ("Rectangle Tool", "矩形工具"), + ("Rounded Rectangle Tool", "圓角矩形工具"), + ("Ellipse Tool", "橢圓工具"), + ("Polygon Tool", "多邊形工具"), + ("Star Tool", "星形工具"), + ("Pen Tool", "筆型工具"), + ("Add Vertex Tool", "添加頂點工具"), + ("Delete Vertex Tool", "刪除頂點工具"), + ("Convert Vertex Tool", "轉換頂點工具"), + ("Mask Feather Tool", "遮色片羽化工具"), + ("Horizontal Type Tool", "橫排文字工具"), + ("Vertical Type Tool", "垂直文字工具"), + ("Brush Tool", "筆刷工具"), + ("Clone Stamp Tool", "仿製圖章工具"), + ("Eraser Tool", "橡皮擦工具"), + ("Roto Brush Tool", "Roto 筆刷工具"), + ("Refine Edge Tool", "調整邊緣工具"), + ("Object Matte Tool", "物件遮色片工具"), + ("Puppet Position Pin Tool", "人偶位置控點工具"), + ("Puppet Starch Pin Tool", "人偶撲粉控點工具"), + ("Puppet Bend Pin Tool", "人偶彎曲控點工具"), + ("Puppet Advanced Pin Tool", "人偶高級控點工具"), + ("Puppet Overlap Pin Tool", "人偶重疊控點工具"), + ("Shortcut", "快速鍵"), + ("Range", "範圍"), + ("Play From", "從下列位置播放"), + ("Frame Rate", "影格速率"), + ("Resolution", "解析度"), + ("Overlays", "覆疊"), + ("Layer Controls", "圖層控制"), + ("Loop", "循環"), + ("Cache Before Playback", "播放前快取"), + ("Pre-roll", "預卷"), + ("Post-roll", "後卷"), + ("If caching, play cached frames", "快取時播放已快取影格"), + ("Apply", "套用"), + ("Analyze", "分析"), + ("Create Camera", "建立攝影機"), + ("Add Point", "添加點"), + ("Remove Point", "移除點"), + ("Render", "算圖"), + ("Save Current as Template…", "將目前項目存為範本…"), + ("New Composition From Footage", "從素材新增合成"), + ("Search effects and presets", "搜尋效果與預設"), + ("Search project", "搜尋專案"), + ("Search", "搜尋"), + ("(no composition)", "沒有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步驟"), + ("Building waveform…", "正在產生波形…"), + ("No effects. Drag one from Effects & Presets.", "沒有效果。從效果與預設集中拖入一個。"), + ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "點擊色環新增顏色;按住 Alt 點擊色標(或將其拖出)即可移除"), + ("Start from a ready-made project. Opening a template makes an untitled copy.", "從現成專案開始。開啟範本會建立一份未命名副本。"), + ("Square Pixels", "方形像素"), + ("Basic", "基本"), + ("3D Renderer", "3D 算圖引擎"), + ("Black", "黑色"), + ("White", "白色"), + ("Composition Settings", "合成設置"), ("Force alphabetical order", "強制依字母順序"), ("Frame rate (fps)", "幀率 (fps)"), ("Import Kind", "匯入類型"), @@ -3192,14 +3255,12 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Real name", "真實姓名"), ("ArtCraft website", "ArtCraft 網站"), ("Source code on GitHub", "GitHub 上的原始碼"), - ("3D Renderer", "3D 轉譯器"), ("Preset Name", "預設集名稱"), ("Save Preset…", "儲存預設集…"), ("Delete Preset", "刪除預設集"), ("Lock Aspect Ratio", "鎖定長寬比"), ("Frame Rate presets", "幀率預設集"), ("Background Color", "背景顏色"), - ("Basic", "基本"), ("Shutter Angle", "快門角度"), ("Shutter Phase", "快門相位"), ("Samples Per Frame", "每幀取樣數"), @@ -3284,7 +3345,6 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Show Effects for All Color Depths", "顯示所有色彩深度的效果"), ("* Animation Presets", "* 動畫預設集"), ("Select a layer to apply to", "請選擇要套用的圖層"), - ("Search effects and presets", "搜尋效果與預設集"), ("Alpha Expansion:", "Alpha 擴張:"), ("Fill Method:", "填色方式:"), ("Edge Blend", "邊緣混合"), @@ -3299,7 +3359,6 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Create Shadow Catcher, Camera and Light", "建立陰影擷取器、攝影機和燈光"), ("Unable to solve camera", "無法求解攝影機"), ("Waiting to solve", "等待求解"), - ("Create Camera", "建立攝影機"), ("track point", "追蹤點"), ("Ruler zero point", "尺規零點"), ("Vertical ruler", "垂直尺規"), @@ -3322,75 +3381,6 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Blend Mode", "混合模式"), ("Show mesh", "顯示網格"), ("Record Options...", "記錄選項..."), - ("Selection Tool", "選取工具"), - ("Hand Tool", "手形工具"), - ("Zoom Tool", "縮放工具"), - ("Orbit Around Cursor Tool", "繞行攝影機工具"), - ("Pan Under Cursor Tool", "平移攝影機工具"), - ("Dolly Towards Cursor Tool", "推拉攝影機工具"), - ("Rotation Tool", "旋轉工具"), - ("Pan Behind (Anchor Point) Tool", "向後平移工具"), - ("Rectangle Tool", "矩形工具"), - ("Rounded Rectangle Tool", "圓角矩形工具"), - ("Ellipse Tool", "橢圓工具"), - ("Polygon Tool", "多邊形工具"), - ("Star Tool", "星形工具"), - ("Pen Tool", "筆型工具"), - ("Add Vertex Tool", "添加頂點工具"), - ("Delete Vertex Tool", "刪除頂點工具"), - ("Convert Vertex Tool", "轉換頂點工具"), - ("Mask Feather Tool", "遮色片羽化工具"), - ("Horizontal Type Tool", "橫排文字工具"), - ("Vertical Type Tool", "垂直文字工具"), - ("Brush Tool", "筆刷工具"), - ("Clone Stamp Tool", "仿製圖章工具"), - ("Eraser Tool", "橡皮擦工具"), - ("Roto Brush Tool", "Roto 筆刷工具"), - ("Refine Edge Tool", "調整邊緣工具"), - ("Object Matte Tool", "物件遮色片工具"), - ("Puppet Position Pin Tool", "人偶位置控點工具"), - ("Puppet Starch Pin Tool", "人偶撲粉控點工具"), - ("Puppet Bend Pin Tool", "人偶彎曲控點工具"), - ("Puppet Advanced Pin Tool", "人偶高級控點工具"), - ("Puppet Overlap Pin Tool", "人偶重疊控點工具"), - ("Shortcut", "快速鍵"), - ("Range", "範圍"), - ("Play From", "從下列位置播放"), - ("Frame Rate", "影格速率"), - ("Resolution", "解析度"), - ("Overlays", "覆疊"), - ("Layer Controls", "圖層控制"), - ("Loop", "循環"), - ("Cache Before Playback", "播放前快取"), - ("Pre-roll", "預卷"), - ("Post-roll", "後卷"), - ("If caching, play cached frames", "快取時播放已快取影格"), - ("Apply", "套用"), - ("Analyze", "分析"), - ("Create Camera", "建立攝影機"), - ("Add Point", "添加點"), - ("Remove Point", "移除點"), - ("Render", "算圖"), - ("Save Current as Template…", "將目前項目存為範本…"), - ("New Composition From Footage", "從素材新增合成"), - ("Search effects and presets", "搜尋效果與預設"), - ("Search project", "搜尋專案"), - ("Search", "搜尋"), - ("(no composition)", "沒有合成"), - ("per second", "每秒"), - ("Waveform", "波形"), - ("steps", "步驟"), - ("Building waveform…", "正在產生波形…"), - ("No effects. Drag one from Effects & Presets.", "沒有效果。從效果與預設集中拖入一個。"), - ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "點擊色環新增顏色;按住 Alt 點擊色標(或將其拖出)即可移除"), - ("Start from a ready-made project. Opening a template makes an untitled copy.", "從現成專案開始。開啟範本會建立一份未命名副本。"), - ("Square Pixels", "方形像素"), - ("Basic", "基本"), - ("Advanced", "高級"), - ("3D Renderer", "3D 算圖引擎"), - ("Black", "黑色"), - ("White", "白色"), - ("Composition Settings", "合成設置"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. From 3bc1caaedbf53dac566ecd677b84d9400bae18ae Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 19:54:11 +0200 Subject: [PATCH 106/112] Shapes: per-corner roundness for Rectangle Path (#1896) Add Per-Corner Roundness to Rectangle Path: a checkbox (off by default) and four Pixels properties (top-left, top-right, bottom-right, bottom-left). New rectangles initialise the four values from their Roundness, so ticking the box keeps the shape; the radii are animatable and expression-driven.\n\nrect_radii() builds the path clockwise from the top-left with kappa quarter-circle arcs. Overlapping radii scale down like CSS border-radius; negative and non-finite radii are square; rect() delegates to it, so uniform roundness clamps to half the shorter side as before.\n\nLottie rc has one r: four equal static radii export as that value; mixed or animated radii export roundness 0 with a warning. Import puts r into the four per-corner values so ticking the box on an imported rectangle keeps the shape.\n\nTests: path geometry and clamping, rendered per-corner pixels, Lottie equal/mixed/checkbox-off export. --- crates/engine/src/cmd/paths.rs | 65 +++++++++++----- crates/engine/src/tests/stubs.rs | 64 +++++++++++++++ crates/lottie/src/shapes.rs | 22 +++++- crates/lottie/src/tests.rs | 36 +++++++++ crates/path/src/lib.rs | 77 +++++++++++++++++++ crates/project/src/build.rs | 7 ++ crates/render/src/shapes.rs | 15 +++- crates/render/src/shapes/tests.rs | 30 ++++++++ crates/ui-egui/src/panels/timeline/tests.rs | 5 ++ crates/ui-egui/tests/it/ui_timeline_shapes.rs | 5 ++ 10 files changed, 304 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 0bb2eaae2..7cd457640 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -5,6 +5,7 @@ use effectcraft_keyframe::{ShapePath, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSource, MaskMotionBlur, Node, PropGroup, Uid}; +use effectcraft_render::EvalCtx; use serde_json::{Value, json}; use super::layer_menu::target_masks; @@ -111,18 +112,30 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result { /// The parametric shape paths that Convert To Bezier Path replaces with an editable path. const PARAMETRIC: [&str; 3] = ["rect", "ellipse", "star"]; -/// The path a parametric shape group draws, in the group's own space, read at `lt`. This is the -/// geometry the renderer builds from the same values (`render::shapes::collect`), so the converted -/// path is the shape that was on screen rather than an approximation of it; the defaults match -/// that code for a property a group doesn't carry (a polygon has no inner radius or roundness). -fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option { - let f = |id: &str, d: f64| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_f64()); - let v2 = |id: &str, d: [f64; 2]| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_vec2()); +/// The path a parametric shape group draws, in the group's own space. This is the geometry the +/// renderer builds from the same values (`render::shapes::collect`), expressions included, so the +/// converted path is the shape that was on screen rather than an approximation of it; the defaults +/// match that code for a property a group doesn't carry (a polygon has no inner radius or +/// roundness). +fn parametric_path(ctx: &EvalCtx, layer: &effectcraft_project::Layer, g: &PropGroup) -> Option { + let f = |id: &str, d: f64| ctx.f(layer, g, id, d); + let v2 = |id: &str, d: [f64; 2]| ctx.v2(layer, g, id, d); let path = match g.match_id.as_str() { - "rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)), + "rect" => { + let (size, pos) = (v2("size", [100.0; 2]), v2("position", [0.0; 2])); + if ctx.b(layer, g, "perCorner") { + effectcraft_path::rect_radii( + size, + pos, + [f("topLeftRoundness", 0.0), f("topRightRoundness", 0.0), f("bottomRightRoundness", 0.0), f("bottomLeftRoundness", 0.0)], + ) + } else { + effectcraft_path::rect(size, pos, f("roundness", 0.0)) + } + } "ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])), "star" => effectcraft_path::polystar(effectcraft_path::Polystar { - star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0, + star: ctx.e(layer, g, "type") == 0, points: f("points", 5.0), pos: v2("position", [0.0; 2]), rotation: f("rotation", 0.0), @@ -160,28 +173,40 @@ fn convert_to_bezier(s: &mut Session, p: &Value) -> Result { } let t = s.time(); let n = shapes.len(); + // Read the geometry before editing, with the same expression-aware context the renderer + // uses: an expression-driven corner radius, size or position must convert to what is on + // screen, not to the property's underlying value. + let expr = s.expr.clone(); + let prepared = { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; + let mut prepared = Vec::new(); + for (lid, uid) in &shapes { + let Some(l) = comp.layer(*lid) else { continue }; + let Some(old) = l.props.sub("contents").and_then(|c| c.find_group(*uid)) else { continue }; + let Some(path) = parametric_path(&ctx, l, old) else { continue }; + // Path Direction is the shape's own, not a default: reversing is a separate command. + prepared.push((*lid, *uid, path, ctx.e(l, old, "direction"), old.enabled)); + } + prepared + }; let made = s.edit("Convert To Bezier Path", None, |proj, st| { let mut next = proj.next_id; let mut made = Vec::new(); - for (lid, uid) in &shapes { - let l = layer_mut(proj, cid, *lid)?; - let lt = l.layer_time(t); + for (lid, uid, path, direction, enabled) in prepared { + let l = layer_mut(proj, cid, lid)?; let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?; - let Some(old) = contents.find_group(*uid) else { continue }; - let Some(path) = parametric_path(old, lt) else { continue }; - // Path Direction is the shape's own, not a default: reversing is a separate command. - let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum()); - let enabled = old.enabled; let mut g = build::shape_path(&mut Ids(&mut next), path); if let Some(pr) = g.prop_mut("direction") { pr.value = KV::Enum(direction); } g.enabled = enabled; let new_uid = g.uid; - let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?; - let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue }; + let parent = contents.parent_of_mut(uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?; + let Some(at) = parent.children.iter().position(|c| c.uid() == uid) else { continue }; parent.children[at] = Node::Group(g); - made.push((*lid, new_uid)); + made.push((lid, new_uid)); } proj.next_id = next; // The new paths are what is selected, so the vertices can be edited straight away. diff --git a/crates/engine/src/tests/stubs.rs b/crates/engine/src/tests/stubs.rs index 9735f2c08..087a4ae1a 100644 --- a/crates/engine/src/tests/stubs.rs +++ b/crates/engine/src/tests/stubs.rs @@ -585,6 +585,70 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() { assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys)); } +/// #1896: Convert To Bezier Path reads Per-Corner Roundness, not just the uniform Roundness, so +/// the editable path is the shape that was on screen. +#[test] +fn convert_to_bezier_honours_per_corner_roundness() { + let mut s = comp(); + let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap(); + let (uid, _) = first_shape(&s, l); + let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid; + let per_corner = uid_of(&s, "perCorner"); + let top_left = uid_of(&s, "topLeftRoundness"); + let bottom_right = uid_of(&s, "bottomRightRoundness"); + for (u, v) in [(per_corner, json!(true)), (top_left, json!(20.0)), (bottom_right, json!(10.0))] { + s.execute("prop.set", json!({"layer": l, "path": format!("@{u}"), "value": v})).unwrap(); + } + + s.state.selected_props = vec![(LayerId(l), uid)]; + s.execute("path.convertToBezier", json!({})).unwrap(); + let (new_uid, now) = first_shape(&s, l); + assert_eq!(now, "path"); + let path = shape_path_of(&s, l, new_uid); + let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 10.0, 0.0])).into_iter().next().unwrap(); + assert_eq!(path, expected, "the converted path has the per-corner geometry"); +} + +/// #1896: Convert To Bezier Path evaluates the shape's expressions, so an expression-driven +/// Per-Corner checkbox and radius convert to the geometry on screen. +#[test] +fn convert_to_bezier_evaluates_corner_expressions() { + struct Corners; + impl effectcraft_render::ExprHost for Corners { + fn eval( + &self, + _: &effectcraft_render::EvalCtx, + _: &effectcraft_project::Layer, + prop: &effectcraft_project::Property, + value: &KV, + ) -> std::result::Result { + Ok(match prop.match_id.as_str() { + "perCorner" => KV::Bool(true), + "topLeftRoundness" => KV::Scalar(20.0), + _ => value.clone(), + }) + } + } + + let mut s = comp(); + s.expr = Some(Arc::new(Corners)); + let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap(); + let (uid, _) = first_shape(&s, l); + let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid; + for (id, expression) in [("perCorner", "true"), ("topLeftRoundness", "20")] { + let u = uid_of(&s, id); + s.execute("prop.setExpression", json!({"layer": l, "path": format!("@{u}"), "expression": expression})).unwrap(); + } + + s.state.selected_props = vec![(LayerId(l), uid)]; + s.execute("path.convertToBezier", json!({})).unwrap(); + let (new_uid, now) = first_shape(&s, l); + assert_eq!(now, "path"); + let path = shape_path_of(&s, l, new_uid); + let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 0.0, 0.0])).into_iter().next().unwrap(); + assert_eq!(path, expected, "the expression-evaluated corner is converted"); +} + /// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a /// shared merge key; one undo must revert the whole drag, not the last frame. #[test] diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index 990a0f28b..fc823b775 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -113,6 +113,9 @@ impl Out<'_, '_> { fn f(&self, m: &str) -> f64 { self.g.get(m).map(|p| p.value.as_f64()).unwrap_or(0.0) } + fn flag(&self, m: &str) -> bool { + self.g.get(m).map(|p| p.value.as_bool()).unwrap_or(false) + } fn animated(&self, m: &str) -> bool { self.g.get(m).is_some_and(|p| p.is_animated() || p.has_expression()) } @@ -232,7 +235,19 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve dir(&mut o); o.put("s", "size", &anim::arr2); o.put("p", "position", &anim::arr2); - o.put("r", "roundness", &anim::num); + if o.flag("perCorner") { + let names = ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"]; + let radii = names.map(|m| o.f(m)); + // Lottie `rc` has one `r`: four equal static radii export as that value. + if radii.iter().all(|v| (v - radii[0]).abs() < 1e-6) && !names.iter().any(|m| o.animated(m)) && radii[0] >= 0.0 { + o.raw("r", json!(r(radii[0]))); + } else { + o.ex.warn(format!("{}: per-corner roundness has no Lottie rectangle equivalent (roundness 0 exported)", o.label)); + o.raw("r", json!(0)); + } + } else { + o.put("r", "roundness", &anim::num); + } } "el" => { dir(&mut o); @@ -447,6 +462,11 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec { diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 203f04c04..24b4690eb 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -896,3 +896,39 @@ fn muted_keys_are_omitted_from_the_export() { assert!((opacity_at(&p, cid, 1.0) - 150.0).abs() < 1e-6); assert!((opacity_at(&q, nc, 1.0) - 150.0).abs() < 1e-6); } + +#[test] +fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() { + let shape = |radii: [f64; 4], roundness: f64, per_corner: bool| { + let (mut p, cid) = setup(); + let l = shape_layer(&mut p, cid, move |ids| { + let mut g = build::shape_rect(ids, [100.0, 50.0], [0.0, 0.0], roundness); + g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner); + for (m, v) in + [("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])] + { + g.prop_mut(m).unwrap().value = Value::Scalar(v); + } + let fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]); + vec![build::shape_group(ids, "Rectangle 1", vec![g, fill])] + }); + push(&mut p, cid, l); + (p, cid) + }; + let rc = |json: &Json| -> Json { json["layers"][0]["shapes"][0]["it"].as_array().unwrap().iter().find(|i| i["ty"] == "rc").cloned().unwrap() }; + // Four equal static radii: `rc.r` carries the value, no warning. + let (p, cid) = shape([12.0; 4], 5.0, true); + let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert_eq!(rc(&json)["r"], json!(12.0)); + assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); + // Mixed radii: not representable, roundness 0 exported with a warning. + let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, true); + let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert_eq!(rc(&json)["r"], json!(0)); + assert!(warnings.iter().any(|w| w.contains("per-corner roundness")), "{warnings:?}"); + // The checkbox off exports the single Roundness and ignores the four values. + let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, false); + let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert_eq!(rc(&json)["r"]["k"], json!(5.0)); + assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); +} diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index 83e2102dc..61fd0e6c3 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -164,6 +164,52 @@ pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath { p } +/// Rectangle path centred at `pos` with independent corner radii `[top-left, top-right, +/// bottom-right, bottom-left]`, clockwise in screen coordinates. Negative and non-finite radii +/// are 0; radii that would overlap are scaled down as CSS `border-radius` does. +pub fn rect_radii(size: [f64; 2], pos: [f64; 2], radii: [f64; 4]) -> BezPath { + // Start at the end of the top-right arc on the right edge, like `rect`, and walk the corners + // clockwise: Trim Paths measures from the first point, so ticking per-corner roundness must + // not make a trim jump (#1896). + let w = if size[0].is_finite() { size[0].abs() } else { 0.0 }; + let h = if size[1].is_finite() { size[1].abs() } else { 0.0 }; + let (x0, y0) = (pos[0] - w / 2.0, pos[1] - h / 2.0); + let (x1, y1) = (pos[0] + w / 2.0, pos[1] + h / 2.0); + let mut r = radii.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }); + // CSS border-radius: when two radii on an edge overlap, scale every radius by the smallest + // edge ratio (a uniform radius then clamps to half the shorter side, as before). + let ratio = |a: f64, b: f64, edge: f64| if a + b > edge && a + b > 0.0 { (edge / (a + b)).min(1.0) } else { 1.0 }; + let f = ratio(r[0], r[1], w).min(ratio(r[3], r[2], w)).min(ratio(r[0], r[3], h)).min(ratio(r[1], r[2], h)); + if f < 1.0 { + for v in &mut r { + *v *= f; + } + } + let [tl, tr, br, bl] = r; + // Quarter-circle Bezier control distance. + const K: f64 = 0.552_284_749_830_793_6; + let mut p = BezPath::new(); + p.move_to((x1, y0 + tr)); + p.line_to((x1, y1 - br)); + if br > 0.0 { + p.curve_to((x1, y1 - br + K * br), (x1 - br + K * br, y1), (x1 - br, y1)); + } + p.line_to((x0 + bl, y1)); + if bl > 0.0 { + p.curve_to((x0 + bl - K * bl, y1), (x0, y1 - bl + K * bl), (x0, y1 - bl)); + } + p.line_to((x0, y0 + tl)); + if tl > 0.0 { + p.curve_to((x0, y0 + tl - K * tl), (x0 + tl - K * tl, y0), (x0 + tl, y0)); + } + p.line_to((x1 - tr, y0)); + if tr > 0.0 { + p.curve_to((x1 - tr + K * tr, y0), (x1, y0 + tr - K * tr), (x1, y0 + tr)); + } + p.close_path(); + p +} + pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath { to_kurbo(&ShapePath::ellipse(pos, size[0], size[1])) } @@ -419,6 +465,11 @@ mod tests { let round = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 40.0)], 0.0, 25.0, 0.0); let (a, b) = ends(&round[0]); assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "{a:?} {b:?}"); + // Per-corner roundness starts and runs in the same order: ticking the checkbox must not + // move a Trim Paths contour. + let corners = ops::trim(&[rect_radii([400.0, 300.0], [0.0, 0.0], [40.0; 4])], 0.0, 25.0, 0.0); + let (a, b) = ends(&corners[0]); + assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "per-corner: {a:?} {b:?}"); // Start > End: AE draws 30..70 % (plain: from x 80 on the bottom up to y −130 on the left). let swapped = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 70.0, 30.0, 0.0); let (a, b) = ends(&swapped[0]); @@ -456,6 +507,32 @@ mod tests { assert!((area - 200.0).abs() < 1.0, "{area}"); } + #[test] + fn rect_radii_uniform_matches_a_rounded_rect() { + for r in [0.0, 5.0, 25.0, 100.0] { + let p = rect_radii([100.0, 50.0], [10.0, 20.0], [r; 4]); + let clamped = r.min(25.0); + let expect = 100.0 * 50.0 - (4.0 - std::f64::consts::PI) * clamped * clamped; + assert!((p.area().abs() - expect).abs() < 1.0, "{r}: {} != {expect}", p.area()); + assert!((p.area() - rect([100.0, 50.0], [10.0, 20.0], r).area()).abs() < 1e-6); + } + } + + #[test] + fn rect_radii_mixes_corners_and_scales_overlaps() { + // Only the top-left and bottom-right corners round. + let p = rect_radii([100.0, 50.0], [0.0, 0.0], [10.0, 0.0, 20.0, 0.0]); + let expect = 100.0 * 50.0 - (1.0 - std::f64::consts::PI / 4.0) * (10.0 * 10.0 + 20.0 * 20.0); + assert!((p.area().abs() - expect).abs() < 1.0, "{}", p.area()); + // Radii wider than the top edge scale down together, like CSS border-radius. + let p = rect_radii([50.0, 100.0], [0.0, 0.0], [40.0, 40.0, 0.0, 0.0]); + let scaled = rect_radii([50.0, 100.0], [0.0, 0.0], [25.0, 25.0, 0.0, 0.0]); + assert!((p.area() - scaled.area()).abs() < 1e-9); + // Negative and non-finite radii are square corners. + let p = rect_radii([50.0, 50.0], [0.0, 0.0], [-5.0, f64::NAN, f64::INFINITY, 0.0]); + assert!((p.area().abs() - 2500.0).abs() < 1e-9); + } + #[test] fn stroke_has_area() { let r = rect([40.0, 40.0], [50.0, 50.0], 0.0); diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 3ff61ba7d..1aa8d7c90 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -385,11 +385,18 @@ pub fn shape_group(ids: &mut Ids, name: &str, items: Vec) -> PropGrou } pub fn shape_rect(ids: &mut Ids, size: [f64; 2], position: [f64; 2], roundness: f64) -> PropGroup { + // Per-corner radii start from the uniform Roundness, so ticking the box keeps the shape. + let r = if roundness.is_finite() { roundness.max(0.0) } else { 0.0 }; indexed(ids.group("rect", "Rectangle Path 1")) .with(ids.prop("direction", "Path Direction", Value::Enum(0)).with_ui(ParamUi::Hidden)) .with(ids.prop("size", "Size", Value::Vec2(size)).with_ui(ParamUi::Pixels)) .with(ids.prop("position", "Position", Value::Vec2(position)).with_ui(ParamUi::Point)) .with(ids.prop("roundness", "Roundness", Value::Scalar(roundness)).with_ui(ParamUi::Pixels)) + .with(ids.prop("perCorner", "Per-Corner Roundness", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(ids.prop("topLeftRoundness", "Top Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) + .with(ids.prop("topRightRoundness", "Top Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) + .with(ids.prop("bottomRightRoundness", "Bottom Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) + .with(ids.prop("bottomLeftRoundness", "Bottom Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) } pub fn shape_ellipse(ids: &mut Ids, size: [f64; 2], position: [f64; 2]) -> PropGroup { diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index bbc4283ff..2497ea807 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -269,7 +269,20 @@ fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Ve "rect" => { let size = ctx.v2(layer, g, "size", [100.0; 2]); let pos = ctx.v2(layer, g, "position", [0.0; 2]); - let p = effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)); + let p = if ctx.b(layer, g, "perCorner") { + effectcraft_path::rect_radii( + size, + pos, + [ + ctx.f(layer, g, "topLeftRoundness", 0.0), + ctx.f(layer, g, "topRightRoundness", 0.0), + ctx.f(layer, g, "bottomRightRoundness", 0.0), + ctx.f(layer, g, "bottomLeftRoundness", 0.0), + ], + ) + } else { + effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)) + }; push_shape(ctx, layer, g, p, arena, &mut live); } "ellipse" => { diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index e0c9086a7..ba7028a25 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -715,3 +715,33 @@ fn miter_corner_still_fits_its_buffer() { let b = shape_buf(&p, cid); assert!(b.img.height as f64 >= 50.0 + 20.0 * 4.0 * 0.5, "{}", b.img.height); } + +#[test] +fn rect_per_corner_roundness_rounds_the_named_corners() { + let render = |per_corner: bool, radii: [f64; 4], roundness: f64| { + let (p, cid) = shape_comp_sized(200, 100, move |ids| { + let mut g = build::shape_rect(ids, [100.0, 100.0], [0.0, 0.0], roundness); + g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner); + for (m, v) in + [("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])] + { + g.prop_mut(m).unwrap().value = Value::Scalar(v); + } + vec![g, build::shape_fill(ids, RED)] + }); + frame(&p, cid) + }; + // Only the top-left corner rounds: its corner pixels fade, the other three stay filled. + let img = render(true, [30.0, 0.0, 0.0, 0.0], 0.0); + assert!(alpha(&img, 52, 2) < 0.1, "top-left rounds: {}", alpha(&img, 52, 2)); + for (x, y) in [(148, 2), (148, 98), (52, 98)] { + assert!(alpha(&img, x, y) > 0.9, "({x},{y}) stays square: {}", alpha(&img, x, y)); + } + // The checkbox off ignores the four values (the single Roundness still rules). + let off = render(false, [30.0; 4], 0.0); + assert!((coverage(&off) - 100.0 * 100.0).abs() < 20.0, "{}", coverage(&off)); + // Four equal per-corner radii render like the same Roundness. + let per = render(true, [25.0; 4], 0.0); + let uni = render(false, [0.0; 4], 25.0); + assert!((coverage(&per) - coverage(&uni)).abs() < 5.0, "{} vs {}", coverage(&per), coverage(&uni)); +} diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 12259abf7..9a5c89f7d 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -195,6 +195,11 @@ fn shape_group_inner_contents_is_elided() { " Size", " Position", " Roundness", + " Per-Corner Roundness", + " Top Left Roundness", + " Top Right Roundness", + " Bottom Right Roundness", + " Bottom Left Roundness", " >Fill 1", " Composite", " Fill Rule", diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index 73686ee99..cd56db825 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -340,6 +340,11 @@ fn timeline_colour_eyedropper_samples_the_comp() { let fill = fill.expect("a fill colour"); fn groups(g: &effectcraft_engine::project::PropGroup, out: &mut Vec) { for c in g.groups() { + // The rectangle's path rows would push Fill below the rows the Timeline fits; the + // eyedropper only needs Fill, so leave that group collapsed. + if c.name == "Rectangle Path 1" { + continue; + } out.push(c.uid); groups(c, out); } From d249f255ae7852e23f285a473640b9720cf1819e Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 21:08:54 +0200 Subject: [PATCH 107/112] Shapes: export per-corner `rc.r` as a Lottie property The equal-radii and zero fallback branches wrote a bare number where the uniform branch (and every other Value field) writes `{a, k}`; external players may reject that form even though our importer reads it. Write `{"a": 0, "k": value}` in both branches and update the export assertions. --- crates/lottie/src/shapes.rs | 5 +++-- crates/lottie/src/tests.rs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index fc823b775..5b9d29970 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -239,11 +239,12 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve let names = ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"]; let radii = names.map(|m| o.f(m)); // Lottie `rc` has one `r`: four equal static radii export as that value. + // `r` is a property (`{a, k}`) like the uniform branch writes, not a bare number. if radii.iter().all(|v| (v - radii[0]).abs() < 1e-6) && !names.iter().any(|m| o.animated(m)) && radii[0] >= 0.0 { - o.raw("r", json!(r(radii[0]))); + o.raw("r", json!({"a": 0, "k": r(radii[0])})); } else { o.ex.warn(format!("{}: per-corner roundness has no Lottie rectangle equivalent (roundness 0 exported)", o.label)); - o.raw("r", json!(0)); + o.raw("r", json!({"a": 0, "k": 0})); } } else { o.put("r", "roundness", &anim::num); diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 24b4690eb..83c81e242 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -919,12 +919,12 @@ fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() { // Four equal static radii: `rc.r` carries the value, no warning. let (p, cid) = shape([12.0; 4], 5.0, true); let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); - assert_eq!(rc(&json)["r"], json!(12.0)); + assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 12.0})); assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); // Mixed radii: not representable, roundness 0 exported with a warning. let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, true); let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); - assert_eq!(rc(&json)["r"], json!(0)); + assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 0})); assert!(warnings.iter().any(|w| w.contains("per-corner roundness")), "{warnings:?}"); // The checkbox off exports the single Roundness and ignores the four values. let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, false); From 1c1faaa758793129571d3aaa4b244c1e0f85d70b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 07:26:44 +0700 Subject: [PATCH 108/112] Timeline: Shift-click adds effects and other groups to the selection (#2552) Clicking an effect, Layer Style, mask or shape item row in the Timeline always replaced the selection, so one animation preset couldn't hold several effects and styles. Shift or Cmd/Ctrl now adds the group to the selection, or takes a selected one out, as property rows and Effect Controls headers already do. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 10 ++++- crates/ui-egui/tests/it/ui_timeline_shapes.rs | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index e8e771d21..8971ba5c5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3591,7 +3591,15 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: } 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}))); + // Shift or Cmd/Ctrl adds the group to the selection, or takes a selected one out, as + // property rows and Effect Controls headers do (#2552: several effects and styles for + // one animation preset). + let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); + if add && app.session.state.selected_props.contains(&(layer.id, *uid)) { + actions.push(("prop.deselect".into(), json!({"layer": layer.id.0, "prop": uid}))); + } else { + actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false, "add": add}))); + } } if gr.double_clicked() { if fx.is_some() { diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index 73686ee99..c9b2245f8 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -448,3 +448,42 @@ fn effects_drag_to_a_new_place_in_the_timeline() { h.state_mut().session.execute("edit.undo", json!({})).unwrap(); assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"], "one undo step per drop"); } + +/// #2552: Shift-clicking effect rows in the Timeline adds them to the selection (and a second +/// Shift-click takes one out), so one animation preset can hold several effects. +#[test] +fn shift_click_selects_several_effects_for_a_preset() { + 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"] { + 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 shift_click = |h: &mut Harness<'_, EffectcraftApp>, uid: u64| { + let p = center(h, &format!("timeline.group.{uid}.name")); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }); + h.step(); + } + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + }; + let selected = |h: &Harness<'_, EffectcraftApp>| h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect::>(); + click(&mut h, &format!("timeline.group.{}.name", uids[0])); + assert_eq!(selected(&h), [uids[0]]); + shift_click(&mut h, uids[1]); + assert_eq!(selected(&h), [uids[0], uids[1]], "Shift adds the second effect"); + let path = std::env::temp_dir().join(format!("ec-2552-{}.ffx", std::process::id())); + let r = h.state_mut().session.execute("anim.savePreset", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["effects"], 2, "{r}"); + let _ = std::fs::remove_file(&path); + shift_click(&mut h, uids[1]); + assert_eq!(selected(&h), [uids[0]], "a second Shift-click takes it out"); +} From 5bb66bad745fcec1f68e0f6a28a7697ba596aec1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 07:37:19 +0700 Subject: [PATCH 109/112] ScriptUI: a docked panel gets its dock's size and onResize (#2585) A docked ScriptUI panel only ever saw its preferredSize: the host drew it in a scroll area at max(dock, preferred) and the script's window never learned the dock. scriptui.resize now gives a window a size: the script's window takes it, lays itself out again and runs onResizing / onResize. The dock host sends it whenever the panel's dock size changes, so window.size is the dock's, as in After Effects. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/scripts.rs | 9 ++++++ crates/engine/src/scriptui.rs | 11 ++++++++ crates/script/src/scriptui.js | 12 ++++++++ crates/ui-egui/src/panels/scriptui_view.rs | 11 ++++++++ crates/ui-egui/tests/it/ui_scriptui.rs | 32 ++++++++++++++++++++++ 5 files changed, 75 insertions(+) diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index a523770e3..a92d484a7 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -467,6 +467,15 @@ pub fn specs() -> Vec { always, scriptui::mouse ), + cmd!( + "scriptui.resize", + "Resize Script Window", + [], + None, + "{window?, size: [width, height] (pixels)} → the window takes the size, lays itself out again and its onResizing / onResize run (a docked ScriptUI panel gets its dock's size)", + always, + scriptui::resize + ), cmd!( "scriptui.close", "Close Script Window", diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index af89932fa..d8f9f1d1e 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -765,6 +765,17 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result { dispatch(s, ScriptUiEvent { window, widget, kind: (*ty).to_string(), value }) } +/// `scriptui.resize`: the host gave the window a new size (a docked panel's dock, #2585): the +/// script's window takes it, lays itself out again and gets `onResizing` / `onResize`. +pub(crate) fn resize(s: &mut Session, p: &Value) -> Result { + let c = "scriptui.resize"; + let window = window_p(s, p, c)?; + let size = p.get("size").and_then(Value::as_array).ok_or_else(|| crate::commands::bad(c, "missing `size` [width, height]"))?; + let side = |i: usize| size.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.round().clamp(1.0, 16384.0)); + let (Some(w), Some(h)) = (side(0), side(1)) else { return Err(crate::commands::bad(c, "`size` must be [width, height] in pixels")) }; + dispatch(s, ScriptUiEvent { window, widget: 0, kind: "resize".into(), value: json!([w, h]) }) +} + /// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel). pub(crate) fn close(s: &mut Session, p: &Value) -> Result { let window = window_p(s, p, "scriptui.close")?; diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 178bd8be1..ddfbc1f3e 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -912,6 +912,18 @@ function __uiDispatch(winId, id, kind, valueJson) { w.close(v === null ? 2 : v); return; } + // The host gave the window a new size (a docked panel's dock): take it, lay out, tell the script. + if (kind === "resize") { + var nw = Number(v && v[0]), nh = Number(v && v[1]); + var b = w.__ub || w.__lb; + if (nw > 0 && nh > 0 && (b[2] - b[0] !== nw || b[3] - b[1] !== nh)) { + w.__ub = [b[0], b[1], b[0] + nw, b[1] + nh]; + __uiLayout(w); + w.__fire("onResizing"); + w.__fire("onResize"); + } + return; + } var c = __uiFind(w, id); if (!c || c.enabled === false) return; var radio = function () { diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 14460ed28..753b2884e 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -26,6 +26,8 @@ enum Act { Close(u32), /// A mouse event (`scriptui.mouse` parameters without window and widget). Mouse(u32, u32, Value), + /// A docked panel's new size (`scriptui.resize`). + Resize(u32, [f32; 2]), } fn rect_of(origin: egui::Pos2, b: [f64; 4]) -> Rect { @@ -500,6 +502,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec) { Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})), Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})), Act::Close(w) => ("scriptui.close", json!({"window": w})), + Act::Resize(w, [x, y]) => ("scriptui.resize", json!({"window": w, "size": [x, y]})), Act::Mouse(w, id, mut v) => { if let Some(o) = v.as_object_mut() { o.insert("window".into(), json!(w)); @@ -582,6 +585,14 @@ pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) { // A modal script dialog blocks the panels too. let modal = app.session.script_ui.windows.iter().any(|w| w.modal); let mut acts = vec![]; + // The script learns its dock's size when it changes (#2585), as After Effects tells a + // docked panel: its window takes the size and onResize runs. + let dock = [rect.width().round(), rect.height().round()]; + let told = egui::Id::unique(("scriptui-dock-size", id)); + if ui.data(|d| d.get_temp::<[f32; 2]>(told)) != Some(dock) && dock[0] >= 1.0 && dock[1] >= 1.0 { + ui.data_mut(|d| d.insert_temp(told, dock)); + acts.push(Act::Resize(id, dock)); + } let content = rect; egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| { let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover()); diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs index 2669a2631..8a248155b 100644 --- a/crates/ui-egui/tests/it/ui_scriptui.rs +++ b/crates/ui-egui/tests/it/ui_scriptui.rs @@ -305,6 +305,38 @@ fn a_drag_keeps_its_control_past_the_window_edge() { h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); } +/// #2585: a window the host resizes takes the size, lays out again and runs onResize; a docked +/// ScriptUI panel gets its dock's size that way. +#[test] +fn script_windows_learn_the_size_they_are_given() { + let mut h = harness(); + let code = r#" + var w = new Window("palette", "Sized"); + var log = w.add("statictext", undefined, "", { name: "log" }); + log.preferredSize = [200, 20]; + w.onResize = function () { log.text = "resized " + w.size.width + "x" + w.size.height; }; + w.show(); + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "sized.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(2); + h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": [300, 180]})).unwrap(); + let win = h.state().session.script_ui.windows[0].clone(); + assert_eq!(win.root.lookup(&json!("log")).unwrap().text, "resized 300x180"); + let list = h.state_mut().session.execute("scriptui.list", json!({})).unwrap(); + assert_eq!(list[0]["size"], json!([300.0, 180.0]), "{list}"); + assert!(h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": ["wide", 1]})).is_err()); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); + // A docked panel: its window is its dock's size, not its preferred size. + h.state_mut().session.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap(); + h.run_steps(4); + let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Layer Tools.jsx").unwrap().clone(); + let dock = rect(&h, &format!("scriptui.{}", panel.id)); + assert_eq!([panel.root.bounds[2].round(), panel.root.bounds[3].round()], [dock.width().round() as f64, dock.height().round() as f64]); + h.state_mut().close_panel(PanelKind::ScriptPanel(panel.id)); + h.run_steps(2); +} + fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) { h.input_mut().events.push(Event::PointerMoved(c)); h.run_steps(1); From df21aff0d80ab36583cb3cae3a5cf447902cb95d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 07:46:50 +0700 Subject: [PATCH 110/112] Docs: ROADMAP progress log and ScriptUI parity for 30 October's features Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 11 ++++++++++- docs/scripting-parity.md | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f3e6750ed..740ec6010 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (localization: 303 more panel/dialog rows in Japanese and the Chinese catalogs; recounted after #1774: Japanese 27% → 46%, Simplified Chinese 29% → 46%, Traditional Chinese 29% → 38%, overall 18% → 24%) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (features of 30 October in the progress log: text Line Join, Timeline drag-reorder and multi-select of effects, viewer edge pan, ScriptUI drag capture and dock size; previously: localization recount after #1774) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -117,6 +117,14 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-30: Text strokes take a Line Join (Miter, Round or Bevel) in the Character panel, + Miter for new text as in After Effects (#2077). In the Timeline, effects, masks and shape items + drag to a new place in their stack (#2210) and Shift-click adds them to the selection, so one + animation preset can hold several (#2552). The Composition panel pans while a mask vertex is + held at its edge (#999). Effect buffers that pad a small layer stay within a memory budget + (#2261); an MP4 lasts as long as its edit list shows (#2592); the Render Queue's times tick + between frames (#2593). ScriptUI drags keep their control past the window edge with + `MouseEvent.buttons` (#2584), and docked panels get their dock's size and `onResize` (#2585). - 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it starts off, so existing shapes behave as before (#2489). @@ -169,6 +177,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Progress log: text Line Join, Timeline drag-reorder and Shift multi-select of effects, viewer edge pan, effect padding budget, MP4 edit-list duration, Render Queue clock, ScriptUI drag capture and docked-panel size | | 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 8d2934518..96e298ece 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -20,7 +20,7 @@ our own scripts; no real-world script library has been run against both apps. | Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | | Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) | | `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging | -| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | +| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch; a docked panel gets its dock's size and `onResize` (`scriptui.resize`, #2585); a drag keeps reaching the control it started on past the window edge, and `MouseEvent.buttons` reports the held buttons (#2584) | 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; a subfolder (one level) of either folder is a submenu named after it | 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 | `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)) | From 1158022585666a11d046351cc5ca448509d83c72 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 07:53:12 +0700 Subject: [PATCH 111/112] Settings: a dragged UI Scale applies on release (#2582) Dragging an integer setting applied every step, so UI Scale resized the interface under the pointer mid-drag. A dragged value now shows in its field and applies when the button is released (a typed one already waited for Enter). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/settings.rs | 13 ++++++++---- crates/ui-egui/tests/it/ui_panels.rs | 30 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index f4dd89bfc..562a90132 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -117,15 +117,20 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{}:", tr(label))).truncate()); let r = match kind { Kind::Int(lo, hi, unit) => { - let mut x = v.as_i64().unwrap_or(0); - // A typed number applies once entered: typing 85 into UI Scale - // doesn't resize the interface to 75 % at its first digit. + // A dragged value applies on release, and a typed one once + // entered: UI Scale doesn't resize the interface under the + // pointer mid-drag (#2582) or at a typed number's first digit. + let held = egui::Id::unique(("settings-drag", key)); + let mut x = ui.data(|d| d.get_temp::(held)).unwrap_or_else(|| v.as_i64().unwrap_or(0)); let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false); let r = ui.add(crate::widgets::drag_value(drag)); if !unit.is_empty() { ui.label(RichText::new(tr(unit)).color(t.text_dim)); } - if r.changed() { + if r.dragged() { + ui.data_mut(|d| d.insert_temp(held, x)); + } else if r.changed() || r.drag_stopped() { + ui.data_mut(|d| d.remove::(held)); acts.push(Act::Set(key.into(), json!(x))); } r diff --git a/crates/ui-egui/tests/it/ui_panels.rs b/crates/ui-egui/tests/it/ui_panels.rs index db9ef7373..6d06c6bf7 100644 --- a/crates/ui-egui/tests/it/ui_panels.rs +++ b/crates/ui-egui/tests/it/ui_panels.rs @@ -664,3 +664,33 @@ fn transition_presets_apply_from_effects_and_presets() { assert_eq!(fx.name, "Radial Wipe"); assert_eq!(fx.prop("completion").unwrap().keys.len(), 2); } + +/// #2582: dragging Settings' UI Scale doesn't resize the interface under the pointer; the value +/// applies once the button is released. +#[test] +fn ui_scale_applies_when_the_drag_ends() { + let mut h = harness(); + open_settings(&mut h); + let e = h.state().auto.find("settings.appearance.uiScale").expect("the UI Scale field"); + let from = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + 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 k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(10.0 * k as f32, 0.0))); + h.step(); + assert_eq!(scale(&h), (100, 1.0), "nothing applies mid-drag"); + } + h.input_mut().events.push(Event::PointerButton { + pos: from + egui::vec2(80.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.run_steps(4); + let (pct, zoom) = scale(&h); + assert!(pct > 100 && (zoom - pct as f32 / 100.0).abs() < 1e-6, "applied on release: {pct} {zoom}"); + click(&mut h, "settings.cancel"); + h.run_steps(2); + assert_eq!(scale(&h), (100, 1.0)); +} From f6222ff5d5c53dc2847c47e61774b10ce4567533 Mon Sep 17 00:00:00 2001 From: erythrofr <20369086+erythrofr@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:47:31 +0100 Subject: [PATCH 112/112] Tests: document crop/resize regressions Describe the dimension, pixel-preservation and extreme-input scenarios and their synthetic-frame helper to address the review's documentation warning. Test logic and the crop/resize implementation are unchanged. AI assistance: OpenAI Codex (GPT-6). --- crates/export/src/pipeline.rs | 4 ++++ crates/export/tests/it/render_settings.rs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 7b9e4111c..8e8c90561 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -1063,6 +1063,7 @@ mod tests { } } + /// Regress the locked-aspect example: cropping 1920x1080 must resize to 1280x1440. #[test] fn crop_resize_applies_margins_once() { use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; @@ -1077,6 +1078,7 @@ mod tests { assert_eq!(img.data.len(), 1280 * 1440); } + /// Apply output-module geometry to a synthetic frame without rendering or writing files. fn run_crop_resize(img: Image, output: &effectcraft_project::render_queue::OutputModule, scale: f64) -> Image { use effectcraft_project::render_queue::RenderSettings; let project = Project::default(); @@ -1097,6 +1099,7 @@ mod tests { Cx::new(&job).crop_resize(img) } + /// Match predicted dimensions across crop, padding, ROI scale, aspect locks and resize qualities. #[test] fn crop_resize_dimensions_match_the_output_module() { use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; @@ -1124,6 +1127,7 @@ mod tests { } } + /// Exercise zero/empty inputs, collapsed crops and existing caps on extreme single-axis resizes. #[test] fn crop_resize_zero_and_extreme_sizes_stay_bounded() { use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index c2d7dcbfa..0fd6d471b 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -174,6 +174,8 @@ fn crop_roi_and_resize() { assert_eq!((z.width(), z.height()), (32, 24)); } +/// Resizing to the cropped frame's own dimensions must preserve every decoded PNG pixel. +/// Compare against crop-only output for padding and scaled ROIs with both resize qualities. #[test] fn crop_resize_preserves_pixels_when_the_cropped_size_is_requested() { let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});