From ba04ab70a0274c022c5139dd08b554f83f387235 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 17:47:39 +0300 Subject: [PATCH 001/227] 3D text and shapes rasterise only the part the output shows (#1388) A 3D text or shape layer close to the camera was rasterised whole at its on-screen scale, up to ~16 million pixels (a quarter gigabyte as f32) per frame worker, though the frame showed a small part of it; behind the camera it was rasterised and not drawn. With 16 preview workers one such layer took the app to 8-14 GB. Renderer::raster_clip projects the output frame back onto the layer's plane (as the Classic 3D compositor draws it) and rasterises only those cells of an 8 x 8 grid over the content, in a window of the whole raster's pixel grid; nothing when the content is behind the camera. Cells keep a moving camera on one cached raster. Layers whose off-frame pixels can matter (effects, styles, masks, motion blur, depth of field, lights, Advanced 3D, per-character 3D, collapsed 2D transforms) are rasterised whole as before. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 89 +++++++- crates/render/src/shapes.rs | 10 +- crates/render/src/tests_raster_clip.rs | 281 +++++++++++++++++++++++++ crates/render/src/text.rs | 42 +++- crates/render/src/three_d/compose.rs | 46 ++++ 5 files changed, 454 insertions(+), 14 deletions(-) create mode 100644 crates/render/src/tests_raster_clip.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..47e301666 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -620,6 +620,9 @@ pub struct Renderer<'a> { through: [Option>; MAX_THROUGH], /// More collapsed levels than [`MAX_THROUGH`]: their motion is not blurred. through_lost: bool, + /// Rasterise 3D text and shapes whole, never only the part the output shows + /// ([`Renderer::raster_clip`]; tests compare against it). + pub(crate) whole_raster: bool, } /// One collapsed precomp layer a nested comp is drawn through. @@ -706,6 +709,7 @@ impl<'a> Renderer<'a> { top: true, through: [None; MAX_THROUGH], through_lost: false, + whole_raster: false, } } @@ -887,8 +891,9 @@ impl<'a> Renderer<'a> { } let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)); let prev = ctx.time - ctx.comp.frame_rate.frame_duration(); - let now = cache::layer_key(ctx, layer, scale, draft, blur); - now.is_some() && now == cache::layer_key(&ctx.at(prev), layer, scale, draft, blur) + let before = ctx.at(prev); + let now = self.clipped_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur)); + now.is_some() && now == self.clipped_key(&before, layer, cache::layer_key(&before, layer, scale, draft, blur)) } /// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers, @@ -921,14 +926,73 @@ impl<'a> Renderer<'a> { // a whole number maps the unit vectors to lengths a hair over 1, which must stay 1 (#390: // rounded up to 2^¼, unscaled text was drawn larger and resampled, softer). let mut k = 2f64.powf((k.log2() * 4.0 - 1e-6).ceil() / 4.0).clamp(1.0 / 16.0, 64.0); - // Keep the buffer within about 16 million pixels. + // Keep the buffer within about 16 million pixels (of the part the output shows, when + // only that is rasterised: see `raster_clip`). if let Some(b) = content_bounds(ctx, layer) { + let b = match self.raster_clip(ctx, layer) { + Some(c) => { + let r = kurbo::Rect::new(b[0], b[1], b[2], b[3]).intersect(c); + [r.x0, r.y0, r.x1, r.y1] + } + None => b, + }; let area = ((b[2] - b[0]) * (b[3] - b[1])).max(1.0) * s * s; k = k.min((16.0e6 / area).sqrt().max(1.0)); } s * k } + /// The layer-space part of a 3D text or shape layer the output shows, when only that part + /// needs rasterising (#1388: text close to the camera was rasterised whole at up to 16 + /// million pixels, a quarter gigabyte per frame worker, though the frame showed a fraction + /// of it; behind the camera it was rasterised and not drawn). `None` rasterises it whole: + /// whenever pixels outside the frame can matter — effects, layer styles, masks, motion blur, + /// depth of field, lights (shadows and transmission), Advanced 3D, per-character 3D, a + /// collapsed 2D transform — or the visible part can't be bounded (see + /// [`three_d::compose::visible_clip`]). + pub(crate) fn raster_clip(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + if self.whole_raster || !layer.is_3d() || !matches!(layer.source, LayerSource::Text | LayerSource::Shape) || self.outer.is_some() { + return None; + } + if (layer.switches.effects && layer.effects().is_some_and(|fx| !fx.children.is_empty())) + || layer.masks().is_some_and(|m| !m.children.is_empty()) + || styles::active(ctx, layer) + || ctx.comp.layers.iter().any(|l| matches!(l.source, LayerSource::Light { .. })) + || self.is_adv3d(ctx) + || (matches!(layer.source, LayerSource::Text) && text::per_char_3d(ctx, layer)) + { + return None; + } + let v = three_d::compose::visible_clip(self, ctx, layer)?; + if v.area() <= 0.0 { + // Behind the camera. + return Some(v); + } + let b = content_bounds(ctx, layer).map(|b| kurbo::Rect::new(b[0], b[1], b[2], b[3]))?; + if v.x0 <= b.x0 && v.y0 <= b.y0 && v.x1 >= b.x1 && v.y1 >= b.y1 { + return None; + } + // Whole cells of a layer-space grid whose cell is about a quarter of the part that shows, + // rounded down to a power of two: a moving camera keeps one raster (and its cache entry) + // while that part stays within the same cells, and a dolly changes the grid only at each + // doubling. The cells cover at most about 2.25 times the part that shows. Worth it only + // when it leaves out at least half of the content. + let c = 2f64.powf(((v.width().max(v.height()) / 4.0).max(1e-6)).log2().floor()); + let snap = |v: f64, up: bool| if up { (v / c).ceil() * c } else { (v / c).floor() * c }; + // (Not cut to the content bounds: a stroke or a glyph's padding reaches past them.) + let cells = kurbo::Rect::new(snap(v.x0, false), snap(v.y0, false), snap(v.x1, true), snap(v.y1, true)); + (cells.is_finite() && cells.intersect(b).area() <= b.area() * 0.5).then_some(cells) + } + + /// A layer content cache key that also names the part rasterised ([`Self::raster_clip`]). + fn clipped_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { + let k = key?; + Some(match self.raster_clip(ctx, layer) { + Some(c) => [c.x0, c.y0, c.x1, c.y1].iter().fold(cache::derive(k, 0xc11b), |d, v| cache::derive(d, (v * 16.0).round() as i64 as u64)), + None => k, + }) + } + /// Draw a Wireframe-quality layer: its bounds as a one-pixel outline. fn draw_wireframe(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { for (x, y) in self.wireframe_pixels(ctx, layer, (canvas.width, canvas.height)) { @@ -1341,8 +1405,11 @@ impl<'a> Renderer<'a> { let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?; Some(self.footage_buf(&img, pf, f.width, f.height)) } - LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), - LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))), + LayerSource::Text => Some(self.authored(text::render_in(ctx, layer, self.raster_scale(ctx, layer), self.raster_clip(ctx, layer)))), + LayerSource::Shape => layer + .props + .sub("contents") + .map(|c| self.authored(shapes::render_in(ctx, layer, c, self.raster_scale(ctx, layer), self.raster_clip(ctx, layer)))), _ => None, } } @@ -1516,7 +1583,7 @@ impl<'a> Renderer<'a> { fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc, Option)> { let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))); - let key = self.content_key(ctx, layer, key); + let key = self.content_key(ctx, layer, self.clipped_key(ctx, layer, key)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -1539,7 +1606,7 @@ impl<'a> Renderer<'a> { /// so scrubbing reuses frames rendered for earlier output frames. pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option> { let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects)); - let key = self.content_key(ctx, layer, key); + let key = self.content_key(ctx, layer, self.clipped_key(ctx, layer, key)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -2206,7 +2273,11 @@ impl<'a> Renderer<'a> { /// its effects show. `None` = the layer changes nothing. pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { // Cached, so an accelerator sees the same buffer (and uploads it once) every frame. - let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))); + let key = self.clipped_key( + ctx, + layer, + self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))), + ); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -2424,4 +2495,6 @@ mod tests_nested; #[cfg(test)] mod tests_paint; #[cfg(test)] +mod tests_raster_clip; +#[cfg(test)] mod tests_time; diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index f8de5a290..2808f283b 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -592,6 +592,12 @@ const BATCH_PIXELS: usize = 8 << 20; /// Each draw rasterises and composites only its own pixel rectangle; coverage masks of /// consecutive draws are rasterised in parallel, then composited in stacking order. pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf { + render_in(ctx, layer, contents, s, None) +} + +/// [`render`], only the part of the layer inside `clip` (layer space; the part the output shows, +/// see `Renderer::raster_clip`). +pub fn render_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64, clip: Option) -> Buf { let mut arena = Vec::new(); let (draws, _) = collect(ctx, layer, contents, &mut arena); // Bottom-most draw first. @@ -608,9 +614,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf let Some(b) = resolved.iter().map(|r| r.2).reduce(|a, b| a.union(b)) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } }; // Keep buffers sane for runaway geometry. let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0)); - let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384); - let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384); - let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0]; + let Some((w, h, offset)) = crate::text::raster_window(b, s, clip) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } }; let m = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s)); let px_rect = |r: kurbo::Rect| -> Option { let x0 = (r.x0 * s + offset[0]).floor().max(0.0); diff --git a/crates/render/src/tests_raster_clip.rs b/crates/render/src/tests_raster_clip.rs new file mode 100644 index 000000000..74da0e616 --- /dev/null +++ b/crates/render/src/tests_raster_clip.rs @@ -0,0 +1,281 @@ +//! #1388: 3D text and shape layers rasterise only the part of the layer the output shows. Where +//! the whole raster fits its pixel budget the frame is identical to rasterising the whole layer; +//! close to the camera the raster stays small instead of growing to ~16 million pixels. + +use effectcraft_color::Label; +use effectcraft_keyframe::{TextDoc, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +const W: u32 = 400; +const H: u32 = 300; + +/// The default camera's distance to the z = 0 plane. +fn zoom() -> f64 { + W as f64 * 50.0 / 36.0 +} + +fn setup() -> (Project, ItemId, Comp) { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + (p, cid, comp) +} + +fn set(l: &mut Layer, path: &str, v: Value) { + l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v; +} + +/// A 3D text layer at `pos`, turned `rot_y` degrees about Y. +fn text3(p: &mut Project, comp: &Comp, pos: [f64; 3], rot_y: f64) -> Layer { + let mut l = build::layer(p, comp, "Text", LayerSource::Text, (W, H), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "NEAR CAMERA TEXT".into(), size: 60.0, ..Default::default() })); + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3(pos)); + set(&mut l, "transform/rotationY", Value::Scalar(rot_y)); + l +} + +/// A 3D shape layer (a 300×120 rectangle with a stroke) at `pos`. +fn shape3(p: &mut Project, comp: &Comp, pos: [f64; 3]) -> Layer { + let mut l = build::layer(p, comp, "Shape", LayerSource::Shape, (W, H), None); + let mut next = p.next_id; + { + let mut ids = Ids(&mut next); + let r = build::shape_rect(&mut ids, [300.0, 120.0], [0.0, 0.0], 0.0); + let f = build::shape_fill(&mut ids, [0.9, 0.4, 0.1, 1.0]); + let s = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 6.0); + let g = build::shape_group(&mut ids, "Box", vec![r, f, s]); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + } + p.next_id = next; + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3(pos)); + l +} + +fn scene(l: impl FnOnce(&mut Project, &Comp) -> Layer) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let layer = l(&mut p, &comp); + p.comp_mut(cid).unwrap().layers.push(layer); + (p, cid) +} + +fn frame(p: &Project, cid: ItemId, whole: bool) -> effectcraft_raster::Image { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.whole_raster = whole; + r.comp_frame(cid, Tick::ZERO) +} + +/// Pixels of the layer's processed buffer. +fn raster_px(p: &Project, cid: ItemId, whole: bool) -> u64 { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.whole_raster = whole; + let c = p.comp(cid).unwrap(); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + let b = r.layer_buf(&ctx, &c.layers[0]).unwrap(); + b.img.width as u64 * b.img.height as u64 +} + +#[test] +fn the_visible_part_renders_the_same_frame_as_the_whole_layer() { + let cx = W as f64 / 2.0; + let cy = H as f64 / 2.0; + let cases: Vec<(&str, (Project, ItemId))> = vec![ + ("far", scene(|p, c| text3(p, c, [cx, cy, 300.0], 0.0))), + ("in the frame plane", scene(|p, c| text3(p, c, [cx, cy, 0.0], 0.0))), + ("past the frame edge", scene(|p, c| text3(p, c, [60.0, cy, -200.0], 0.0))), + ("turned", scene(|p, c| text3(p, c, [cx, cy, -150.0], 55.0))), + ("close to the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() + 40.0], 0.0))), + ("partly behind the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() + 30.0], 80.0))), + ("behind the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() - 200.0], 0.0))), + ("shape", scene(|p, c| shape3(p, c, [cx + 150.0, cy, -250.0]))), + ]; + for (label, (p, cid)) in &cases { + let clipped = frame(p, *cid, false); + let whole = frame(p, *cid, true); + assert_eq!(clipped.data, whole.data, "{label}"); + } +} + +/// Close to the camera only part of the text shows: its raster is a fraction of the whole one +/// (millions of pixels), and the frame still shows text. +#[test] +fn text_close_to_the_camera_rasterises_what_shows() { + let (p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let clipped = raster_px(&p, cid, false); + let whole = raster_px(&p, cid, true); + assert!(whole > 5_000_000, "the whole raster is huge ({whole} px)"); + eprintln!("near camera: {clipped} px rasterised, {whole} px whole"); + assert!(clipped * 10 < whole, "{clipped} px of {whole}"); + let img = frame(&p, cid, false); + let covered = img.data.iter().filter(|px| px[3] > 0.5).count(); + assert!(covered > (W * H / 20) as usize, "text shows ({covered} px)"); + // Behind the camera there is nothing to rasterise. + let (p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() - 200.0], 0.0)); + assert!(raster_px(&p, cid, false) <= 1); +} + +/// The #1388 case at full size: 200 pt text in a 1920 × 1080 comp, close to the camera. Rasterised +/// whole it fills the ~16 million pixel cap; clipped, it stays within a few frames' worth of pixels +/// (and the text still shows). +#[test] +fn hd_text_close_to_the_camera_stays_within_a_few_frames() { + let mut p = Project::default(); + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (1920, 1080), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "NEAR CAMERA".into(), size: 200.0, ..Default::default() })); + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3([960.0, 540.0, -1920.0 * 50.0 / 36.0 + 60.0])); + p.comp_mut(cid).unwrap().layers.push(l); + let (clipped, whole) = (raster_px(&p, cid, false), raster_px(&p, cid, true)); + eprintln!("HD near camera: {clipped} px rasterised, {whole} px whole"); + assert!(whole > 12_000_000, "{whole}"); + assert!(clipped <= 4 * 1920 * 1080, "{clipped} px"); + assert!(frame(&p, cid, false).data.iter().any(|px| px[3] > 0.5), "the text shows"); +} + +/// A slow move of the layer against the camera (a camera move does the same) keeps the part +/// rasterised within the same grid cells for several frames: they reuse the cached raster instead +/// of drawing the text again each frame. +#[test] +fn a_slow_move_reuses_the_clipped_raster() { + let cache = crate::LayerCache::default(); + let at = |x: f64| { + let (p, cid) = scene(|p, c| text3(p, c, [x, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + r.cache = Some(&cache); + r.comp_frame(cid, Tick::ZERO); + }; + at(W as f64 / 2.0); + let misses = cache.stats().misses; + // 16 frames, a quarter of a layer pixel apart (here about three output pixels). + for i in 1..=16 { + at(W as f64 / 2.0 + i as f64 * 0.25); + } + let rasters = cache.stats().misses - misses; + assert!(rasters <= 2, "{rasters} new rasters over 16 frames"); +} + +/// One keyframed motion: (seconds, value) pairs. +type Keys<'a> = &'a [(f64, [f64; 3])]; + +fn keyed(l: &mut Layer, path: &str, keys: Keys) { + l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).keys = + keys.iter().map(|(t, v)| effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(*t), Value::Vec3(*v))).collect(); +} + +/// A camera keyed through `pos` / `poi`, and the text keyed through `text_pos` (and turning about +/// Y through `text_rot`): every frame of a second, rendered with the clipped raster and a layer +/// cache shared across the frames, matches rasterising the whole layer without a cache (or differs +/// by an anti-aliasing sub-sample or two on a few edge pixels) — except where the whole raster is held at +/// its pixel cap (softer than the clipped one, which isn't): there the text shows and the clipped +/// raster stays small. +fn moving(label: &str, pos: Keys, poi: Keys, text_pos: Keys, text_rot: &[(f64, f64)]) { + let (mut p, cid, comp) = setup(); + let mut text = text3(&mut p, &comp, [W as f64 / 2.0, H as f64 / 2.0, 0.0], 0.0); + if !text_pos.is_empty() { + keyed(&mut text, "transform/position", text_pos); + } + if !text_rot.is_empty() { + text.props.prop_mut("transform/rotationY").unwrap().keys = + text_rot.iter().map(|(t, v)| effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(*t), Value::Scalar(*v))).collect(); + } + let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (W, H), None); + set(&mut cam, "cameraOptions/zoom", Value::Scalar(zoom())); + keyed(&mut cam, "transform/position", pos); + keyed(&mut cam, "transform/poi", poi); + p.comp_mut(cid).unwrap().layers = vec![cam, text]; + let cache = crate::LayerCache::default(); + let (mut exact, mut near, mut capped) = (0, 0, 0); + for f in 0..30 { + let t = Tick::from_seconds_f64(f as f64 / 30.0); + let mut clipped = Renderer::new(&p, &NoFootage, RenderOpts::default()); + clipped.cache = Some(&cache); + let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default()); + whole.whole_raster = true; + let ctx = whole.eval_ctx(cid, t).unwrap(); + let layer = &p.comp(cid).unwrap().layers[1]; + let a = clipped.comp_frame(cid, t); + if clipped.raster_scale(&ctx, layer) == whole.raster_scale(&ctx, layer) { + let b = whole.comp_frame(cid, t); + if a.data == b.data { + exact += 1; + } else { + // The clipped raster places the glyphs at other coordinates (smaller ones: the whole + // raster can be thousands of pixels wide), and tiny-skia transforms them in f32: its + // 4 × 4 anti-aliasing may land a sub-sample or two differently on a few edge pixels. + let (n, worst) = a.data.iter().zip(&b.data).fold((0usize, 0.0f32), |(n, w), (p, q)| { + let d = (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0f32, f32::max); + (n + usize::from(d > 0.0), w.max(d)) + }); + assert!(worst <= 2.0 / 16.0 + 1e-4 && n * 1000 <= (W * H) as usize, "{label}: frame {f}: {n} px differ, by up to {worst}"); + near += 1; + } + } else { + // Held at the cap whole: the clipped raster is sharper, and small. + let buf = clipped.layer_buf(&ctx, layer).unwrap(); + assert!(buf.img.width as u64 * buf.img.height as u64 <= 16_500_000, "{label}: frame {f}"); + let shows = |img: &effectcraft_raster::Image| img.data.iter().filter(|px| px[3] > 0.5).count(); + let (sa, sb) = (shows(&a), shows(&whole.comp_frame(cid, t))); + // The same text shows (or none, the camera past it); coverage within a few percent. + assert!(sa.abs_diff(sb) * 50 <= sb.max(1) + 50, "{label}: frame {f}: {sa} px of text, {sb} rasterised whole"); + capped += 1; + } + } + eprintln!("{label}: {exact} frames identical, {near} within anti-aliasing noise, {capped} sharper than the capped whole raster"); + assert!(exact + near >= 10, "{label}: {exact} identical, {near} close, {capped} at the cap"); +} + +#[test] +fn a_camera_moving_any_way_renders_every_frame_as_the_whole_layer() { + let (cx, cy, z) = (W as f64 / 2.0, H as f64 / 2.0, -zoom()); + let still = [(0.0, [cx, cy, 0.0])]; + // In towards the text, through it and past it; and back out. + moving("dolly in through the text", &[(0.0, [cx, cy, 2.0 * z]), (1.0, [cx, cy, 150.0])], &[(0.0, [cx, cy, 400.0]), (1.0, [cx, cy, 700.0])], &[], &[]); + moving("dolly out", &[(0.0, [cx, cy, -60.0]), (1.0, [cx, cy, 3.0 * z])], &still, &[], &[]); + // Sideways and up and down close to the text, so it leaves the frame on both sides. + moving( + "pan x", + &[(0.0, [cx - 500.0, cy, -120.0]), (1.0, [cx + 500.0, cy, -120.0])], + &[(0.0, [cx - 500.0, cy, 0.0]), (1.0, [cx + 500.0, cy, 0.0])], + &[], + &[], + ); + moving("pan y", &[(0.0, [cx, cy - 300.0, -90.0]), (1.0, [cx, cy + 300.0, -90.0])], &[(0.0, [cx, cy - 300.0, 0.0]), (1.0, [cx, cy + 300.0, 0.0])], &[], &[]); + // Around the text, close, looking at it. + let orbit: Vec<(f64, [f64; 3])> = (0..=8) + .map(|i| { + let a = i as f64 / 8.0 * std::f64::consts::TAU * 0.4 - 0.8; + (i as f64 / 8.0, [cx + 150.0 * a.sin(), cy, -150.0 * a.cos()]) + }) + .collect(); + moving("orbit", &orbit, &still, &[], &[]); + // The text moves instead: towards the camera, past it, and across. + moving( + "text towards and past the camera", + &[(0.0, [cx, cy, z])], + &still, + &[(0.0, [cx + 200.0, cy - 80.0, 400.0]), (1.0, [cx - 150.0, cy + 60.0, z - 100.0])], + &[], + ); + // Turning close to the camera. + moving("text turning near the camera", &[(0.0, [cx, cy, z])], &still, &[(0.0, [cx, cy, z + 70.0])], &[(0.0, -70.0), (1.0, 70.0)]); +} + +/// A layer whose pixels outside the frame can matter (an effect here) is rasterised whole. +#[test] +fn layers_with_effects_rasterise_whole() { + let (mut p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap(); + let mut next = p.next_id; + let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]); + p.next_id = next; + p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(g.into()); + assert_eq!(raster_px(&p, cid, false), raster_px(&p, cid, true)); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fbb0feb24..2aa93ecfa 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -943,8 +943,46 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt } } +/// Buffer pixels kept around a clip (see [`raster_window`]). +const CLIP_PAD: f64 = 24.0; + +/// The buffer for content bounds `b` (layer space) at scale `s`: (width, height, layer → buffer +/// offset), two pixels of padding around it. With `clip`, only the pixels of that grid inside +/// `clip`, padded by [`CLIP_PAD`]: a whole-pixel window of the whole buffer's grid. Its pixels are +/// the whole buffer's, except that a glyph cut by the window is rasterised from the window's edge: +/// tiny-skia's 4 × 4 anti-aliasing then lands a sub-sample differently on a few edge pixels, up to +/// a dozen rows in from that edge — inside the padding, out of the frame. `None` when `clip` +/// misses the content. +pub(crate) fn raster_window(b: kurbo::Rect, s: f64, clip: Option) -> Option<(u32, u32, [f64; 2])> { + let (full_w, full_h) = ((b.width() * s).ceil() + 4.0, (b.height() * s).ceil() + 4.0); + let full = [-b.x0 * s + 2.0, -b.y0 * s + 2.0]; + let (x0, y0, x1, y1) = match clip { + None => (0.0, 0.0, full_w, full_h), + // Nothing shows (behind the camera). + Some(c) if c.area() <= 0.0 => return None, + Some(c) => ( + (c.x0 * s + full[0] - CLIP_PAD).floor().max(0.0), + (c.y0 * s + full[1] - CLIP_PAD).floor().max(0.0), + (c.x1 * s + full[0] + CLIP_PAD).ceil().min(full_w), + (c.y1 * s + full[1] + CLIP_PAD).ceil().min(full_h), + ), + }; + if !(x1 > x0 && y1 > y0) { + return None; + } + let w = ((x1 - x0) as u32).clamp(1, 16384); + let h = ((y1 - y0) as u32).clamp(1, 16384); + Some((w, h, [full[0] - x0, full[1] - y0])) +} + /// Render a text layer into a layer-space buffer at scale `s`. pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf { + render_in(ctx, layer, s, None) +} + +/// [`render`], only the part of the layer inside `clip` (layer space; the part the output shows, +/// see `Renderer::raster_clip`). +pub fn render_in(ctx: &EvalCtx, layer: &Layer, s: f64, clip: Option) -> Buf { let empty = || Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s }; let Some(geom) = text_geom(ctx, layer) else { return empty() }; let mut bounds: Option = None; @@ -957,9 +995,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf { } let Some(b) = bounds else { return empty() }; let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0)); - let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384); - let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384); - let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0]; + let Some((w, h, offset)) = raster_window(b, s, clip) else { return empty() }; let mut img = Image::new(w, h); let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s)); draw_glyphs(&geom, &mut img, &base, s, None); diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 6cf87e70e..1f17e3314 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -185,6 +185,52 @@ pub(crate) fn view_size(r: &Renderer, ctx: &EvalCtx) -> (f64, f64) { (c.width as f64, c.height as f64) } +/// The layer-space rectangle of a 3D layer that this render's output can show: the output frame +/// (padded by two pixels) projected back onto the layer's plane, the way [`geo`] draws it. `None` +/// when that can't be bounded: an orthographic camera, depth of field (its blur reads past the +/// frame), motion blur (each sub-sample sees another part), or a frame corner whose ray misses the +/// plane in front of the camera (a plane seen towards its horizon). An empty rectangle when the +/// whole content is behind the camera. +pub(crate) fn visible_clip(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option { + let cam = camera_for(r, ctx); + if cam.ortho || cam.dof.is_some() || mb_samples(r, ctx, layer) > 1 { + return None; + } + let comp = view_size(r, ctx); + let s = r.opts.scale; + let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world); + let world = outer * ctx.world_matrix(layer); + let (off, w, h) = match r.roi_offset() { + Some((x, y, w, h)) => ((x, y), w as f64, h as f64), + None => ((0.0, 0.0), (comp.0 * s).round(), (comp.1 * s).round()), + }; + let proj = Mat4::translate(vec3(-off.0, -off.1, 0.0)) * Mat4::scale(vec3(s, s, 1.0)) * cam.projection(comp.0, comp.1); + let hinv = (proj * world).plane_to_mat3().inverse()?; + let cv = cam.view * world; + let depth = |u: f64, v: f64| cv.0[2][0] * u + cv.0[2][1] * v + cv.0[2][3]; + if let Some(b) = crate::content_bounds(ctx, layer) + && [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].iter().all(|&(u, v)| depth(u, v) < NEAR) + { + return Some(kurbo::Rect::ZERO); + } + const PAD: f64 = 2.0; + let mut rect: Option = None; + for (sx, sy) in [(-PAD, -PAD), (w + PAD, -PAD), (w + PAD, h + PAD), (-PAD, h + PAD)] { + let m = &hinv.0; + let qz = m[2][0] * sx + m[2][1] * sy + m[2][2]; + if qz <= 1e-300 { + return None; + } + let (u, v) = ((m[0][0] * sx + m[0][1] * sy + m[0][2]) / qz, (m[1][0] * sx + m[1][1] * sy + m[1][2]) / qz); + if !u.is_finite() || !v.is_finite() || depth(u, v) < NEAR { + return None; + } + let p = kurbo::Rect::new(u, v, u, v); + rect = Some(rect.map_or(p, |r| r.union(p))); + } + rect +} + /// Number of motion-blur sub-samples for a layer. fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { if r.opts.motion_blur && ctx.comp.enable_motion_blur && r.layer_motion_blur(layer) { From 3ac9a82378898f2fe05ec9fa454425f95d798b51 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 18:32:28 +0300 Subject: [PATCH 002/227] Layer-space reads of a 3D text or shape layer get the whole layer (#1388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: effects that take another layer as a parameter (Set Matte, Displacement Map, time effects…) read it through Renderer::nested in layer space, and the motion tracker reads layer_source; both could get only the part of the layer the frame shows. The nested renderer and layer_source now rasterise whole. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 6 +++++- crates/render/src/tests_raster_clip.rs | 28 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 47e301666..54948c8dd 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -766,6 +766,9 @@ impl<'a> Renderer<'a> { top: false, through: [None; MAX_THROUGH], through_lost: false, + // Effects read other layers through it in layer space (layer parameters, Echo…): + // they need the whole layer, not the part this frame shows (see `raster_clip`). + whole_raster: true, ..*self } } @@ -1573,7 +1576,8 @@ impl<'a> Renderer<'a> { /// (what the motion tracker analyses, like After Effects' Layer panel). `None` for layers /// without pixels. pub fn layer_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { - self.source(ctx, layer) + // The whole layer, not the part the frame shows (see `raster_clip`). + Renderer { whole_raster: true, ..*self }.source(ctx, layer) } /// Layer pixels after source → masks → effects (no layer styles). diff --git a/crates/render/src/tests_raster_clip.rs b/crates/render/src/tests_raster_clip.rs index 74da0e616..da0b40884 100644 --- a/crates/render/src/tests_raster_clip.rs +++ b/crates/render/src/tests_raster_clip.rs @@ -268,6 +268,34 @@ fn a_camera_moving_any_way_renders_every_frame_as_the_whole_layer() { moving("text turning near the camera", &[(0.0, [cx, cy, z])], &still, &[(0.0, [cx, cy, z + 70.0])], &[(0.0, -70.0), (1.0, 70.0)]); } +/// Reads of a layer in its own space get the whole layer, not the part the frame shows: an effect +/// taking another layer as a parameter (Set Matte, stretched to fit, from hidden text close to the +/// camera), and the source the motion tracker analyses. +#[test] +fn layer_space_reads_get_the_whole_layer() { + let (mut p, cid, comp) = setup(); + let mut text = text3(&mut p, &comp, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0); + text.switches.video = false; + let text_id = text.id; + let sid = + p.add_item("Solid", Label::Red, None, ItemKind::Solid(effectcraft_project::Solid { color: [0.2, 0.6, 1.0], width: W, height: H, pixel_aspect: 1.0 })); + let mut solid = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (W, H), None); + let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]); + p.next_id = next; + g.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(text_id.0)); + solid.props.sub_mut("effects").unwrap().children.push(g.into()); + p.comp_mut(cid).unwrap().layers = vec![text, solid]; + assert_eq!(frame(&p, cid, false).data, frame(&p, cid, true).data, "Set Matte from the text"); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + let layer = &p.comp(cid).unwrap().layers[0]; + let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default()); + whole.whole_raster = true; + assert_eq!(r.layer_source(&ctx, layer).unwrap().img, whole.layer_source(&ctx, layer).unwrap().img, "the tracker's source"); +} + /// A layer whose pixels outside the frame can matter (an effect here) is rasterised whole. #[test] fn layers_with_effects_rasterise_whole() { From d383a1e8bb67aed08b3e4da1c0df0f6e994affe5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:03:41 +0200 Subject: [PATCH 003/227] Make the audio preview follow mute changes made during playback (#2104) --- crates/ui-egui/src/audio.rs | 160 +++++++++++++++++++++++++++++++----- crates/ui-egui/src/lib.rs | 4 + 2 files changed, 142 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 7972e6942..1ddff0c5b 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -23,7 +23,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; -use effectcraft_engine::project::ItemId; +use effectcraft_engine::project::{ItemId, Project}; use effectcraft_engine::time::{FrameRate, Tick}; use crate::frames::RenderSource; @@ -179,12 +179,24 @@ pub struct AudioPlayback { device: Box, stop: Arc, thread: Option>, - /// wasm32 (no threads): what the feeder thread would own, run by [`AudioPlayback::pump`]. - feeder: Option<(RenderSource, ItemId, i64, u32)>, + /// What the mixer reads and advances: the feeder thread, or [`AudioPlayback::pump`] on wasm32 + /// (no threads). + state: Arc>, + comp: ItemId, + mix: u32, pub rate: u32, pub span: PlaySpan, } +/// The mixer's position and its view of the project. +struct Feeder { + src: RenderSource, + /// The next comp sample index to mix. + cursor: i64, + /// Bumped when the project changes: a block mixed from an older project is dropped. + generation: u64, +} + /// Audio kept queued ahead of the device: ~200 ms (a feeder thread tops it up continuously); /// wasm32 tops it up once per UI frame, so it keeps ~350 ms to ride out slow frames. fn queue_target(rate: u32) -> usize { @@ -211,9 +223,10 @@ impl AudioPlayback { let span = PlaySpan::new(start, wa, wb, looping, rate); let feed = Arc::new(AudioFeed::default()); let stop = Arc::new(AtomicBool::new(false)); + let state = Arc::new(Mutex::new(Feeder { src, cursor: span.start, generation: 0 })); if cfg!(target_arch = "wasm32") { // The device takes samples only in `pump`, which fills the queue first. - let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, feeder: Some((src, comp, span.start, mix)), rate, span }; + let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, state, comp, mix, rate, span }; p.device.start(feed)?; p.pump(); return Ok(p); @@ -223,15 +236,14 @@ impl AudioPlayback { // as that took (#408). The device plays silence until the first block arrives, and the // clock doesn't count it. let thread = { - let (feed, stop) = (feed.clone(), stop.clone()); + let (feed, stop, state) = (feed.clone(), stop.clone(), state.clone()); std::thread::Builder::new() .name("ec-audio-feed".into()) .spawn(move || { - let mut cursor = span.start; let target = queue_target(rate); while !stop.load(Ordering::Relaxed) { if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) { - cursor = feed_block(&src, comp, &feed, &span, cursor, rate, mix); + feed_block(&state, comp, &feed, &span, rate, mix); } else { std::thread::sleep(std::time::Duration::from_millis(4)); } @@ -240,21 +252,39 @@ impl AudioPlayback { .map_err(|e| e.to_string())? }; device.start(feed.clone())?; - Ok(AudioPlayback { feed, device, stop, thread: Some(thread), feeder: None, rate, span }) + Ok(AudioPlayback { feed, device, stop, thread: Some(thread), state, comp, mix, rate, span }) } /// Every UI frame while playing: on wasm32 mix ahead into the feed (what the feeder thread /// does elsewhere), then let the device take what it needs. pub fn pump(&mut self) { - if let Some((src, comp, cursor, mix)) = &mut self.feeder { + if cfg!(target_arch = "wasm32") { let target = queue_target(self.rate); while self.feed.queued_frames() < target && !self.feed.finished.load(Ordering::Relaxed) { - *cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate, *mix); + feed_block(&self.state, self.comp, &self.feed, &self.span, self.rate, self.mix); } } self.device.pump(); } + /// The project the sound is mixed from. + pub fn project(&self) -> Option> { + self.state.lock().ok().map(|s| s.src.project.clone()) + } + + /// Mix from `project` from now on (the project was edited while playing, e.g. a layer's Audio + /// switch, #2104): what is queued came from the old project, so it is dropped and mixing + /// resumes at the first sample the device has not taken yet (also when the feeder had + /// already mixed up to the end of a non-looping span). + pub fn set_project(&mut self, project: Arc) { + let Ok(mut s) = self.state.lock() else { return }; + s.src.project = project; + s.generation += 1; + self.feed.clear(); + self.feed.finished.store(false, Ordering::Relaxed); + s.cursor = clock_sample(&self.span, self.feed.consumed(), 0); + } + /// The comp sample index audible now. pub fn clock(&self) -> i64 { clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames()) @@ -279,20 +309,33 @@ impl Drop for AudioPlayback { } } -/// Mix one block at `cursor` into the feed; returns the next cursor (wrapping or finishing at -/// the work-area end). -fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, cursor: i64, rate: u32, mix: u32) -> i64 { - let mut cursor = cursor; - if cursor >= span.wb { - if !span.looping { - feed.finished.store(true, Ordering::Relaxed); - return cursor; - } +/// Mix one block at the feeder's cursor into the feed and move the cursor on (wrapping or +/// finishing at the work-area end). The block is mixed without holding the lock, so a project +/// change never waits for a slow mix; a block mixed from the old project is dropped. +fn feed_block(state: &Mutex, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, rate: u32, mix: u32) { + let Ok((src, mut cursor, generation)) = state.lock().map(|s| (s.src.clone(), s.cursor, s.generation)) else { + // Poisoned: nothing more can be mixed. + feed.finished.store(true, Ordering::Relaxed); + return; + }; + let mut block = None; + if cursor >= span.wb && span.looping { cursor = span.wa; } - let n = ((span.wb - cursor) as usize).min(BLOCK); - feed.push(&mix_block(src, comp, sample_time(cursor, rate), n, rate, mix)); - cursor + n as i64 + if cursor < span.wb { + let n = ((span.wb - cursor) as usize).min(BLOCK); + block = Some(mix_block(&src, comp, sample_time(cursor, rate), n, rate, mix)); + cursor += n as i64; + } + let Ok(mut s) = state.lock() else { return }; + if s.generation != generation { + return; + } + match block { + Some(b) => feed.push(&b), + None => feed.finished.store(true, Ordering::Relaxed), + } + s.cursor = cursor; } /// `n` stereo frames of `comp`'s mix from comp time `t` at the device `rate`, mixed at `mix` Hz @@ -559,6 +602,79 @@ mod tests { playback.stop(); } + /// #2104: muting a layer while the preview plays silences it: the queued sound is dropped + /// and everything mixed afterwards comes from the edited project. + #[test] + fn muting_a_layer_while_playing_silences_the_preview() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::{Comp, Footage, FootageKind, ItemKind, LayerSource, build}; + use effectcraft_engine::render::FootageSource; + use effectcraft_engine::render::cache::LayerCache; + use std::time::{Duration, Instant}; + + /// Footage audio that is a constant 0.5. + struct Steady; + impl FootageSource for Steady { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, _: u32) -> Option> { + Some(vec![0.5; frames * 2]) + } + } + struct Device; + impl AudioDevice for Device { + fn sample_rate(&self) -> u32 { + 48000 + } + fn start(&mut self, _: Arc) -> Result<(), String> { + Ok(()) + } + fn stop(&mut self) {} + } + + let mut p = Project::default(); + let footage = + Footage { path: "tone.flac".into(), kind: FootageKind::Audio, duration: Tick::from_seconds_f64(60.0), has_audio: true, ..Default::default() }; + let item = p.add_item("tone.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, "tone.flac", LayerSource::Footage { item }, (0, 0), None); + comp.layers = vec![layer]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let mut project = Arc::new(p); + let src = RenderSource { + project: project.clone(), + footage: Arc::new(Steady), + expr: None, + layer_cache: Arc::new(LayerCache::default()), + gpu: None, + gpu_display: false, + disk: None, + }; + let mut playback = + AudioPlayback::start(Box::new(Device), src, cid, Tick::ZERO, Tick::ZERO, Tick::from_seconds_f64(10.0), true, 0).expect("the preview starts"); + let t = Instant::now(); + while playback.feed.queued_frames() == 0 { + assert!(t.elapsed() < Duration::from_secs(30), "the feeder never delivered"); + std::thread::sleep(Duration::from_millis(10)); + } + // Mute the layer while the sound plays. + if let Some(c) = Arc::make_mut(&mut project).comp_mut(cid) { + c.layers[0].switches.audio = false; + } + playback.set_project(project.clone()); + assert!(playback.project().is_some_and(|p| Arc::ptr_eq(&p, &project))); + // The feeder tops the queue up again, now from the muted project. + while playback.feed.queued_frames() == 0 { + assert!(t.elapsed() < Duration::from_secs(30), "the feeder never delivered"); + std::thread::sleep(Duration::from_millis(10)); + } + let mut out = vec![1.0f32; playback.feed.queued_frames() * 2]; + playback.feed.pull(&mut out); + assert!(out.iter().all(|v| *v == 0.0), "the muted layer still plays"); + playback.stop(); + } + #[test] fn meter_decays_and_holds_peaks() { let mut m = Meter::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 72016cc53..e76788ef2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1263,6 +1263,10 @@ impl EffectcraftApp { } } if let Some(a) = &mut self.audio { + // An edit while playing (mute, unmute, solo, levels): the sound follows it (#2104). + if a.project().is_some_and(|p| !Arc::ptr_eq(&p, &self.session.project)) { + a.set_project(self.session.project.clone()); + } a.pump(); self.meter.update(a.feed.take_peaks(), now); } else if let Some(s) = &mut self.scrub { From 2301fd6e84675996159a297172399b61eda003fe Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:09:16 +0200 Subject: [PATCH 004/227] Keep the audio queue when an edit during playback does not change the sound (#2104) --- crates/ui-egui/src/audio.rs | 90 ++++++++++++++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 2 +- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 1ddff0c5b..029c7ca6b 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -23,7 +23,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; -use effectcraft_engine::project::{ItemId, Project}; +use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, Project}; use effectcraft_engine::time::{FrameRate, Tick}; use crate::frames::RenderSource; @@ -285,6 +285,18 @@ impl AudioPlayback { s.cursor = clock_sample(&self.span, self.feed.consumed(), 0); } + /// Follow an edited `project`: mixing continues from it, and the queued sound is dropped only + /// when the edit changed what is heard ([`audio_changed`]); a transform or colour edit leaves + /// the queue alone, so playback does not drop out. + pub fn follow_project(&mut self, project: Arc) { + let Some(old) = self.project() else { return }; + if audio_changed(&old, &project, self.comp) { + self.set_project(project); + } else if let Ok(mut s) = self.state.lock() { + s.src.project = project; + } + } + /// The comp sample index audible now. pub fn clock(&self) -> i64 { clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames()) @@ -309,6 +321,46 @@ impl Drop for AudioPlayback { } } +/// Whether `new` sounds different from `old` for `comp`: a layer's timing, source, Audio / Solo / +/// Effects switch, Audio Levels or effects, or the same for a nested comp. Other edits (video +/// properties, names, labels) do not change the mix. +fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { + fn comps_differ(old: &Project, new: &Project, comp: ItemId, depth: usize) -> bool { + let (Some(a), Some(b)) = (old.comp(comp), new.comp(comp)) else { return old.comp(comp).is_some() != new.comp(comp).is_some() }; + if depth >= 16 { + return false; + } + a.layers.len() != b.layers.len() + || a.layers.iter().zip(&b.layers).any(|(x, y)| { + x.id != y.id + || x.source != y.source + || x.start_time != y.start_time + || x.in_point != y.in_point + || x.out_point != y.out_point + || x.stretch != y.stretch + || x.switches.audio != y.switches.audio + || x.switches.solo != y.switches.solo + || x.switches.effects != y.switches.effects + || x.props.sub("audio") != y.props.sub("audio") + || x.effects() != y.effects() + || match &x.source { + LayerSource::Footage { item } => footage_differs(old, new, *item), + LayerSource::Comp { item } => comps_differ(old, new, *item, depth + 1), + _ => false, + } + }) + } + /// The footage item's audio availability changed (relinked, missing, no audio). + fn footage_differs(old: &Project, new: &Project, item: ItemId) -> bool { + let f = |p: &Project| match p.item(item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => Some((f.has_audio, f.missing, f.path.clone())), + _ => None, + }; + f(old) != f(new) + } + comps_differ(old, new, comp, 0) +} + /// Mix one block at the feeder's cursor into the feed and move the cursor on (wrapping or /// finishing at the work-area end). The block is mixed without holding the lock, so a project /// change never waits for a slow mix; a block mixed from the old project is dropped. @@ -675,6 +727,42 @@ mod tests { playback.stop(); } + /// #2104: only edits that change the mix drop the queued sound. + #[test] + fn only_audible_edits_change_the_audio() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::{Comp, Footage, FootageKind, build}; + + let mut p = Project::default(); + let footage = + Footage { path: "tone.flac".into(), kind: FootageKind::Audio, duration: Tick::from_seconds_f64(60.0), has_audio: true, ..Default::default() }; + let item = p.add_item("tone.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, "tone.flac", LayerSource::Footage { item }, (0, 0), None); + comp.layers = vec![layer]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let old = p; + // A video-only edit: the layer is hidden, its name and label change. + let mut video = old.clone(); + if let Some(l) = video.comp_mut(cid).and_then(|c| c.layers.first_mut()) { + l.switches.video = false; + l.name = "renamed".into(); + } + assert!(!audio_changed(&old, &video, cid), "a video edit must not drop the sound"); + // Muting, solo and a moved in point do change it. + for edit in 0..3 { + let mut edited = old.clone(); + if let Some(l) = edited.comp_mut(cid).and_then(|c| c.layers.first_mut()) { + match edit { + 0 => l.switches.audio = false, + 1 => l.switches.solo = true, + _ => l.in_point = Tick::from_seconds_f64(1.0), + } + } + assert!(audio_changed(&old, &edited, cid), "audible edit {edit} must be followed"); + } + } + #[test] fn meter_decays_and_holds_peaks() { let mut m = Meter::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e76788ef2..c3a17fa30 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1265,7 +1265,7 @@ impl EffectcraftApp { if let Some(a) = &mut self.audio { // An edit while playing (mute, unmute, solo, levels): the sound follows it (#2104). if a.project().is_some_and(|p| !Arc::ptr_eq(&p, &self.session.project)) { - a.set_project(self.session.project.clone()); + a.follow_project(self.session.project.clone()); } a.pump(); self.meter.update(a.feed.take_peaks(), now); From 55040de3d139f97a516c107cdb82ace687619ea2 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:42:06 +0100 Subject: [PATCH 005/227] Build the #2104 audio tests' layer lists with into(), as Comp::layers is now a Layers (#2104) --- crates/ui-egui/src/audio.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index eb7828a18..f8b52fc88 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -691,7 +691,7 @@ mod tests { let item = p.add_item("tone.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, "tone.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 mut project = Arc::new(p); let src = RenderSource { @@ -739,7 +739,7 @@ mod tests { let item = p.add_item("tone.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, "tone.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 old = p; // A video-only edit: the layer is hidden, its name and label change. From faf1d99e4a0650b5c660daa908a657743e2e4fac Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Mon, 2 Nov 2026 21:55:19 +0530 Subject: [PATCH 006/227] Fix #2869: disclose selected effects with arrow keys --- crates/ui-egui/src/menus/shortcuts.rs | 4 + crates/ui-egui/src/panels/effect_controls.rs | 1 + .../src/panels/effect_controls/panel.rs | 22 ++++ crates/ui-egui/tests/it/ui_effect_controls.rs | 102 ++++++++++++++++++ 4 files changed, 129 insertions(+) diff --git a/crates/ui-egui/src/menus/shortcuts.rs b/crates/ui-egui/src/menus/shortcuts.rs index 4ef1e64fb..65542e56f 100644 --- a/crates/ui-egui/src/menus/shortcuts.rs +++ b/crates/ui-egui/src/menus/shortcuts.rs @@ -403,6 +403,10 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only if app.dialog.is_some() { continue; } + if !mods.any() && crate::panels::effect_controls::handle_disclosure_key(app, key) { + ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, key)); + continue; + } // Enter in the Project panel renames the selected item (handled by the panel). // In Effects & Presets and the Render Queue Enter applies or opens the row (handled by the // panel), not the layer rename. diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index d9706f147..6e71c6850 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -36,6 +36,7 @@ mod viewer; pub(crate) use common::gesture_key; pub use common::{choose_files_after_apply, reveal_applied}; +pub(crate) use panel::handle_disclosure_key; pub use panel::{show, start_rename}; pub(crate) use rows::toggle_pick; pub use viewer::viewer_hook; diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 122a6abf7..0d81029e6 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -3,6 +3,28 @@ use super::editors::{curves_editor, editor_height, histogram_only, levels_editor use super::rows::{FxScope, group_rows, toggle_closed}; use super::{Align2, Color32, EffectcraftApp, EvalCtx, GroupKind, Icon, Layer, PropGroup, Rect, Sense, Stroke, Tokens, json, pos2, vec2, widgets}; +/// Plain Left/Right disclose selected effect headers before global layer nudges run. +pub(crate) fn handle_disclosure_key(app: &mut EffectcraftApp, key: egui::Key) -> bool { + if app.ui.focused != crate::dock::PanelKind::EffectControls || !matches!(key, egui::Key::ArrowLeft | egui::Key::ArrowRight) { + return false; + } + let Some(comp) = app.session.active_comp() else { return false }; + let Some(layer) = app.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) else { return false }; + let Some(effects) = layer.effects() else { return false }; + let mut handled = false; + for g in effects.groups() { + if matches!(&g.kind, GroupKind::Effect { .. }) && app.session.state.selected_props.contains(&(layer.id, g.uid)) { + if key == egui::Key::ArrowLeft { + app.ui.fx_closed.insert(g.uid); + } else { + app.ui.fx_closed.remove(&g.uid); + } + handled = true; + } + } + handled +} + // --------------------------------------------------------------------------------------------- /// Open the rename field on effect `uid` (Enter with the effect selected), name selected. diff --git a/crates/ui-egui/tests/it/ui_effect_controls.rs b/crates/ui-egui/tests/it/ui_effect_controls.rs index d6138ba5c..3de502a5b 100644 --- a/crates/ui-egui/tests/it/ui_effect_controls.rs +++ b/crates/ui-egui/tests/it/ui_effect_controls.rs @@ -90,6 +90,108 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) { h.step(); } +fn header_arrow_harness() -> (Harness<'static, EffectcraftApp>, LayerId, [(u64, u64); 3]) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Disclosure", "width": 32, "height": 18, "frameRate": 30, "duration": 2})).unwrap(); + let lid = s.execute("layer.newSolid", json!({"name": "Controls", "color": "#40b080"})).unwrap()["layer"].as_u64().unwrap(); + let mut effects = [(0, 0); 3]; + for pair in &mut effects { + let fx = s.execute("effect.apply", json!({"layers": [lid], "effect": "Point Control"})).unwrap()["effects"][0].as_u64().unwrap(); + let prop = layer(&s, lid).props.find_group(fx).unwrap().get("point").unwrap().uid; + *pair = (fx, prop); + } + s.state.selected_props.clear(); + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.toggle_maximize(PanelKind::EffectControls); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app); + h.run_steps(4); + (h, LayerId(lid), effects) +} + +fn header_arrow_key(h: &mut Harness<'_, EffectcraftApp>, key: egui::Key, modifiers: egui::Modifiers) { + h.key_press_modifiers(modifiers, key); + h.run_steps(2); +} + +#[test] +fn effect_header_arrows_collapse_and_expand_without_editing_the_document() { + let (mut h, lid, [(fx, prop), (_, other), _]) = header_arrow_harness(); + let at = rect_of(&h, &format!("effectControls.effect.{fx}.name")).center(); + click(&mut h, at); + assert_eq!(h.state().ui.focused, PanelKind::EffectControls); + assert_eq!(h.state().session.state.selected_props, vec![(lid, fx)]); + let project = serde_json::to_value(&h.state().session.project).unwrap(); + let time = h.state().session.time(); + let undo = h.state().session.history.undo.len(); + let redo = h.state().session.history.redo.len(); + for key in [egui::Key::ArrowLeft, egui::Key::ArrowLeft, egui::Key::ArrowRight, egui::Key::ArrowRight] { + header_arrow_key(&mut h, key, egui::Modifiers::NONE); + assert_eq!(h.state().auto.find(&format!("effectControls.row.{prop}")).is_some(), key == egui::Key::ArrowRight); + assert!(h.state().auto.find(&format!("effectControls.row.{other}")).is_some(), "unselected effect stays open"); + assert_eq!(h.state().session.state.selected_props, vec![(lid, fx)]); + assert_eq!(serde_json::to_value(&h.state().session.project).unwrap(), project); + assert_eq!(h.state().session.time(), time); + assert_eq!((h.state().session.history.undo.len(), h.state().session.history.redo.len()), (undo, redo)); + } +} + +#[test] +fn effect_header_arrows_only_disclose_selected_headers_on_the_displayed_layer() { + let (mut h, lid, [(first, _), (second, _), (third, prop)]) = header_arrow_harness(); + h.state_mut().session.state.selected_props = vec![(lid, first), (lid, second), (LayerId(0), third), (lid, u64::MAX)]; + header_arrow_key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE); + assert_eq!(h.state().ui.fx_closed.iter().copied().collect::>(), vec![first, second]); + assert!(h.state().auto.find(&format!("effectControls.row.{prop}")).is_some()); + header_arrow_key(&mut h, egui::Key::ArrowRight, egui::Modifiers::NONE); + assert!(h.state().ui.fx_closed.is_empty()); + + // A parameter selection, stale header, or header on another layer must still fall through. + h.state_mut().session.state.selected_props = vec![(lid, prop), (LayerId(0), first), (lid, u64::MAX)]; + let before = serde_json::to_value(&h.state().session.project).unwrap(); + header_arrow_key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE); + assert!(h.state().ui.fx_closed.is_empty()); + assert_ne!(serde_json::to_value(&h.state().session.project).unwrap(), before, "the existing layer nudge still runs"); +} + +#[test] +fn effect_header_arrows_preserve_modified_shortcuts_and_keyboard_ownership() { + let (mut h, lid, [(fx, _), _, _]) = header_arrow_harness(); + h.state_mut().session.state.selected_props = vec![(lid, fx)]; + let time = h.state().session.time(); + header_arrow_key(&mut h, egui::Key::ArrowRight, egui::Modifiers::COMMAND); + assert!(h.state().session.time() > time, "Cmd/Ctrl+Right still advances a frame"); + header_arrow_key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::COMMAND); + assert_eq!(h.state().session.time(), time); + header_arrow_key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::SHIFT); + assert!(h.state().ui.fx_closed.is_empty(), "Shift+Left does not collapse effects"); + h.state_mut().ui.focused = PanelKind::Composition; + header_arrow_key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE); + assert!(h.state().ui.fx_closed.is_empty(), "another panel owns the arrows"); + h.state_mut().ui.focused = PanelKind::EffectControls; + h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::About); + header_arrow_key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE); + assert!(h.state().ui.fx_closed.is_empty(), "a dialog owns the arrows"); + h.state_mut().dialog = None; + h.run_steps(3); + let at = rect_of(&h, &format!("effectControls.effect.{fx}.name")).center(); + h.event(egui::Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + assert!(egui::Popup::is_any_open(&h.ctx), "the effect context menu is open"); + header_arrow_key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE); + assert!(h.state().ui.fx_closed.is_empty(), "the context menu owns the arrows"); + header_arrow_key(&mut h, egui::Key::Escape, egui::Modifiers::NONE); + double_click(&mut h, at); + assert!(h.ctx.egui_wants_keyboard_input(), "the rename field owns the keyboard"); + header_arrow_key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE); + assert!(h.state().ui.fx_closed.is_empty(), "Left moves the text caret instead of collapsing"); + assert!(h.state().auto.find(&format!("effectControls.effect.{fx}.nameEdit")).is_some()); +} + #[test] fn effect_controls_widgets_register_automation_ids() { let (app, x) = app(); From c5bf611909d5647cb1d45218310a6b860b1d1873 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Mon, 2 Nov 2026 22:10:35 +0530 Subject: [PATCH 007/227] #2817: allow reopening manually drawn paths --- crates/engine/src/cmd.rs | 2 +- crates/engine/src/cmd/mask.rs | 2 +- crates/engine/src/cmd/paths.rs | 4 + crates/engine/src/cmd/paths/closed.rs | 125 ++++++++++++++++++ crates/engine/src/menus.rs | 3 +- crates/engine/src/menus/tests.rs | 38 ++++++ crates/engine/src/tests/viewer.rs | 179 ++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 30 +++-- 8 files changed, 367 insertions(+), 16 deletions(-) create mode 100644 crates/engine/src/cmd/paths/closed.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 1168c431b..acd991fb2 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -47,7 +47,7 @@ mod nav_more; pub mod paint; pub mod panels_cmds; pub(crate) mod path_nulls; -mod paths; +pub(crate) mod paths; mod project_items; pub(crate) mod prop; mod prop_groups; diff --git a/crates/engine/src/cmd/mask.rs b/crates/engine/src/cmd/mask.rs index 778dfb54c..d7a1c1dbe 100644 --- a/crates/engine/src/cmd/mask.rs +++ b/crates/engine/src/cmd/mask.rs @@ -644,7 +644,7 @@ pub fn specs() -> Vec { cmd!("mask.new", "New Mask from Points", [], None, "{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, mode?}", has_layers, new_mask), cmd!("mask.addVertex", "Add Mask Vertex", [], None, "{layer?, mask, point: [x,y], in?, out?, index?}", has_layers, add_vertex), cmd!("mask.setVertex", "Set Mask Vertex", [], None, "{layer?, mask, index, point?, in?, out?, merge?}", has_layers, set_vertex), - cmd!("mask.setClosed", "Closed", ["Layer", "Mask and Shape Path"], None, "{layer?, mask, closed?}", has_layers, set_closed), + cmd!("mask.setClosed", "Closed", [], None, "{layer?, mask, closed?}", has_layers, set_closed), cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices), cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices), cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices), diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 8364f67ec..2f1e1051b 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -12,6 +12,9 @@ use super::layer_menu::target_masks; use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, merge_p, str_p}; use crate::{EngineError, Result, Session, VertexRef, cmd}; +mod closed; +pub(crate) use closed::selected_paths_closed; + // ---------------------------------------------------------------- RotoBezier /// Automatic (RotoBezier) tangents: each vertex's tangents follow the line through its @@ -547,6 +550,7 @@ fn ungroup_shapes(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + cmd!("path.toggleClosed", "Closed", ["Layer", "Mask and Shape Path"], None, "{}", closed::has_paths, closed::toggle_closed), cmd!("path.rotoBezier", "RotoBezier", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?, value?}", has_layers, roto_bezier), cmd!("path.convertToBezier", "Convert To Bezier Path", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?}", has_layers, convert_to_bezier), cmd!("path.groupShapes", "Group Shapes", ["Layer"], Some("Cmd+G"), "{layer?, items?: [uid]}", has_shape_items, group_shapes), diff --git a/crates/engine/src/cmd/paths/closed.rs b/crates/engine/src/cmd/paths/closed.rs new file mode 100644 index 000000000..d3cd8c1ef --- /dev/null +++ b/crates/engine/src/cmd/paths/closed.rs @@ -0,0 +1,125 @@ +//! The selection-aware Closed menu action. Explicit edits retain `mask.setClosed`'s API. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{GroupKind, Layer, LayerId, PropGroup, Uid}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::super::{bad, layer_mut}; +use crate::{EngineError, Result, Session, VertexRef}; + +const MAX_DEPTH: usize = 128; + +struct Target { + layer: LayerId, + route: Vec, + closed: bool, +} + +struct Selection { + vertices: Vec, + props: Vec<(LayerId, Uid)>, + time: Tick, +} + +/// A route uses checked child access and never recurses through an input-controlled tree. +fn group_at_mut<'a>(mut group: &'a mut PropGroup, route: &[usize]) -> Option<&'a mut PropGroup> { + for &index in route { + group = group.children.get_mut(index)?.as_group_mut()?; + } + Some(group) +} + +fn targets(s: &Session) -> Result> { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + // Layer navigation and insertion can retain point/property selections on other layers. + let selection = Selection { + vertices: s.state.selected_vertices.iter().filter(|v| s.state.selected_layers.contains(&v.layer)).copied().collect(), + props: s.state.selected_props.iter().filter(|(lid, _)| s.state.selected_layers.contains(lid)).copied().collect(), + time: s.time(), + }; + let mut targets = Vec::new(); + for layer in comp.layers.iter() { + let selected_layer = if !selection.vertices.is_empty() { + selection.vertices.iter().any(|v| v.layer == layer.id) + } else if !selection.props.is_empty() { + selection.props.iter().any(|(lid, _)| *lid == layer.id) + } else { + s.state.selected_layers.contains(&layer.id) + }; + if !selected_layer || layer.switches.locked { + continue; + } + collect(&selection, layer, &layer.props, &mut Vec::new(), selection.props.is_empty(), &mut targets)?; + } + if targets.is_empty() { + return Err(bad("path.toggleClosed", "select an unlocked mask or Bezier shape path")); + } + Ok(targets) +} + +fn collect(selection: &Selection, layer: &Layer, group: &PropGroup, route: &mut Vec, inherited: bool, targets: &mut Vec) -> Result<()> { + if route.len() > MAX_DEPTH { + return Err(bad("path.toggleClosed", "path group nesting exceeds 128 levels")); + } + if matches!(group.kind, GroupKind::Mask { locked: true, .. }) { + return Ok(()); + } + let selected = inherited || selection.props.contains(&(layer.id, group.uid)); + if super::super::mask::is_path_group(group) + && let Some(prop) = group.get("path") + && let KV::Path(path) = prop.value_at(layer.layer_time(selection.time)) + { + let selected = if selection.vertices.is_empty() { + selected || selection.props.contains(&(layer.id, prop.uid)) + } else { + selection.vertices.iter().any(|v| v.layer == layer.id && v.mask == group.uid && v.index < path.vertices.len()) + }; + if selected { + targets.push(Target { layer: layer.id, route: route.clone(), closed: path.closed }); + } + return Ok(()); + } + for (index, child) in group.children.iter().enumerate() { + let Some(child) = child.as_group() else { continue }; + route.push(index); + collect(selection, layer, child, route, selected, targets)?; + route.pop(); + } + Ok(()) +} + +pub(crate) fn selected_paths_closed(s: &Session) -> Option { + targets(s).ok().map(|targets| targets.iter().all(|target| target.closed)) +} + +pub(super) fn has_paths(s: &Session) -> std::result::Result<(), String> { + targets(s).map(|_| ()).map_err(|error| error.to_string()) +} + +pub(super) fn toggle_closed(s: &mut Session, _: &Value) -> Result { + let targets = targets(s)?; + let closed = !targets.iter().all(|target| target.closed); + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let time = s.time(); + s.edit("Closed", None, |project, _| { + for target in &targets { + let layer = layer_mut(project, cid, target.layer)?; + let time = layer.layer_time(time); + let group = group_at_mut(&mut layer.props, &target.route).ok_or_else(|| bad("path.toggleClosed", "the selected path no longer exists"))?; + let roto = super::is_roto(group); + let prop = group.get_mut("path").ok_or_else(|| bad("path.toggleClosed", "the selected group has no path"))?; + let KV::Path(mut path) = prop.value_at(time) else { return Err(bad("path.toggleClosed", "the selected value is not a path")) }; + if path.closed == closed { + continue; + } + path.closed = closed; + if roto { + super::roto_smooth(&mut path); + } + prop.set_value_at(time, KV::Path(path)); + } + Ok(()) + })?; + Ok(json!({"paths": targets.len(), "closed": closed})) +} diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index a7418ae58..813097828 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -314,6 +314,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { let layer = s.state.selected_layers.first().and_then(|l| comp.layer(*l)); let pstr = |k: &str| params.get(k).and_then(Value::as_str); match command { + "path.toggleClosed" => crate::cmd::paths::selected_paths_closed(s), "layer.setBlendMode" => { let want = effectcraft_color::BlendMode::from_name(pstr("mode")?)?; Some(layer?.blend_mode == want) @@ -820,7 +821,7 @@ Layer Hide Locked Masks | layer.mask.hideLocked Mask and Shape Path RotoBezier | path.rotoBezier - Closed | mask.setClosed + Closed | path.toggleClosed Convert To Bezier Path | path.convertToBezier Set First Vertex | path.setFirstVertex Free Transform Points | path.freeTransform diff --git a/crates/engine/src/menus/tests.rs b/crates/engine/src/menus/tests.rs index 913a0e92e..02c825bf9 100644 --- a/crates/engine/src/menus/tests.rs +++ b/crates/engine/src/menus/tests.rs @@ -140,6 +140,44 @@ fn checked_reflects_layer_state() { assert_eq!(checked(&s, "file.save", &serde_json::json!({})), None); } +#[test] +fn closed_menu_opens_and_recloses_a_manual_shape() { + use serde_json::json; + let mut s = crate::Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let r = s + .execute( + "shape.newPath", + json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true, + "inTangents": [[0, -12], [-8, 0], [0, -6]], "outTangents": [[12, 0], [0, 8], [-6, 0]]}), + ) + .unwrap(); + let lid = effectcraft_project::LayerId(r["layer"].as_u64().unwrap()); + let uid = r["path"].as_u64().unwrap(); + let path = + |s: &crate::Session| s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); + let original = path(&s); + for mac in [true, false] { + let (_, entry) = + platform_entries(mac).into_iter().find(|(p, e)| p.iter().map(String::as_str).eq(["Layer", "Mask and Shape Path"]) && e.label == "Closed").unwrap(); + assert!(s.is_enabled(&entry.command)); + assert_eq!(checked(&s, &entry.command, &entry.params), Some(true)); + s.execute(&entry.command, entry.params.clone()).unwrap(); + let mut opened = original.clone(); + opened.closed = false; + assert_eq!(path(&s), opened); + assert!(!effectcraft_path::to_kurbo(&path(&s)).elements().contains(&effectcraft_path::PathEl::ClosePath)); + assert_eq!(checked(&s, &entry.command, &entry.params), Some(false)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(path(&s), original); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(path(&s), opened); + s.execute(&entry.command, entry.params).unwrap(); + assert_eq!(path(&s), original); + assert!(effectcraft_path::to_kurbo(&path(&s)).elements().contains(&effectcraft_path::PathEl::ClosePath)); + } +} + /// `cargo test -p effectcraft-engine dump_specs -- --ignored --nocapture`: every command as TSV. #[test] #[ignore] diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index ef2bd8acc..36e3b0537 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -286,6 +286,185 @@ fn shape_path(s: &Session, lid: u64, uid: u64) -> effectcraft_keyframe::ShapePat l.props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone() } +#[test] +fn toggle_closed_respects_path_and_vertex_selection() { + let mut s = comp(); + let a = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + let lid = a["layer"].as_u64().unwrap(); + let a = a["path"].as_u64().unwrap(); + let b = s.execute("shape.newPath", json!({"layer": lid, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap()["path"].as_u64().unwrap(); + let leaf = layer(&s, lid).props.find_group(a).unwrap().get("path").unwrap().uid; + s.state.selected_vertices.clear(); + s.state.selected_props = vec![(LayerId(lid), leaf)]; + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(!shape_path(&s, lid, a).closed); + assert!(shape_path(&s, lid, b).closed, "an unselected sibling stays closed"); + s.state.selected_props = vec![(LayerId(lid), b)]; + s.state.selected_vertices = vec![crate::VertexRef { layer: LayerId(lid), mask: a, index: 1 }]; + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(shape_path(&s, lid, a).closed, "vertices take precedence over property selection"); + assert!(shape_path(&s, lid, b).closed); + s.state.selected_vertices.clear(); + s.state.selected_props = vec![(LayerId(lid), a), (LayerId(lid), leaf), (LayerId(lid), b)]; + let r = s.execute("path.toggleClosed", json!({})).unwrap(); + assert_eq!(r["paths"], json!(2)); + assert!(!shape_path(&s, lid, a).closed); + assert!(!shape_path(&s, lid, b).closed); + s.execute("edit.undo", json!({})).unwrap(); + assert!(shape_path(&s, lid, a).closed); + assert!(shape_path(&s, lid, b).closed, "all paths undo in one step"); + s.execute("mask.setClosed", json!({"layer": lid, "mask": a, "closed": false})).unwrap(); + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(shape_path(&s, lid, a).closed); + assert!(shape_path(&s, lid, b).closed, "a mixed selection closes all paths"); + s.state.selected_props.clear(); + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(!shape_path(&s, lid, a).closed); + assert!(!shape_path(&s, lid, b).closed, "layer selection addresses all editable paths"); +} + +#[test] +fn toggle_closed_ignores_selections_retained_on_other_layers() { + let mut s = comp(); + let a = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + let (a_layer, a_path) = (a["layer"].as_u64().unwrap(), a["path"].as_u64().unwrap()); + let b_layer = s.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.state.selected_vertices.iter().any(|v| v.layer == LayerId(a_layer)), "insertion retains old vertices"); + assert!(!s.is_enabled("path.toggleClosed")); + assert!(s.execute("path.toggleClosed", json!({})).is_err()); + assert!(shape_path(&s, a_layer, a_path).closed); + + let b_path = + s.execute("shape.newPath", json!({"layer": b_layer, "vertices": [[0, 0], [50, 0], [50, 50]], "closed": true})).unwrap()["path"].as_u64().unwrap(); + s.execute("prop.select", json!({"layer": b_layer, "prop": b_path})).unwrap(); + s.execute("layer.selectNext", json!({})).unwrap(); + assert_eq!(s.state.selected_layers, vec![LayerId(a_layer)]); + assert!(s.state.selected_vertices.iter().any(|v| v.layer == LayerId(b_layer)), "navigation retains old vertices"); + assert!(s.state.selected_props.contains(&(LayerId(b_layer), b_path))); + assert_eq!(crate::menus::checked(&s, "path.toggleClosed", &json!({})), Some(true)); + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(!shape_path(&s, a_layer, a_path).closed); + assert!(shape_path(&s, b_layer, b_path).closed, "the deselected layer stays unchanged"); + s.execute("layer.selectPrevious", json!({})).unwrap(); + assert_eq!(s.state.selected_layers, vec![LayerId(b_layer)]); + s.execute("prop.select", json!({"layer": b_layer, "path": "transform/position"})).unwrap(); + assert!(!s.is_enabled("path.toggleClosed"), "a non-path property does not select sibling paths"); + s.execute("layer.selectNext", json!({})).unwrap(); + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(shape_path(&s, a_layer, a_path).closed, "a property retained on another layer cannot block this layer"); + assert!(shape_path(&s, b_layer, b_path).closed); +} + +#[test] +fn toggle_closed_rejects_locked_stale_and_nonpath_selection() { + let mut s = comp(); + let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + let (lid, uid) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap()); + let before = s.project.clone(); + s.state.selected_vertices.clear(); + s.state.selected_props = vec![(LayerId(lid), u64::MAX)]; + assert!(!s.is_enabled("path.toggleClosed")); + assert!(s.execute("path.toggleClosed", json!({})).is_err()); + assert!(s.project.same_content(&before)); + s.state.selected_props.clear(); + s.state.selected_vertices = vec![crate::VertexRef { layer: LayerId(lid), mask: uid, index: usize::MAX }]; + assert!(!s.is_enabled("path.toggleClosed")); + assert!(s.execute("path.toggleClosed", json!({})).is_err()); + s.state.selected_vertices.clear(); + let cid = s.active_comp_id().unwrap(); + std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(lid)).unwrap().switches.locked = true; + assert!(!s.is_enabled("path.toggleClosed")); + assert!(s.execute("path.toggleClosed", json!({})).is_err()); + assert!(shape_path(&s, lid, uid).closed); + s.execute("layer.newShape", json!({"kind": "rect"})).unwrap(); + s.state.selected_props.clear(); + assert!(!s.is_enabled("path.toggleClosed")); + assert!(s.execute("path.toggleClosed", json!({})).is_err()); +} + +#[test] +fn toggle_closed_handles_masks_and_preserves_explicit_set_closed() { + let mut s = comp(); + let lid = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap(); + let uid = s.execute("mask.new", json!({"layer": lid, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap()["mask"].as_u64().unwrap(); + let path = |s: &Session| layer(s, lid).props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); + let original = path(&s); + s.state.selected_props = vec![(LayerId(lid), uid)]; + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(!path(&s).closed); + s.execute("mask.setClosed", json!({"layer": lid, "mask": uid})).unwrap(); + assert_eq!(path(&s), original, "explicit API still defaults to closed"); + s.execute("mask.setClosed", json!({"layer": lid, "mask": uid, "closed": false})).unwrap(); + assert!(!path(&s).closed); + s.execute("path.toggleClosed", json!({})).unwrap(); + assert_eq!(path(&s), original); + let cid = s.active_comp_id().unwrap(); + let g = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(lid)).unwrap().props.find_group_mut(uid).unwrap(); + if let effectcraft_project::GroupKind::Mask { locked, .. } = &mut g.kind { + *locked = true; + } + assert!(!s.is_enabled("path.toggleClosed")); + assert!(s.execute("path.toggleClosed", json!({})).is_err()); + assert_eq!(path(&s), original); +} + +#[test] +fn toggle_closed_edits_layer_time_and_roundtrips() { + use effectcraft_keyframe::Keyframe; + let mut s = comp(); + let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + let (lid, uid) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap()); + let cid = s.active_comp_id().unwrap(); + let original = shape_path(&s, lid, uid); + let second = Tick::from_seconds_f64(1.0); + let layer = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(lid)).unwrap(); + layer.start_time = second; + layer.stretch = 200.0; + let prop = layer.props.find_group_mut(uid).unwrap().get_mut("path").unwrap(); + prop.keys = vec![Keyframe::new(Tick::ZERO, KV::Path(original.clone())), Keyframe::new(second, KV::Path(original.clone()))]; + let keys = prop.keys.clone(); + s.execute("time.set", json!({"time": 3.0})).unwrap(); + s.execute("path.toggleClosed", json!({})).unwrap(); + let prop = s.project.comp(cid).unwrap().layer(LayerId(lid)).unwrap().props.find_group(uid).unwrap().get("path").unwrap(); + assert_eq!(prop.keys.len(), 2); + assert_eq!(prop.keys[0], keys[0], "the other keyframe is untouched"); + let mut opened = original; + opened.closed = false; + assert_eq!(prop.value_at(second), KV::Path(opened.clone())); + assert!(prop.value_at(Tick::from_seconds_f64(0.5)).as_path().unwrap().closed, "the earlier segment keeps its closedness"); + let bytes = serde_json::to_vec(&*s.project).unwrap(); + let restored: effectcraft_project::Project = serde_json::from_slice(&bytes).unwrap(); + let prop = restored.comp(cid).unwrap().layer(LayerId(lid)).unwrap().props.find_group(uid).unwrap().get("path").unwrap(); + assert_eq!(prop.value_at(second), KV::Path(opened)); + s.execute("time.set", json!({"time": 2.0})).unwrap(); + assert_eq!(crate::menus::checked(&s, "path.toggleClosed", &json!({})), Some(true)); + s.execute("time.set", json!({"time": 3.0})).unwrap(); + assert_eq!(crate::menus::checked(&s, "path.toggleClosed", &json!({})), Some(false)); +} + +#[test] +fn toggle_closed_rejects_excessive_group_nesting_without_an_edit() { + use effectcraft_project::build::{self, Ids}; + let mut s = comp(); + let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + let lid = LayerId(r["layer"].as_u64().unwrap()); + let cid = s.active_comp_id().unwrap(); + let project = std::sync::Arc::make_mut(&mut s.project); + let mut next = project.next_id; + let contents = project.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.sub_mut("contents").unwrap(); + let node = contents.children.first_mut().unwrap(); + let mut nested = node.as_group().unwrap().clone(); + for _ in 0..130 { + nested = build::shape_group(&mut Ids(&mut next), "Nested", vec![nested]); + } + *node = effectcraft_project::Node::Group(nested); + project.next_id = next; + let before = s.project.clone(); + assert!(!s.is_enabled("path.toggleClosed")); + assert!(s.execute("path.toggleClosed", json!({})).is_err()); + assert!(s.project.same_content(&before)); +} + #[test] fn shape_pen_builds_shape_group_and_renders() { let mut s = comp(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index fe676163e..18ffe2725 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -699,7 +699,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "隐藏锁定的蒙版"), ("", "Mask and Shape Path", "蒙版和形状路径"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "已关闭"), + ("path.toggleClosed", "Closed", "已关闭"), ("path.convertToBezier", "Convert To Bezier Path", "转换为贝塞尔曲线路径"), ("path.setFirstVertex", "Set First Vertex", "设置第一个顶点"), ("path.freeTransform", "Free Transform Points", "自由变换点"), @@ -1333,7 +1333,7 @@ const SWEDISH: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Dölj låsta masker"), ("", "Mask and Shape Path", "Mask- och formbana"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Sluten"), + ("path.toggleClosed", "Closed", "Sluten"), ("path.convertToBezier", "Convert To Bezier Path", "Konvertera till Bezierbana"), ("path.setFirstVertex", "Set First Vertex", "Ange första hörnpunkt"), ("path.freeTransform", "Free Transform Points", "Fri transformering av punkter"), @@ -1973,7 +1973,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "隱藏鎖定的遮色片"), ("", "Mask and Shape Path", "遮色片和形狀路徑"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "封閉"), + ("path.toggleClosed", "Closed", "封閉"), ("path.convertToBezier", "Convert To Bezier Path", "轉換為貝茲路徑"), ("path.setFirstVertex", "Set First Vertex", "設定第一個頂點"), ("path.freeTransform", "Free Transform Points", "自由變換點"), @@ -2608,7 +2608,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "ロックしたマスクを非表示"), ("", "Mask and Shape Path", "マスクとシェイプのパス"), ("path.rotoBezier", "RotoBezier", "ロトベジェ"), - ("mask.setClosed", "Closed", "閉じたパス"), + ("path.toggleClosed", "Closed", "閉じたパス"), ("path.convertToBezier", "Convert To Bezier Path", "ベジェパスに変換"), ("path.setFirstVertex", "Set First Vertex", "最初の頂点を設定"), ("path.freeTransform", "Free Transform Points", "ポイントを自由変形"), @@ -3246,7 +3246,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Приховати заблоковані маски"), ("", "Mask and Shape Path", "Контур маски та фігури"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Замкнений"), + ("path.toggleClosed", "Closed", "Замкнений"), ("path.convertToBezier", "Convert To Bezier Path", "Перетворити на контур Безьє"), ("path.setFirstVertex", "Set First Vertex", "Задати першу вершину"), ("path.freeTransform", "Free Transform Points", "Вільне трансформування точок"), @@ -3885,7 +3885,7 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Ocultar máscaras bloqueadas"), ("", "Mask and Shape Path", "Trazado de máscara y de forma"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Cerrado"), + ("path.toggleClosed", "Closed", "Cerrado"), ("path.convertToBezier", "Convert To Bezier Path", "Convertir en trazado Bézier"), ("path.setFirstVertex", "Set First Vertex", "Establecer primer vértice"), ("path.freeTransform", "Free Transform Points", "Transformar puntos libremente"), @@ -4522,7 +4522,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Ocultar máscaras bloqueadas"), ("", "Mask and Shape Path", "Caminho da máscara e da forma"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Fechado"), + ("path.toggleClosed", "Closed", "Fechado"), ("path.convertToBezier", "Convert To Bezier Path", "Converter em caminho Bézier"), ("path.setFirstVertex", "Set First Vertex", "Definir primeiro vértice"), ("path.freeTransform", "Free Transform Points", "Pontos de transformação livre"), @@ -5166,7 +5166,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Скрыть заблокированные маски"), ("", "Mask and Shape Path", "Контур маски и фигуры"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Замкнутый"), + ("path.toggleClosed", "Closed", "Замкнутый"), ("path.convertToBezier", "Convert To Bezier Path", "Преобразовать в кривую Безье"), ("path.setFirstVertex", "Set First Vertex", "Задать первую вершину"), ("path.freeTransform", "Free Transform Points", "Свободное трансформирование точек"), @@ -5803,7 +5803,7 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Masquer les masques verrouillés"), ("", "Mask and Shape Path", "Trajectoire de masque et de forme"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Fermé"), + ("path.toggleClosed", "Closed", "Fermé"), ("path.convertToBezier", "Convert To Bezier Path", "Convertir en trajectoire Bézier"), ("path.setFirstVertex", "Set First Vertex", "Définir le premier sommet"), ("path.freeTransform", "Free Transform Points", "Transformation libre des points"), @@ -6440,7 +6440,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Gesperrte Masken ausblenden"), ("", "Mask and Shape Path", "Masken- und Formpfad"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Geschlossen"), + ("path.toggleClosed", "Closed", "Geschlossen"), ("path.convertToBezier", "Convert To Bezier Path", "In Bézier-Pfad konvertieren"), ("path.setFirstVertex", "Set First Vertex", "Ersten Scheitelpunkt festlegen"), ("path.freeTransform", "Free Transform Points", "Punkte frei transformieren"), @@ -7076,7 +7076,7 @@ const POLISH: &[(&str, &str, &str)] = &[ ("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.toggleClosed", "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"), @@ -7712,7 +7712,7 @@ const CZECH: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Skrýt zamknuté masky"), ("", "Mask and Shape Path", "Cesta masky a tvaru"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Uzavřená"), + ("path.toggleClosed", "Closed", "Uzavřená"), ("path.convertToBezier", "Convert To Bezier Path", "Převést na Bézierovu cestu"), ("path.setFirstVertex", "Set First Vertex", "Nastavit první vrchol"), ("path.freeTransform", "Free Transform Points", "Volně transformovat body"), @@ -8345,7 +8345,7 @@ const GEORGIAN: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "ჩაკეტილი ნიღბების დამალვა"), ("", "Mask and Shape Path", "ნიღაბი და ფიგურის ტრაექტორია"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "შეკრული"), + ("path.toggleClosed", "Closed", "შეკრული"), ("path.convertToBezier", "Convert To Bezier Path", "ბეზიეს ტრაექტორიად გარდაქმნა"), ("path.setFirstVertex", "Set First Vertex", "პირველი წვეროს დაყენება"), ("path.freeTransform", "Free Transform Points", "წერტილების თავისუფალი გარდაქმნა"), @@ -9215,12 +9215,16 @@ mod tests { alt_shortcuts: Vec::new(), }; let new_comp = menu_entry("comp.new", "New Composition..."); + let closed_path = menu_entry("path.toggleClosed", "Closed"); let undo = menu_entry("edit.undo", "Undo"); for code in CATALOG_CODES { app.session.execute("prefs.set", json!({"key":"general.language", "value":code})).unwrap(); let drawn = entry(&app, &new_comp, new_comp.label.clone()); assert_eq!(drawn, label(&app, "comp.new", "New Composition..."), "{code}"); assert_ne!(drawn, new_comp.label, "{code}: the menu entry is drawn in English"); + let closed_label = entry(&app, &closed_path, closed_path.label.clone()); + assert_eq!(closed_label, label(&app, "path.toggleClosed", "Closed"), "{code}"); + assert_ne!(closed_label, closed_path.label, "{code}: Closed is drawn in English"); } app.session.execute("prefs.set", json!({"key":"general.language", "value":"ru"})).unwrap(); assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Нечего отменять"); From 81d585e381d9aabec87ebdfb4ecb77963972c26d Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Mon, 2 Nov 2026 22:14:13 +0530 Subject: [PATCH 008/227] Fix #2869: preserve arrows when Effect Controls is hidden --- .../src/panels/effect_controls/panel.rs | 9 ++++- crates/ui-egui/tests/it/ui_effect_controls.rs | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 0d81029e6..4932ddd21 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -2,10 +2,17 @@ use super::common::{Actions, PARAM_NAME_X, ROW, effect_active, selected_layer}; use super::editors::{curves_editor, editor_height, histogram_only, levels_editor, object_matte_editor, roto_editor, warp_editor}; use super::rows::{FxScope, group_rows, toggle_closed}; use super::{Align2, Color32, EffectcraftApp, EvalCtx, GroupKind, Icon, Layer, PropGroup, Rect, Sense, Stroke, Tokens, json, pos2, vec2, widgets}; +use crate::dock::{PanelKind, WorkspaceExt as _}; /// Plain Left/Right disclose selected effect headers before global layer nudges run. pub(crate) fn handle_disclosure_key(app: &mut EffectcraftApp, key: egui::Key) -> bool { - if app.ui.focused != crate::dock::PanelKind::EffectControls || !matches!(key, egui::Key::ArrowLeft | egui::Key::ArrowRight) { + let panel = PanelKind::EffectControls; + if app.ui.focused != panel + || app.ui.start_screen + || !app.ui.dock.is_visible(panel) + || (app.ui.dock.is_docked(panel) && app.ui.maximized.is_some_and(|m| m != panel && app.ui.dock.is_docked(m))) + || !matches!(key, egui::Key::ArrowLeft | egui::Key::ArrowRight) + { return false; } let Some(comp) = app.session.active_comp() else { return false }; diff --git a/crates/ui-egui/tests/it/ui_effect_controls.rs b/crates/ui-egui/tests/it/ui_effect_controls.rs index 3de502a5b..6710b6603 100644 --- a/crates/ui-egui/tests/it/ui_effect_controls.rs +++ b/crates/ui-egui/tests/it/ui_effect_controls.rs @@ -154,6 +154,41 @@ fn effect_header_arrows_only_disclose_selected_headers_on_the_displayed_layer() assert_ne!(serde_json::to_value(&h.state().session.project).unwrap(), before, "the existing layer nudge still runs"); } +#[test] +fn effect_header_arrows_ignore_hidden_panels_with_stale_focus() { + for hidden in ["closed", "maximized", "home"] { + let (mut h, lid, [(fx, _), _, _]) = header_arrow_harness(); + h.state_mut().session.state.selected_props = vec![(lid, fx)]; + match hidden { + "closed" => h.state_mut().close_panel(PanelKind::EffectControls), + "maximized" => h.state_mut().toggle_maximize(PanelKind::Composition), + _ => h.state_mut().ui.start_screen = true, + } + h.run_steps(2); + assert_eq!(h.state().ui.focused, PanelKind::EffectControls, "{hidden}: stale focus is preserved"); + assert!(h.state().auto.find("panel.EffectControls").is_none(), "{hidden}: Effect Controls is hidden"); + let x = |h: &Harness<'_, EffectcraftApp>| layer(&h.state().session, lid.0).props.prop("transform/position").unwrap().value.as_vec3()[0]; + let before = x(&h); + header_arrow_key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE); + assert!(h.state().ui.fx_closed.is_empty(), "{hidden}: hidden effects keep their disclosure state"); + assert!(x(&h) < before, "{hidden}: Left still nudges the layer"); + } +} + +#[test] +fn effect_header_arrows_work_in_floating_panel_beside_maximized_panel() { + let (mut h, lid, [(fx, prop), _, _]) = header_arrow_harness(); + h.state_mut().apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::EffectControls, rect: [40.0, 40.0, 600.0, 400.0] }).unwrap(); + h.state_mut().toggle_maximize(PanelKind::Composition); + h.state_mut().session.state.selected_props = vec![(lid, fx)]; + h.run_steps(3); + assert!(h.state().auto.find(&format!("effectControls.row.{prop}")).is_some()); + header_arrow_key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE); + assert!(h.state().auto.find(&format!("effectControls.row.{prop}")).is_none()); + header_arrow_key(&mut h, egui::Key::ArrowRight, egui::Modifiers::NONE); + assert!(h.state().auto.find(&format!("effectControls.row.{prop}")).is_some()); +} + #[test] fn effect_header_arrows_preserve_modified_shortcuts_and_keyboard_ownership() { let (mut h, lid, [(fx, _), _, _]) = header_arrow_harness(); From 2b1b68855798f23ec473408ad01fc144f313f3cd Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Mon, 2 Nov 2026 22:16:19 +0530 Subject: [PATCH 009/227] #2817: honor combined path and shape group selections --- crates/engine/src/cmd/paths/closed.rs | 20 +++++++++----------- crates/engine/src/tests/viewer.rs | 27 ++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/paths/closed.rs b/crates/engine/src/cmd/paths/closed.rs index d3cd8c1ef..268e68843 100644 --- a/crates/engine/src/cmd/paths/closed.rs +++ b/crates/engine/src/cmd/paths/closed.rs @@ -38,19 +38,18 @@ fn targets(s: &Session) -> Result> { props: s.state.selected_props.iter().filter(|(lid, _)| s.state.selected_layers.contains(lid)).copied().collect(), time: s.time(), }; + let whole_layers = selection.vertices.is_empty() && selection.props.is_empty(); let mut targets = Vec::new(); for layer in comp.layers.iter() { - let selected_layer = if !selection.vertices.is_empty() { - selection.vertices.iter().any(|v| v.layer == layer.id) - } else if !selection.props.is_empty() { - selection.props.iter().any(|(lid, _)| *lid == layer.id) - } else { + let selected_layer = if whole_layers { s.state.selected_layers.contains(&layer.id) + } else { + selection.vertices.iter().any(|v| v.layer == layer.id) || selection.props.iter().any(|(lid, _)| *lid == layer.id) }; if !selected_layer || layer.switches.locked { continue; } - collect(&selection, layer, &layer.props, &mut Vec::new(), selection.props.is_empty(), &mut targets)?; + collect(&selection, layer, &layer.props, &mut Vec::new(), whole_layers, &mut targets)?; } if targets.is_empty() { return Err(bad("path.toggleClosed", "select an unlocked mask or Bezier shape path")); @@ -70,11 +69,10 @@ fn collect(selection: &Selection, layer: &Layer, group: &PropGroup, route: &mut && let Some(prop) = group.get("path") && let KV::Path(path) = prop.value_at(layer.layer_time(selection.time)) { - let selected = if selection.vertices.is_empty() { - selected || selection.props.contains(&(layer.id, prop.uid)) - } else { - selection.vertices.iter().any(|v| v.layer == layer.id && v.mask == group.uid && v.index < path.vertices.len()) - }; + // Property selection also selects its points; both describe the selected paths. + let selected = selected + || selection.props.contains(&(layer.id, prop.uid)) + || selection.vertices.iter().any(|v| v.layer == layer.id && v.mask == group.uid && v.index < path.vertices.len()); if selected { targets.push(Target { layer: layer.id, route: route.clone(), closed: path.closed }); } diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index 36e3b0537..a9cd5c646 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -302,7 +302,7 @@ fn toggle_closed_respects_path_and_vertex_selection() { s.state.selected_props = vec![(LayerId(lid), b)]; s.state.selected_vertices = vec![crate::VertexRef { layer: LayerId(lid), mask: a, index: 1 }]; s.execute("path.toggleClosed", json!({})).unwrap(); - assert!(shape_path(&s, lid, a).closed, "vertices take precedence over property selection"); + assert!(shape_path(&s, lid, a).closed, "selected points and properties both describe target paths"); assert!(shape_path(&s, lid, b).closed); s.state.selected_vertices.clear(); s.state.selected_props = vec![(LayerId(lid), a), (LayerId(lid), leaf), (LayerId(lid), b)]; @@ -323,6 +323,31 @@ fn toggle_closed_respects_path_and_vertex_selection() { assert!(!shape_path(&s, lid, b).closed, "layer selection addresses all editable paths"); } +#[test] +fn toggle_closed_combines_property_and_shape_group_selections() { + let mut s = comp(); + let a = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + let (lid, a_path) = (a["layer"].as_u64().unwrap(), a["path"].as_u64().unwrap()); + let b = s.execute("shape.newPath", json!({"layer": lid, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap(); + let (b_path, b_group) = (b["path"].as_u64().unwrap(), b["group"].as_u64().unwrap()); + let c_path = + s.execute("shape.newPath", json!({"layer": lid, "vertices": [[20, 20], [80, 20], [80, 80]], "closed": true})).unwrap()["path"].as_u64().unwrap(); + s.execute("prop.select", json!({"layer": lid, "prop": a_path})).unwrap(); + s.execute("prop.select", json!({"layer": lid, "prop": b_group, "add": true})).unwrap(); + assert!(s.state.selected_vertices.iter().all(|v| v.mask == a_path), "only the path property implies selected vertices"); + assert!(!s.state.selected_vertices.is_empty()); + assert!(s.state.selected_props.contains(&(LayerId(lid), b_group))); + let result = s.execute("path.toggleClosed", json!({})).unwrap(); + assert_eq!(result["paths"], json!(2)); + assert!(!shape_path(&s, lid, a_path).closed); + assert!(!shape_path(&s, lid, b_path).closed, "the added shape group is included alongside implied vertices"); + assert!(shape_path(&s, lid, c_path).closed, "unselected sibling stays unchanged"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(shape_path(&s, lid, a_path).closed); + assert!(shape_path(&s, lid, b_path).closed, "both selected paths undo together"); + assert!(shape_path(&s, lid, c_path).closed); +} + #[test] fn toggle_closed_ignores_selections_retained_on_other_layers() { let mut s = comp(); From 4966534c8274e2349dc75c3cd26e9200b69b01b0 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:55:33 +0100 Subject: [PATCH 010/227] Make the audio preview follow edits that change the sound only, and start it when a silent preview gains sound (#2104) --- crates/ui-egui/src/app/playback.rs | 1 + crates/ui-egui/src/app/prefetch.rs | 16 ++++++++++++ crates/ui-egui/src/audio.rs | 40 ++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 2 ++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index d40bc69ff..5bb860d5b 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -40,6 +40,7 @@ impl EffectcraftApp { restore_max, audio_frame: (!plan.video).then_some(plan.first), audio_held: false, + silent_project: None, starting: false, starting_since: now, first_cached: None, diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index c18bb2415..2277589de 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -70,6 +70,7 @@ impl EffectcraftApp { let step = plan.map_or(1, |p| p.step); let fit_ahead = self.frames_that_fit(&c, scale) - 1; self.prefetch_playback(&pass, fit_ahead); + self.wake_silent_preview(); self.pump_meters(ctx, now); if !self.playback.playing { self.cache_when_idle(ctx, &c, &series, now, input_active, (wa, wb)); @@ -254,6 +255,21 @@ impl EffectcraftApp { false } + /// A silent preview whose project was edited so that it has sound now (a layer unmuted while + /// playing, #2104): hold the sound for [`Self::start_held_audio`], which starts it from the + /// current frame once the frames ahead are cached. Checked once per project edit. + fn wake_silent_preview(&mut self) { + let pb = &self.playback; + if self.audio.is_some() || !pb.playing || pb.caching || pb.starting || pb.audio_held || !pb.plan.is_some_and(|p| p.video) { + return; + } + if pb.silent_project.as_ref().is_some_and(|p| std::sync::Arc::ptr_eq(p, &self.session.project)) { + return; + } + self.playback.silent_project = Some(self.session.project.clone()); + self.playback.audio_held = self.audio_plays(); + } + /// Held sound starts once the frames ahead are cached again after a stall (see /// `Playback::audio_held`): the prefetch window when the silent preview keeps real time, else /// the rest of the preview. diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index f8b52fc88..eb54822d1 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -23,7 +23,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; -use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, Project}; +use effectcraft_engine::project::{GroupKind, ItemId, ItemKind, Layer, LayerSource, Project, PropGroup}; use effectcraft_engine::time::{FrameRate, Tick}; use crate::frames::RenderSource; @@ -342,7 +342,7 @@ fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { || x.switches.solo != y.switches.solo || x.switches.effects != y.switches.effects || x.props.sub("audio") != y.props.sub("audio") - || x.effects() != y.effects() + || audio_effects(x) != audio_effects(y) || match &x.source { LayerSource::Footage { item } => footage_differs(old, new, *item), LayerSource::Comp { item } => comps_differ(old, new, *item, depth + 1), @@ -350,6 +350,12 @@ fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { } }) } + /// The layer's audio effects (Effect > Audio), the only effects the mixer applies; a video effect + /// edit leaves them as they were. + fn audio_effects(l: &Layer) -> Vec<&PropGroup> { + let is_audio = |g: &&PropGroup| matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_engine::effects::audio_fx::is_audio_effect(effect)); + l.effects().into_iter().flat_map(|fx| fx.groups()).filter(is_audio).collect() + } /// The footage item's audio availability changed (relinked, missing, no audio). fn footage_differs(old: &Project, new: &Project, item: ItemId) -> bool { let f = |p: &Project| match p.item(item).map(|i| &i.kind) { @@ -763,6 +769,36 @@ mod tests { } } + /// #2104: a video effect edit leaves the mix alone, an audio effect changes it. + #[test] + fn only_audio_effects_change_the_audio() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::build::{self, Ids}; + use effectcraft_engine::project::{Comp, Footage, FootageKind}; + + let mut p = Project::default(); + let footage = + Footage { path: "tone.flac".into(), kind: FootageKind::Audio, duration: Tick::from_seconds_f64(60.0), has_audio: true, ..Default::default() }; + let item = p.add_item("tone.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, "tone.flac", LayerSource::Footage { item }, (0, 0), None); + comp.layers = vec![layer].into(); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let with_effect = |id: &str| { + let mut edited = p.clone(); + let spec = effectcraft_engine::effects::find(id).expect("the effect exists"); + let mut next = edited.next_id; + let g = effectcraft_engine::effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 36.0]); + edited.next_id = next; + if let Some(fx) = edited.comp_mut(cid).and_then(|c| c.layers.first_mut()).and_then(|l| l.props.sub_mut("effects")) { + fx.children.push(g.into()); + } + edited + }; + assert!(!audio_changed(&p, &with_effect("ec.blur.gaussian"), cid), "a video effect must not drop the sound"); + assert!(audio_changed(&p, &with_effect("ec.audio.backwards"), cid), "an audio effect must be followed"); + } + #[test] fn meter_decays_and_holds_peaks() { let mut m = Meter::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e237eeaa6..cd39b2ba7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -196,6 +196,8 @@ pub struct Playback { /// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or /// sooner once the silent preview keeps real time with the frames ahead cached (#208). pub audio_held: bool, + /// The project last checked for a silent preview that became audible (#2104). + pub silent_project: Option>, /// Starting a preview with sound (#947): the playhead stays on the first frame while the /// frames ahead render (the prefetch window, at most what fits in the RAM preview), then /// sound and picture start together from it. Without this the picture played on silently From 29619dfe6749cd977f005a6b73e4ef7bcf12d228 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:38:57 +0100 Subject: [PATCH 011/227] Preview: render at most two frames on the GPU at once so video memory stays flat (#3028) --- crates/ui-egui/src/frames.rs | 59 ++++++++++++++++++++++++++++-- crates/ui-egui/src/frames/tests.rs | 22 +++++++++++ docs/architecture.md | 4 +- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 9aad2a81c..c4cc1eee1 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -26,7 +26,7 @@ use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; use effectcraft_engine::render::disk_cache::{self, DiskCache}; @@ -47,6 +47,46 @@ const MIN_GPU_BUDGET: usize = 64 << 20; const GPU_PRESSURE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(5); const GPU_RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); +/// GPU frames that render at once, however many frame threads there are. Each one holds its own +/// working textures, so 16 at once ran an RTX 3090 out of video memory (#3028). +const MAX_GPU_RENDERS: usize = 2; + +/// Counts the GPU renders in flight; the rest of the frame threads wait their turn. +struct GpuSlots { + busy: Mutex, + freed: Condvar, +} + +/// One GPU render slot, given back on drop. +struct GpuSlot<'a>(&'a GpuSlots); + +static GPU_SLOTS: GpuSlots = GpuSlots::new(); + +impl GpuSlots { + const fn new() -> Self { + Self { busy: Mutex::new(0), freed: Condvar::new() } + } + + /// Wait until fewer than `max` renders are in flight, then take a slot. The browser has no + /// threads to wait on: it never blocks. + fn acquire(&self, max: usize) -> GpuSlot<'_> { + let mut busy = self.busy.lock().unwrap_or_else(PoisonError::into_inner); + while !cfg!(target_arch = "wasm32") && *busy >= max { + busy = self.freed.wait(busy).unwrap_or_else(PoisonError::into_inner); + } + *busy += 1; + GpuSlot(self) + } +} + +impl Drop for GpuSlot<'_> { + fn drop(&mut self) { + let mut busy = self.0.busy.lock().unwrap_or_else(PoisonError::into_inner); + *busy = busy.saturating_sub(1); + self.0.freed.notify_one(); + } +} + /// A rendered viewer frame: CPU pixels or a GPU texture. #[derive(Clone)] pub enum FrameImage { @@ -1458,7 +1498,10 @@ impl Worker { r.cache = Some(&job.src.layer_cache); r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator); r.active_accel()?; - let f = g.within_memory(|| g.render_display(&r, job.comp, job.t)); + let f = { + let _slot = GPU_SLOTS.acquire(MAX_GPU_RENDERS); + g.within_memory(|| g.render_display(&r, job.comp, job.t)) + }; effectcraft_engine::render::Accelerator::frame_end(g); match f { Ok(f) => { @@ -1497,7 +1540,11 @@ impl Worker { let auto = if cfg!(target_arch = "wasm32") { None } else { r.frame_auto(a, job.comp, true) }; if auto.is_none_or(|(p, key)| p.choose(key)) { let t0 = web_time::Instant::now(); - match g.within_memory(|| g.render_display(&r, job.comp, job.t)) { + let shown = { + let _slot = GPU_SLOTS.acquire(MAX_GPU_RENDERS); + g.within_memory(|| g.render_display(&r, job.comp, job.t)) + }; + match shown { Ok(Some(f)) => { self.gpu_succeeded(); if let Some((p, key)) = auto { @@ -1530,7 +1577,11 @@ impl Worker { } if let Some(g) = gpu { // The GPU composites and the frame is read back. - return match g.within_memory(|| r.comp_frame(job.comp, job.t)) { + let composited = { + let _slot = GPU_SLOTS.acquire(MAX_GPU_RENDERS); + g.within_memory(|| r.comp_frame(job.comp, job.t)) + }; + return match composited { Ok(img) => { self.gpu_succeeded(); FrameImage::Cpu(Arc::new(to_color_image(&img))) diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index ace51b0bb..dde9225c5 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -1113,3 +1113,25 @@ fn timing_markers_and_expressions_still_change_the_frames() { s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.75})).unwrap(); assert_ne!(at(&s), o2, "an expression can read the marker, so the frames change"); } + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_renders_in_flight_stay_within_the_cap() { + use std::sync::atomic::AtomicUsize; + let slots = GpuSlots::new(); + let now = AtomicUsize::new(0); + let peak = AtomicUsize::new(0); + std::thread::scope(|s| { + for _ in 0..16 { + s.spawn(|| { + let _slot = slots.acquire(2); + let n = now.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(n, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(5)); + now.fetch_sub(1, Ordering::SeqCst); + }); + } + }); + assert!(peak.load(Ordering::SeqCst) <= 2); + assert_eq!(*slots.busy.lock().unwrap_or_else(PoisonError::into_inner), 0); +} diff --git a/docs/architecture.md b/docs/architecture.md index c4bebd9ed..7637dbc28 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -362,7 +362,9 @@ as NaNs (a kernel that skips pixels fails the oracle) and validation errors pani (`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. Outside tests, device errors are logged rather than fatal. A viewer frame that runs out of video memory (`Gpu::within_memory`) renders on the CPU, the uploaded layers and pooled textures are freed, and -the RAM preview keeps half as many GPU frames from then on. The same guard catches a frame's +the RAM preview keeps half as many GPU frames from then on. However many frame threads the viewer +runs, at most two of them render on the GPU at once and the rest wait their turn, so their working +textures don't add up to more video memory than the GPU has (#3028). The same guard catches a frame's validation errors (an invalid texture, often left by a failed allocation): that frame renders on the CPU and the kept textures are freed, but the compositor stays on and, with no allocation failure before it, the RAM preview keeps its GPU frames (#1319). A frame whose render panics is From d643e7f8f0babbf9c80aeba1fbd82dea7d9bd9f8 Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 18:55:46 +0100 Subject: [PATCH 012/227] #1388: distinguish fine raster clips and update layer test fixtures --- crates/render/src/pipeline.rs | 8 +++++++- crates/render/src/tests/raster_clip.rs | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 5966ebf2e..029438cc5 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -499,7 +499,7 @@ impl<'a> Renderer<'a> { pub(crate) fn clipped_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { let k = key?; Some(match self.raster_clip(ctx, layer) { - Some(c) => [c.x0, c.y0, c.x1, c.y1].iter().fold(cache::derive(k, 0xc11b), |d, v| cache::derive(d, (v * 16.0).round() as i64 as u64)), + Some(c) => raster_clip_key(k, c), None => k, }) } @@ -1209,3 +1209,9 @@ impl<'a> Renderer<'a> { Some(self.keep(key, Arc::new(buf?), &files).0) } } + +/// Snap cells can be finer than 1/16 unit: preserve every coordinate bit so visible +/// clips cannot collide with another cell or the empty behind-camera raster. +pub(crate) fn raster_clip_key(key: u64, clip: kurbo::Rect) -> u64 { + [clip.x0, clip.y0, clip.x1, clip.y1].iter().fold(cache::derive(key, 0xc11b), |key, coordinate| cache::derive(key, coordinate.to_bits())) +} diff --git a/crates/render/src/tests/raster_clip.rs b/crates/render/src/tests/raster_clip.rs index 8bcadb7ee..f7227f634 100644 --- a/crates/render/src/tests/raster_clip.rs +++ b/crates/render/src/tests/raster_clip.rs @@ -190,7 +190,7 @@ fn moving(label: &str, pos: Keys, poi: Keys, text_pos: Keys, text_rot: &[(f64, f set(&mut cam, "cameraOptions/zoom", Value::Scalar(zoom())); keyed(&mut cam, "transform/position", pos); keyed(&mut cam, "transform/poi", poi); - p.comp_mut(cid).unwrap().layers = vec![cam, text]; + p.comp_mut(cid).unwrap().layers = vec![cam, text].into(); let cache = crate::LayerCache::default(); let (mut exact, mut near, mut capped) = (0, 0, 0); for f in 0..30 { @@ -286,7 +286,7 @@ fn layer_space_reads_get_the_whole_layer() { p.next_id = next; g.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(text_id.0)); solid.props.sub_mut("effects").unwrap().children.push(g.into()); - p.comp_mut(cid).unwrap().layers = vec![text, solid]; + p.comp_mut(cid).unwrap().layers = vec![text, solid].into(); assert_eq!(frame(&p, cid, false).data, frame(&p, cid, true).data, "Set Matte from the text"); let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); @@ -321,3 +321,14 @@ fn layer_panel_input_reads_the_whole_layer_behind_the_camera() { assert!(expected.img.data.iter().any(|pixel| pixel[3] > 0.5)); assert_eq!(r.layer_input(&ctx, layer, 0).unwrap().img, expected.img); } + +#[test] +fn fine_clip_cells_have_distinct_cache_keys() { + let a = kurbo::Rect::new(0.0, 0.0, 1.0 / 128.0, 1.0 / 128.0); + let b = kurbo::Rect::new(1.0 / 128.0, 0.0, 2.0 / 128.0, 1.0 / 128.0); + let key = |clip| crate::pipeline::raster_clip_key(42, clip); + // All three clips rounded to identical coordinates in the old 1/16-unit key. + assert_ne!(key(a), key(b)); + assert_ne!(key(a), key(kurbo::Rect::ZERO)); + assert_ne!(key(b), key(kurbo::Rect::ZERO)); +} From fc4bcd21d474d126e5a59c1cad8ed5589cf45490 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:15:19 +0100 Subject: [PATCH 013/227] #3357: report unexpected desktop exits with local diagnostics --- apps/effectcraft/src/crash_report.rs | 490 +++++++++++++++++++++++++++ apps/effectcraft/src/main.rs | 29 +- crates/engine/src/system/logging.rs | 33 +- docs/testing.md | 21 ++ 4 files changed, 571 insertions(+), 2 deletions(-) create mode 100644 apps/effectcraft/src/crash_report.rs diff --git a/apps/effectcraft/src/crash_report.rs b/apps/effectcraft/src/crash_report.rs new file mode 100644 index 000000000..1dd25c571 --- /dev/null +++ b/apps/effectcraft/src/crash_report.rs @@ -0,0 +1,490 @@ +//! A small, separate process reports an unexpected desktop exit (#3357). +//! +//! Allocation failure can abort without a panic hook. The GUI therefore owns a pipe to a +//! helper which never loads a project, font database or GPU. Only an explicit marker after +//! normal teardown suppresses the report. EOF alone identifies an unexpected exit, not its +//! cause. System-wide exhaustion can still prevent the helper or its native dialog working. + +use std::ffi::{OsStr, OsString}; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const FLAG: &str = "--internal-crash-reporter"; +const CLEAN: &[u8] = b"EFFECTCRAFT_CLEAN_EXIT_V1\n"; +const LOG_BYTES: usize = 64 * 1024; +const HEADER_BYTES: usize = 128; + +/// Owned only by the GUI, never handed to another child. Dropping it without `finish` closes +/// the pipe and reports an unexpected exit; there is intentionally no clean-exit Drop hook. +pub struct Reporter { + pipe: ChildStdin, + child: Child, +} + +impl Reporter { + /// Call only after application teardown, including handled startup/device-loss errors. + pub fn finish(mut self) -> io::Result<()> { + let sent = self.pipe.write_all(CLEAN).and_then(|()| self.pipe.flush()); + drop(self.pipe); + // Do not let a suspended/broken helper hang shutdown. Reap it before the GUI + // exits where possible, so Windows updates do not race an old executable. + let cleanup = finish_child(&mut self.child, Duration::from_secs(2)); + match (sent, cleanup) { + (Err(sent), Err(cleanup)) => Err(io::Error::other(format!("clean marker failed: {sent}; cleanup failed: {cleanup}"))), + (Err(error), _) | (_, Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } + } +} + +fn finish_child(child: &mut Child, grace: Duration) -> io::Result<()> { + let start = Instant::now(); + while start.elapsed() < grace { + if let Some(status) = child.try_wait()? { + return if status.success() { Ok(()) } else { Err(io::Error::other(format!("crash reporter exited with {status}"))) }; + } + std::thread::sleep(Duration::from_millis(20)); + } + child.kill()?; + // Even after termination, do not introduce an unbounded wait on a stuck OS process. + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(1) { + if child.try_wait()?.is_some() { + return Err(io::Error::other("crash reporter did not finish promptly and was terminated")); + } + std::thread::sleep(Duration::from_millis(20)); + } + Err(io::Error::other("crash reporter termination could not be confirmed")) +} + +/// Each process/run has its own report, including simultaneous launches. +pub fn run_id() -> String { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(); + format!("{}-{nanos}", std::process::id()) +} + +pub fn session_marker(id: &str) -> String { + format!("crash-report session {id}") +} + +/// Start before heavy work. A missing helper is a warning, never a reason to refuse the app. +pub fn start(id: &str, log: Option<&Path>, reports: &Path, interactive: bool) -> io::Result { + let exe = std::env::current_exe()?; + let args = [ + OsString::from(FLAG), + OsString::from(id), + log.map_or_else(OsString::new, |p| p.as_os_str().to_owned()), + reports.as_os_str().to_owned(), + OsString::from(if interactive { "1" } else { "0" }), + ]; + spawn(&exe, &args) +} + +fn spawn(exe: &Path, args: &[OsString]) -> io::Result { + let mut child = Command::new(exe).args(args).stdin(Stdio::piped()).stdout(Stdio::null()).spawn()?; + match child.stdin.take() { + Some(pipe) => Ok(Reporter { pipe, child }), + None => { + let _ = child.kill(); + let _ = child.wait(); + Err(io::Error::other("crash reporter has no control pipe")) + } + } +} + +/// Handle the internal mode before logging, argument parsing, updates or GUI initialization. +/// The callback is a native dialog on this helper's main thread, not on the failed GUI's. +pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool { + if args.first().map(OsString::as_os_str) != Some(OsStr::new(FLAG)) { + return false; + } + let parsed = match args { + [_, id, log, reports, interactive] => id + .to_str() + .filter(|id| valid_id(id)) + .zip(match interactive.to_str() { + Some("0") => Some(false), + Some("1") => Some(true), + _ => None, + }) + .map(|(id, interactive)| (id, (!log.is_empty()).then(|| Path::new(log)), Path::new(reports), interactive)), + _ => None, + }; + let Some((id, log, reports, interactive)) = parsed else { + eprintln!("EffectCraft crash reporter: invalid internal arguments"); + return true; + }; + // Keep the exact file open while the GUI is alive: a later rotation must not substitute + // another instance's log. If rotation won the startup race, the session marker rejects it. + let mut log = SessionLog::open(log, id); + match clean_exit(&mut io::stdin().lock()) { + Ok(true) => {} + result => { + let failure = result.err().map(|e| e.to_string()); + let saved = write_report(reports, id, &mut log, failure.as_deref()); + let description = description(&saved); + eprintln!("{description}"); + if interactive { + show(&description); + } + } + } + true +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 80 && id.bytes().all(|c| c.is_ascii_digit() || c == b'-') +} + +/// Fixed-size comparison: arbitrary/truncated input never grows a buffer or counts as clean. +fn clean_exit(input: &mut impl Read) -> io::Result { + let mut marker = [0u8; CLEAN.len()]; + let mut filled = 0; + while filled < marker.len() { + let remaining = marker.get_mut(filled..).ok_or_else(|| io::Error::other("invalid control marker offset"))?; + match input.read(remaining) { + Ok(0) => return Ok(false), + Ok(n) => filled += n, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(marker == CLEAN) +} + +enum SessionLog { + Captured(File), + Unavailable(String), +} + +impl SessionLog { + fn open(path: Option<&Path>, id: &str) -> Self { + let opened = (|| { + let path = path.ok_or_else(|| io::Error::other("no session log was opened"))?; + let mut file = File::open(path)?; + let mut header = [0u8; HEADER_BYTES]; + let marker = format!("{}\n", session_marker(id)); + let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?; + file.read_exact(prefix)?; + if prefix != marker.as_bytes() { + return Err(io::Error::other("matching session marker unavailable; the log may have rotated")); + } + Ok(file) + })(); + match opened { + Ok(file) => Self::Captured(file), + Err(e) => Self::Unavailable(e.to_string()), + } + } + + fn write_tail(&mut self, report: &mut File) -> io::Result<()> { + match self { + Self::Unavailable(reason) => writeln!(report, "Recent log unavailable: {reason}"), + Self::Captured(file) => { + let size = file.metadata()?.len(); + file.seek(SeekFrom::Start(size.saturating_sub(LOG_BYTES as u64)))?; + writeln!(report, "\nRecorded session log tail (last {LOG_BYTES} bytes at most; logging filters/size caps may omit later events):")?; + io::copy(&mut file.take(LOG_BYTES as u64), report)?; + Ok(()) + } + } + } +} + +fn write_report(reports: &Path, id: &str, log: &mut SessionLog, pipe_error: Option<&str>) -> io::Result { + if !valid_id(id) { + return Err(io::Error::other("invalid report identity")); + } + std::fs::create_dir_all(reports)?; + let path = reports.join(format!("crash-{id}.txt")); + let mut report = OpenOptions::new().write(true).create_new(true).open(&path)?; + writeln!( + report, + "EffectCraft unexpected-exit diagnostic\nVersion: {}\nPlatform: {} / {}\nSession: {id}", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH + )?; + writeln!( + report, + "The GUI's control pipe ended without its clean-exit marker.\nThe exit status and exact cause are not available to this reporter.\nMemory exhaustion, a native crash or external termination are possible.\nThis report was saved locally; it was not sent anywhere." + )?; + if let Some(error) = pipe_error { + writeln!(report, "Control pipe error: {error}")?; + } + if let Err(error) = log.write_tail(&mut report) { + writeln!(report, "\nCould not read the recent session log: {error}")?; + } + report.flush()?; + Ok(path) +} + +fn description(saved: &io::Result) -> String { + let diagnostic = match saved { + Ok(path) => format!("A diagnostic report was saved to:\n{}", path.display()), + Err(error) => format!("A diagnostic report could not be saved: {error}"), + }; + format!( + "EffectCraft stopped unexpectedly.\n\n{diagnostic}\n\nThe exact cause is unknown. Reopen EffectCraft to check for an existing auto-save. Changes since the last auto-save may be lost.\n\nNo report has been sent anywhere." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::thread; + use std::time::{Duration, Instant}; + + static NEXT: AtomicUsize = AtomicUsize::new(0); + const ROLE: &str = "EFFECTCRAFT_CRASH_REPORT_TEST_ROLE"; + + fn folder() -> PathBuf { + let p = std::env::temp_dir().join(format!("ec-crash-report-{}-{}", run_id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&p).unwrap(); + p + } + + fn test_name(name: &str) -> String { + // Also usable as a standalone std-only rustc test while the app's build cache is busy. + let prefix = module_path!().split_once("::").map_or("", |(_, rest)| rest); + format!("{prefix}::{name}") + } + + fn helper(dir: &Path, id: &str) -> Reporter { + helper_mode(dir, id, false) + } + + fn helper_mode(dir: &Path, id: &str, interactive: bool) -> Reporter { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("reporter_process"), "--ignored", "--nocapture"]) + .env(ROLE, "reporter") + .env("EC_REPORT_DIR", dir) + .env("EC_REPORT_ID", id) + .env("EC_REPORT_INTERACTIVE", if interactive { "1" } else { "0" }) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + Reporter { pipe: child.stdin.take().unwrap(), child } + } + + fn wait_file(path: &Path) { + let until = Instant::now() + Duration::from_secs(10); + while !path.is_file() && Instant::now() < until { + thread::sleep(Duration::from_millis(20)); + } + assert!(path.is_file(), "report did not arrive: {}", path.display()); + } + + #[test] + fn bounded_protocol_rejects_eof_and_partial_or_wrong_markers() { + let mut clean = CLEAN; + assert!(clean_exit(&mut clean).unwrap()); + for mut input in [b"".as_slice(), &CLEAN[..CLEAN.len() - 1], b"clean", b"EFFECTCRAFT_CLEAN_EXIT_V0\n"] { + assert!(!clean_exit(&mut input).unwrap()); + } + assert!(!clean_exit(&mut &vec![b'x'; 100_000][..]).unwrap()); + } + + #[test] + fn report_is_bounded_and_keeps_its_session_after_log_rotation() { + let dir = folder(); + let original = dir.join("effectcraft.log"); + let id = "123-456"; + std::fs::write(&original, format!("{}\n", session_marker(id))).unwrap(); + let mut log = SessionLog::open(Some(&original), id); + std::fs::rename(&original, dir.join("effectcraft.1.log")).unwrap(); + std::fs::write(&original, "OTHER SESSION MUST NOT APPEAR").unwrap(); + let mut old = OpenOptions::new().append(true).open(dir.join("effectcraft.1.log")).unwrap(); + old.write_all(&vec![b'a'; LOG_BYTES * 2]).unwrap(); + old.write_all(b"\nGPU context retired\n").unwrap(); + let report = write_report(&dir, id, &mut log, None).unwrap(); + let text = std::fs::read_to_string(report).unwrap(); + assert!(text.len() < LOG_BYTES + 2048); + assert!(text.contains("GPU context retired")); + assert!(!text.contains("OTHER SESSION")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn wrong_session_and_missing_logs_are_reported_without_reading_other_content() { + let dir = folder(); + let path = dir.join("log"); + std::fs::write(&path, "private other session").unwrap(); + for (id, source) in [("1-1", Some(path.as_path())), ("1-2", None)] { + let report = write_report(&dir, id, &mut SessionLog::open(source, id), None).unwrap(); + let text = std::fs::read_to_string(report).unwrap(); + assert!(text.contains("Recent log unavailable")); + assert!(!text.contains("private other session")); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn session_identity_requires_the_exact_first_line() { + let dir = folder(); + let path = dir.join("log"); + for header in ["crash-report session 1-20\n", "unrelated\ncrash-report session 1-2\n"] { + std::fs::write(&path, header).unwrap(); + assert!(matches!(SessionLog::open(Some(&path), "1-2"), SessionLog::Unavailable(_))); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn report_write_failure_is_described_and_existing_report_is_not_overwritten() { + let dir = folder(); + let mut log = SessionLog::Unavailable("no log".into()); + let first = write_report(&dir, "2-1", &mut log, None).unwrap(); + let before = std::fs::read(&first).unwrap(); + let result = write_report(&dir, "2-1", &mut log, None); + assert!(result.is_err()); + assert!(description(&result).contains("could not be saved")); + assert_eq!(std::fs::read(first).unwrap(), before); + assert!(write_report(&dir, "../escape", &mut log, None).is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn normal_and_handled_shutdown_send_clean_marker_without_a_report() { + for id in ["3-1", "3-2"] { + let dir = folder(); + let r = helper(&dir, id); + r.finish().unwrap(); + // A completion file proves the helper consumed the marker, not merely that it + // hadn't yet produced a report. + wait_file(&dir.join(format!("ready-{id}"))); + assert!(!dir.join(format!("crash-{id}.txt")).exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + } + + #[test] + fn an_aborting_parent_leaves_one_diagnostic_report() { + let dir = folder(); + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"]) + .env(ROLE, "abort") + .env("EC_REPORT_DIR", &dir) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert!(!status.success()); + wait_file(&dir.join("ready-4-1")); + let text = std::fs::read_to_string(dir.join("crash-4-1.txt")).unwrap(); + assert!(text.contains("exact cause are not available")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn an_unrelated_child_does_not_keep_the_control_pipe_open() { + let dir = folder(); + let r = helper(&dir, "5-1"); + let mut unrelated = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"]) + .env(ROLE, "linger") + .stdout(Stdio::null()) + .spawn() + .unwrap(); + drop(r); + wait_file(&dir.join("ready-5-1")); + assert!(unrelated.try_wait().unwrap().is_none()); + unrelated.kill().unwrap(); + unrelated.wait().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn an_unresponsive_helper_cannot_hang_clean_shutdown() { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"]) + .env(ROLE, "linger") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let pipe = child.stdin.take().unwrap(); + let started = Instant::now(); + let result = Reporter { pipe, child }.finish(); + assert!(result.unwrap_err().to_string().contains("was terminated")); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn simultaneous_sessions_write_separate_reports() { + let dir = folder(); + let a = helper(&dir, "6-1"); + let b = helper(&dir, "6-2"); + drop(a); + drop(b); + wait_file(&dir.join("ready-6-1")); + wait_file(&dir.join("ready-6-2")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn interactive_failure_calls_dialog_once_after_saving_the_report() { + let dir = folder(); + drop(helper_mode(&dir, "7-1", true)); + wait_file(&dir.join("ready-7-1")); + let text = std::fs::read_to_string(dir.join("dialog-7-1")).unwrap(); + assert!(text.contains("crash-7-1.txt")); + assert!(text.contains("No report has been sent")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn internal_mode_rejects_invalid_arguments_without_launching_the_app() { + assert!(!maybe_run(&[], |_| panic!("no dialog"))); + assert!(!maybe_run(&[OsString::from("--version")], |_| panic!("no dialog"))); + assert!(maybe_run(&[OsString::from(FLAG)], |_| panic!("no dialog"))); + let args = [FLAG, "../escape", "", "", "1"].map(OsString::from); + assert!(maybe_run(&args, |_| panic!("no dialog"))); + let args = [FLAG, "1-1", "", "", "invalid"].map(OsString::from); + assert!(maybe_run(&args, |_| panic!("no dialog"))); + } + + #[test] + fn missing_helper_is_an_error_not_a_panic() { + assert!(spawn(Path::new("/nonexistent-effectcraft-crash-helper"), &[]).is_err()); + } + + // Roles are ignored by ordinary test runs. Explicit subprocess invocations exercise + // real EOF/abort behavior without allocating a huge image or killing the test runner. + #[test] + #[ignore] + fn reporter_process() { + assert_eq!(std::env::var(ROLE).unwrap(), "reporter"); + let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap()); + let id = std::env::var("EC_REPORT_ID").unwrap(); + let interactive = std::env::var("EC_REPORT_INTERACTIVE").unwrap(); + let args = [OsString::from(FLAG), OsString::from(&id), OsString::new(), dir.as_os_str().to_owned(), OsString::from(&interactive)]; + assert!(maybe_run(&args, |description| { + assert_eq!(interactive, "1", "non-interactive reporter must not show a dialog"); + assert!(dir.join(format!("crash-{id}.txt")).exists()); + let mut shown = OpenOptions::new().write(true).create_new(true).open(dir.join(format!("dialog-{id}"))).unwrap(); + shown.write_all(description.as_bytes()).unwrap(); + })); + std::fs::write(dir.join(format!("ready-{id}")), b"done").unwrap(); + } + + #[test] + #[ignore] + fn aborting_parent() { + assert_eq!(std::env::var(ROLE).unwrap(), "abort"); + let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap()); + let _reporter = helper(&dir, "4-1"); + std::process::abort(); + } + + #[test] + #[ignore] + fn lingering_child() { + assert_eq!(std::env::var(ROLE).unwrap(), "linger"); + thread::sleep(Duration::from_secs(30)); + } +} diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 5350addbe..ce4bcdd84 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -22,6 +22,7 @@ mod appimage; mod apple_events; mod audio_out; mod control_server; +mod crash_report; mod launch_guard; #[cfg(target_os = "macos")] mod native_menu; @@ -180,6 +181,25 @@ fn start_helpers_if_opted_in(session: &mut effectcraft_engine::Session) { /// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then /// reports startup failure through `startup_failed`. Returns the event loop's result. fn main() -> eframe::Result { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if crash_report::maybe_run(&args, |description| { + rfd::MessageDialog::new().set_level(rfd::MessageLevel::Error).set_title("EffectCraft stopped unexpectedly").set_description(description).show(); + }) { + return Ok(()); + } + let mut reporter = None; + let result = run_desktop(&mut reporter); + // run_desktop has dropped the app and handled any startup error with its existing dialog. + // An unwinding panic or abort never reaches this marker. + if let Some(reporter) = reporter + && let Err(error) = reporter.finish() + { + log::warn!("could not mark crash reporter clean: {error}"); + } + result +} + +fn run_desktop(reporter: &mut Option) -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { @@ -205,12 +225,19 @@ fn main() -> eframe::Result { // The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs // kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so // `--version` leaves no file behind; records logged until now are written to it first. + let run_id = crash_report::run_id(); if let Some(dir) = effectcraft_host::config_dir() { - match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) { + let header = format!("{}\n", crash_report::session_marker(&run_id)); + match effectcraft_engine::logging::attach_session_log_with_header(&effectcraft_engine::logging::log_dir(Some(&dir)), &header) { Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), Err(e) => log::warn!("no log file: {e}"), } } + let logs = effectcraft_engine::logging::log_dir(effectcraft_host::config_dir().as_deref()); + match crash_report::start(&run_id, effectcraft_engine::logging::session_file().as_deref(), &logs.join("Crash Reports"), control_port.is_none()) { + Ok(started) => *reporter = Some(started), + Err(error) => log::warn!("crash reporter could not start: {error}"), + } // The font menus list the installed fonts: read their names while the window opens. effectcraft_engine::text::fonts::scan_system_in_background(); // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. diff --git a/crates/engine/src/system/logging.rs b/crates/engine/src/system/logging.rs index 857a3e90d..198aa3b46 100644 --- a/crates/engine/src/system/logging.rs +++ b/crates/engine/src/system/logging.rs @@ -221,9 +221,18 @@ fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { /// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to /// a fresh `effectcraft.log` there, first the ones logged so far; returns its path. pub fn attach_session_log(dir: &Path) -> Result { + attach_session_log_with_header(dir, "") +} + +/// Open a session log with a bounded, unfiltered identity header before buffered records. +/// A crash reporter can then recognize this run even with logging disabled or verbose startup. +pub fn attach_session_log_with_header(dir: &Path, header: &str) -> Result { + if header.len() > 1024 { + return Err("session log header exceeds 1024 bytes".into()); + } let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let mut sink = SESSION.lock().unwrap_or_else(PoisonError::into_inner); - sink.attach(file); + sink.attach_with_header(file, header); sink.path = Some(path.clone()); Ok(path) } @@ -264,8 +273,14 @@ impl Sink { } /// Start writing to `file`, first the lines kept so far. + #[cfg(test)] fn attach(&mut self, file: File) { + self.attach_with_header(file, ""); + } + + fn attach_with_header(&mut self, file: File, header: &str) { self.file = Some(file); + self.append(header); for line in std::mem::take(&mut self.pending) { self.append(&line); } @@ -333,6 +348,22 @@ mod tests { use super::*; use std::sync::atomic::AtomicUsize; + #[test] + fn identity_header_precedes_buffered_records_independent_of_log_filter() { + let dir = temp_dir("identity-header"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("session"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write(&"earlier startup record".repeat(1000)); + sink.attach_with_header(File::create(&path).unwrap(), "crash-report session 1-2\n"); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.starts_with("crash-report session 1-2\n")); + assert!(text.contains("earlier startup record")); + assert!(attach_session_log_with_header(&dir, &"x".repeat(1025)).is_err()); + drop(sink); + std::fs::remove_dir_all(dir).unwrap(); + } + /// `RUST_LOG` picks what start-up prints to stderr (#234). #[test] fn rust_log_filters_by_level_and_target() { diff --git a/docs/testing.md b/docs/testing.md index e84a9a23b..6c3d91626 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -112,3 +112,24 @@ and line instead of being discarded. `--fail-over PCT` requires the same run key successful latest runs, and at least one comparable resource metric per run. Failed latest runs, missing coverage, empty results and wall-time-only measurements cannot pass the gate. Omit `--fail-over` for an exploratory partial comparison; failed latest runs remain visible. + +## Unexpected desktop exit diagnostics + +The desktop starts a separate, lightweight reporter before loading fonts, projects or the GPU. +A normal exit (including a handled startup error) sends a clean marker only after desktop +teardown. An abort or other unexpected process termination closes the pipe without that marker. +The helper saves a local report under the session log folder's `Crash Reports` directory and +shows a native error dialog. Control-server launches save the report without a modal dialog. +Reports contain version/platform and at most the last 64 KiB of the matching session's log. +Logging filters and the session file size cap can omit later events from that tail. +They are never uploaded automatically. Review logs for private project paths before sharing. + +Run `cargo test -p effectcraft crash_report::tests` for bounded protocol, normal shutdown, +actual subprocess abort, unrelated child handles, concurrent sessions, missing log/helper, +log rotation and report-write failure coverage. The three ignored tests are subprocess entry +points launched by these tests, not manual tests. + +This is diagnosis, not memory recovery: a pipe closing does not prove an out-of-memory failure +or provide the process's exit code. System-wide exhaustion, killing the whole process tree, +or an unavailable native dialog service can also prevent the helper from reporting. Existing +auto-save recovery remains responsible for restoring previously saved work. From 59c41fee0a80c462a2443f9214ddf1035aa67b15 Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 20:21:23 +0100 Subject: [PATCH 014/227] #3028: keep dedicated preview lanes waiting on GPU admission --- crates/ui-egui/src/frames.rs | 26 ++++++++-- crates/ui-egui/src/frames/tests.rs | 78 ++++++++++++++++++++++++++++++ docs/architecture.md | 3 +- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 0efcab351..32f0dd161 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -101,6 +101,12 @@ impl GpuRenderSlots { /// A Rayon helper may be executing a nested frame while an admitted render waits for /// its work. Blocking that helper on a permit would deadlock the render (#3028). fn for_render(&self, max: usize) -> Option> { + // Dedicated preview pools have one thread, protected from nested drains. They cannot + // be helpers of another admitted frame, so waiting preserves GPU preview throughput. + #[cfg(not(target_arch = "wasm32"))] + if DRAINING_LANE.get() { + return Some(self.acquire(max)); + } if rayon::current_thread_index().is_some() { self.try_acquire(max) } else { Some(self.acquire(max)) } } @@ -1686,15 +1692,29 @@ struct Worker { thread_local! { /// This pool thread is inside [`Worker::drain`] (rendering a frame). static DRAINING: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Only the single thread of a dedicated preview lane may wait for GPU admission. + static DRAINING_LANE: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// Clears [`DRAINING`] when the drain ends, even by a panic. #[cfg(not(target_arch = "wasm32"))] struct Draining; +#[cfg(not(target_arch = "wasm32"))] +impl Draining { + fn enter(lane: Option) -> Option { + if DRAINING.replace(true) { + return None; + } + DRAINING_LANE.set(lane.is_some()); + Some(Self) + } +} + #[cfg(not(target_arch = "wasm32"))] impl Drop for Draining { fn drop(&mut self) { + DRAINING_LANE.set(false); DRAINING.set(false); } } @@ -1723,11 +1743,7 @@ impl Worker { /// `lane`: the lane this runs on (see [`Queue::idle_lanes`]), `None` on the pool. #[cfg(not(target_arch = "wasm32"))] fn drain(&self, lane: Option) { - if DRAINING.get() { - return; - } - DRAINING.set(true); - let _reset = Draining; + let Some(_reset) = Draining::enter(lane) else { return }; while self.run_next(lane) {} } diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index cb3e41f21..0a148b83c 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -3237,6 +3237,42 @@ fn gpu_worker_routes_admit_display_readback_and_auto_effects() { assert!(worker.gpu_ready()); assert!(gpu.context().check_health().is_ok()); + // The real dedicated-lane drain waits, then caches a GPU frame instead of silently + // switching a healthy preview to CPU rendering while the other lanes are busy. + frames.queue.lock().unwrap().jobs.push(Job { + key: job.key, + src: job.src.clone(), + comp, + t: job.t, + opts: RenderOpts { backend: Backend::Gpu, ..job.opts }, + urgent: false, + background: true, + seq: 1, + cancel: Arc::default(), + }); + let first = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS); + let second = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS); + let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let (started, ready) = std::sync::mpsc::channel(); + let (tx, rx) = std::sync::mpsc::channel(); + let (early, finished) = std::thread::scope(|scope| { + scope.spawn(|| { + pool.install(|| { + started.send(()).unwrap(); + worker.drain(Some(0)); + tx.send(()).unwrap(); + }) + }); + ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + let early = rx.recv_timeout(std::time::Duration::from_millis(100)); + drop((first, second)); + let finished = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2))); + (early, finished) + }); + assert!(early.is_err(), "preview lane must wait for an available GPU permit"); + assert_eq!(finished, Ok(())); + assert!(frames.cache.lock().unwrap().map.get(&job.key).unwrap().img.is_gpu()); + // A retirement found inside the scope releases the permit before the fresh CPU retry. gpu.context().device_failure_notifier()("synthetic worker retirement".into()); let before = admissions(); @@ -3288,3 +3324,45 @@ fn gpu_render_slots_never_block_rayon_helpers() { assert_eq!(declined, Ok(true), "Rayon helpers must not wait on GPU permits"); assert_eq!(*slots.busy.lock().unwrap(), 0); } + +/// Dedicated preview lanes have one protected thread and can wait without creating the +/// shared-pool helper dependency cycle. They must keep rendering on a healthy GPU. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_preview_lane_waits_for_admission_and_resets_its_marker() { + let slots = GpuRenderSlots::new(); + let first = slots.acquire(2); + let second = slots.acquire(2); + let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let (started, ready) = std::sync::mpsc::channel(); + let (tx, rx) = std::sync::mpsc::channel(); + let (early, admitted) = std::thread::scope(|scope| { + scope.spawn(|| { + pool.install(|| { + let _drain = Draining::enter(Some(0)).unwrap(); + assert!(Draining::enter(None).is_none(), "a lane cannot start a nested drain"); + assert!(DRAINING_LANE.get(), "nested rejection must not clear the lane marker"); + started.send(()).unwrap(); + tx.send(slots.for_render(2).is_some()).unwrap(); + }); + }); + ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + let early = rx.recv_timeout(std::time::Duration::from_millis(100)); + drop((first, second)); + let admitted = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2))); + (early, admitted) + }); + assert!(early.is_err(), "busy dedicated lane must wait instead of falling back to CPU"); + assert_eq!(admitted, Ok(true)); + pool.install(|| { + assert!(!DRAINING.get()); + assert!(!DRAINING_LANE.get()); + let panic = std::panic::catch_unwind(|| { + let _drain = Draining::enter(Some(0)).unwrap(); + panic!("synthetic lane failure"); + }); + assert!(panic.is_err()); + assert!(!DRAINING.get()); + assert!(!DRAINING_LANE.get()); + }); +} diff --git a/docs/architecture.md b/docs/architecture.md index 32949351b..195634eb1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -414,7 +414,8 @@ tests, device errors are logged rather than fatal. A viewer frame that runs out (`Gpu::within_memory`) renders on the CPU, the uploaded layers and pooled textures are freed, and the RAM preview keeps half as many GPU frames from then on. However many frame threads the viewer runs, at most two of them start GPU render attempts at once. Shared-pool workers use the CPU -when both slots are busy rather than blocking Rayon helpers; other render threads wait, so +when both slots are busy rather than blocking Rayon helpers; dedicated single-thread preview +lanes wait for admission and remain on the GPU, so working-texture pressure is bounded by concurrent attempts rather than the thread count (#3028). A single large frame or retained frames can still exceed the available video memory. The same guard catches a frame's validation errors (an invalid texture, often left by a failed allocation): that frame renders on From 47c9b4a99b01c53f76f2537f5d3ec656b33fd896 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:58:45 +0100 Subject: [PATCH 015/227] #3357: finish crash reporter after accepted macOS teardown --- apps/effectcraft/src/crash_report.rs | 80 +++++++++++++++++++++++++++- apps/effectcraft/src/main.rs | 42 +++++++++------ docs/testing.md | 5 +- 3 files changed, 109 insertions(+), 18 deletions(-) diff --git a/apps/effectcraft/src/crash_report.rs b/apps/effectcraft/src/crash_report.rs index 1dd25c571..837161811 100644 --- a/apps/effectcraft/src/crash_report.rs +++ b/apps/effectcraft/src/crash_report.rs @@ -60,6 +60,32 @@ fn finish_child(child: &mut Child, grace: Duration) -> io::Result<()> { Err(io::Error::other("crash reporter termination could not be confirmed")) } +/// Shared ownership lets macOS finish in on_exit, while main retains the startup-error path. +#[derive(Clone, Default)] +pub struct Shutdown(std::sync::Arc>>); + +impl Shutdown { + /// Install the one reporter created for this desktop launch. + pub fn install(&self, reporter: Reporter) -> io::Result<()> { + let mut slot = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?; + *slot = Some(reporter); + Ok(()) + } + + /// Consume the reporter exactly once, after the applicable platform teardown boundary. + pub fn finish(&self) -> io::Result<()> { + let reporter = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?.take(); + reporter.map_or(Ok(()), Reporter::finish) + } +} + +/// A panic during teardown must leave the reporter armed. macOS terminates after this hook +/// without returning to main; other platforms pass None and finish after run_native returns. +pub fn after_teardown(reporter: Option<&Shutdown>, teardown: impl FnOnce()) -> io::Result<()> { + teardown(); + reporter.map_or(Ok(()), Shutdown::finish) +} + /// Each process/run has its own report, including simultaneous launches. pub fn run_id() -> String { let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(); @@ -363,6 +389,51 @@ mod tests { } } + #[test] + fn accepted_native_close_finishes_before_process_termination() { + let dir = folder(); + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"]) + .env(ROLE, "native-close") + .env("EC_REPORT_DIR", &dir) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert!(status.success()); + wait_file(&dir.join("ready-8-1")); + assert!(!dir.join("crash-8-1.txt").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn a_teardown_panic_does_not_mark_the_reporter_clean() { + let dir = folder(); + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "8-2")).unwrap(); + let result = std::panic::catch_unwind(|| { + let _ = after_teardown(Some(&reporter), || panic!("simulated teardown failure")); + }); + assert!(result.is_err()); + drop(reporter); + wait_file(&dir.join("ready-8-2")); + assert!(dir.join("crash-8-2.txt").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn platforms_returning_to_main_keep_the_reporter_armed_until_then() { + let dir = folder(); + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "8-3")).unwrap(); + after_teardown(None, || {}).unwrap(); + assert!(reporter.0.lock().unwrap().is_some()); + reporter.clone().finish().unwrap(); + reporter.finish().unwrap(); + wait_file(&dir.join("ready-8-3")); + assert!(!dir.join("crash-8-3.txt").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn an_aborting_parent_leaves_one_diagnostic_report() { let dir = folder(); @@ -475,8 +546,15 @@ mod tests { #[test] #[ignore] fn aborting_parent() { - assert_eq!(std::env::var(ROLE).unwrap(), "abort"); + let role = std::env::var(ROLE).unwrap(); let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap()); + if role == "native-close" { + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "8-1")).unwrap(); + after_teardown(Some(&reporter.clone()), || {}).unwrap(); + std::process::exit(0); // Models macOS not returning from its native event loop. + } + assert_eq!(role, "abort"); let _reporter = helper(&dir, "4-1"); std::process::abort(); } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index ce4bcdd84..3d9455c81 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -187,19 +187,17 @@ fn main() -> eframe::Result { }) { return Ok(()); } - let mut reporter = None; - let result = run_desktop(&mut reporter); + let reporter = crash_report::Shutdown::default(); + let result = run_desktop(&reporter); // run_desktop has dropped the app and handled any startup error with its existing dialog. // An unwinding panic or abort never reaches this marker. - if let Some(reporter) = reporter - && let Err(error) = reporter.finish() - { + if let Err(error) = reporter.finish() { log::warn!("could not mark crash reporter clean: {error}"); } result } -fn run_desktop(reporter: &mut Option) -> eframe::Result { +fn run_desktop(reporter: &crash_report::Shutdown) -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { @@ -235,7 +233,11 @@ fn run_desktop(reporter: &mut Option) -> eframe::Result } let logs = effectcraft_engine::logging::log_dir(effectcraft_host::config_dir().as_deref()); match crash_report::start(&run_id, effectcraft_engine::logging::session_file().as_deref(), &logs.join("Crash Reports"), control_port.is_none()) { - Ok(started) => *reporter = Some(started), + Ok(started) => { + if let Err(error) = reporter.install(started) { + log::warn!("crash reporter could not be retained: {error}"); + } + } Err(error) => log::warn!("crash reporter could not start: {error}"), } // The font menus list the installed fonts: read their names while the window opens. @@ -272,6 +274,7 @@ fn run_desktop(reporter: &mut Option) -> eframe::Result let apple_events = apple_events::AppleEvents::install(); #[cfg(target_os = "macos")] let apple_events = &apple_events; + let desktop_reporter = reporter.clone(); let result = eframe::run_native( "EffectCraft", options, @@ -425,6 +428,7 @@ fn run_desktop(reporter: &mut Option) -> eframe::Result window: window::Tracker::new(opened, saved_window, control_port.is_none()), launch, drawn: false, + reporter: desktop_reporter, })) }), ); @@ -518,6 +522,7 @@ struct Desktop { launch: launch_guard::Launch, /// The first frame was drawn (logged once). drawn: bool, + reporter: crash_report::Shutdown, } impl eframe::App for Desktop { @@ -552,15 +557,20 @@ impl eframe::App for Desktop { } fn on_exit(&mut self) { - // On macOS the app quits through `-[NSApplication terminate:]`, and the process ends - // before `eframe::run_native` returns (#341). - log::info!("the window is closing"); - self.launch.exited(); - self.window.save(self.app.session.config.as_deref()); - self.app.on_exit(); - effectcraft_host::stop_helpers(); - // Scripts in Scripts/Shutdown run with the project closed. - let _ = self.app.session.execute("file.runShutdownScripts", json!({})); + // macOS terminates after this hook without returning from run_native (#341). + // Other platforms keep the reporter armed until main sees complete event-loop teardown. + let reporter = cfg!(target_os = "macos").then(|| self.reporter.clone()); + if let Err(error) = crash_report::after_teardown(reporter.as_ref(), || { + log::info!("the window is closing"); + self.launch.exited(); + self.window.save(self.app.session.config.as_deref()); + self.app.on_exit(); + effectcraft_host::stop_helpers(); + // Scripts in Scripts/Shutdown run with the project closed. + let _ = self.app.session.execute("file.runShutdownScripts", json!({})); + }) { + log::warn!("could not mark crash reporter clean after teardown: {error}"); + } } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { diff --git a/docs/testing.md b/docs/testing.md index 6c3d91626..54152b9dc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -117,7 +117,10 @@ missing coverage, empty results and wall-time-only measurements cannot pass the The desktop starts a separate, lightweight reporter before loading fonts, projects or the GPU. A normal exit (including a handled startup error) sends a clean marker only after desktop -teardown. An abort or other unexpected process termination closes the pipe without that marker. +teardown. macOS finishes at the end of its successful exit hook because its native event loop +terminates without returning to main; other platforms finish after the event loop returns. +A teardown panic leaves reporting armed. An abort or other unexpected process termination +closes the pipe without that marker. The helper saves a local report under the session log folder's `Crash Reports` directory and shows a native error dialog. Control-server launches save the report without a modal dialog. Reports contain version/platform and at most the last 64 KiB of the matching session's log. From 8d9d9c446b994f39d56e7fffa53f34032acac52d Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:04:08 +0100 Subject: [PATCH 016/227] #3357: preserve concurrent session log identity --- apps/effectcraft/src/crash_report.rs | 29 +++++++++++++++++++++++++--- crates/engine/src/system/logging.rs | 22 ++++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/effectcraft/src/crash_report.rs b/apps/effectcraft/src/crash_report.rs index 837161811..32306aa5c 100644 --- a/apps/effectcraft/src/crash_report.rs +++ b/apps/effectcraft/src/crash_report.rs @@ -182,7 +182,7 @@ fn clean_exit(input: &mut impl Read) -> io::Result { } enum SessionLog { - Captured(File), + Captured { file: File, marker: String }, Unavailable(String), } @@ -201,7 +201,7 @@ impl SessionLog { Ok(file) })(); match opened { - Ok(file) => Self::Captured(file), + Ok(file) => Self::Captured { file, marker: format!("{}\n", session_marker(id)) }, Err(e) => Self::Unavailable(e.to_string()), } } @@ -209,7 +209,15 @@ impl SessionLog { fn write_tail(&mut self, report: &mut File) -> io::Result<()> { match self { Self::Unavailable(reason) => writeln!(report, "Recent log unavailable: {reason}"), - Self::Captured(file) => { + Self::Captured { file, marker } => { + // An older concurrent launcher may have truncated the captured file. + // Recheck its identity before copying any content into this report. + file.seek(SeekFrom::Start(0))?; + let mut header = [0u8; HEADER_BYTES]; + let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?; + if file.read_exact(prefix).is_err() || prefix != marker.as_bytes() { + return writeln!(report, "Recent log unavailable: session identity changed"); + } let size = file.metadata()?.len(); file.seek(SeekFrom::Start(size.saturating_sub(LOG_BYTES as u64)))?; writeln!(report, "\nRecorded session log tail (last {LOG_BYTES} bytes at most; logging filters/size caps may omit later events):")?; @@ -350,6 +358,21 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn captured_log_rejects_a_session_overwritten_after_open() { + let dir = folder(); + let path = dir.join("log"); + std::fs::write(&path, "crash-report session 1-2\noriginal session\n").unwrap(); + let mut log = SessionLog::open(Some(&path), "1-2"); + std::fs::write(&path, "crash-report session 3-4\nprivate other session\n").unwrap(); + let report = write_report(&dir, "1-2", &mut log, None).unwrap(); + let text = std::fs::read_to_string(report).unwrap(); + assert!(text.contains("Recent log unavailable")); + assert!(!text.contains("private other session")); + assert!(!text.contains("session 3-4")); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn session_identity_requires_the_exact_first_line() { let dir = folder(); diff --git a/crates/engine/src/system/logging.rs b/crates/engine/src/system/logging.rs index 198aa3b46..2bb3518a1 100644 --- a/crates/engine/src/system/logging.rs +++ b/crates/engine/src/system/logging.rs @@ -214,10 +214,16 @@ fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { } } let path = numbered(dir, 0); - let file = File::create(&path)?; + let file = create_session_file(&path)?; Ok((path, file)) } +// Another process may create the current log after our rotation. Never truncate its +// file: its writer and crash reporter may already hold the same inode open. +fn create_session_file(path: &Path) -> std::io::Result { + std::fs::OpenOptions::new().write(true).create_new(true).open(path) +} + /// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to /// a fresh `effectcraft.log` there, first the ones logged so far; returns its path. pub fn attach_session_log(dir: &Path) -> Result { @@ -428,6 +434,20 @@ mod tests { assert_eq!(timestamp(u64::MAX), "584556019-04-03T14:25:51.615Z"); } + #[test] + fn concurrent_session_creation_does_not_truncate_the_winner() { + let dir = temp_dir("concurrent-create"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(SESSION_LOG); + let mut winner = create_session_file(&path).unwrap(); + winner.write_all(b"crash-report session 1-2\nwinner content\n").unwrap(); + assert_eq!(create_session_file(&path).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists); + winner.write_all(b"still the same session\n").unwrap(); + assert_eq!(read(&path), "crash-report session 1-2\nwinner content\nstill the same session\n"); + drop(winner); + std::fs::remove_dir_all(dir).unwrap(); + } + /// Each launch keeps the previous runs' logs, so the log of a run that crashed survives. #[test] fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { From 2d6d08f29fe440275eaa264d3d6a5d59049b47e8 Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 22:54:40 +0100 Subject: [PATCH 017/227] #1388: retain whole text raster for animator blur --- crates/render/src/pipeline.rs | 2 +- crates/render/src/tests/raster_clip.rs | 26 ++++++++++++++++++++++++++ crates/render/src/text.rs | 10 ++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 9800b188d..6265cb5f7 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -488,7 +488,7 @@ impl<'a> Renderer<'a> { || styles::active(ctx, layer) || ctx.comp.layers.iter().any(|l| matches!(l.source, LayerSource::Light { .. })) || self.is_adv3d(ctx) - || (matches!(layer.source, LayerSource::Text) && text::per_char_3d(ctx, layer)) + || (matches!(layer.source, LayerSource::Text) && (text::per_char_3d(ctx, layer) || text::animator_blur(ctx, layer))) { return None; } diff --git a/crates/render/src/tests/raster_clip.rs b/crates/render/src/tests/raster_clip.rs index f7227f634..c85176fca 100644 --- a/crates/render/src/tests/raster_clip.rs +++ b/crates/render/src/tests/raster_clip.rs @@ -332,3 +332,29 @@ fn fine_clip_cells_have_distinct_cache_keys() { assert_ne!(key(a), key(kurbo::Rect::ZERO)); assert_ne!(key(b), key(kurbo::Rect::ZERO)); } + +/// Text animator Blur reads beyond the fixed anti-aliasing padding of a clipped window. +#[test] +fn animated_text_blur_keeps_offscreen_glyph_pixels_while_panning() { + let (mut p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let props = build::text_anim_props(&mut ids, "blur", false); + let mut animator = build::text_animator(&mut ids, "Blur", props); + animator.prop_mut("properties/blur").unwrap().value = Value::Vec2([40.0, 40.0]); + p.next_id = next; + p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.push(animator.into()); + let opts = RenderOpts { scale: 0.125, ..Default::default() }; + let cache = crate::LayerCache::default(); + for dx in [-10.0, 0.0, 10.0] { + set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/position", Value::Vec3([W as f64 / 2.0 + dx, H as f64 / 2.0, -zoom() + 40.0])); + let mut r = Renderer::new(&p, &NoFootage, opts); + r.cache = Some(&cache); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + let layer = &p.comp(cid).unwrap().layers[0]; + assert!(r.raster_clip(&ctx, layer).is_none(), "animator blur needs off-window pixels"); + let mut whole = Renderer::new(&p, &NoFootage, opts); + whole.whole_raster = true; + assert_eq!(r.comp_frame(cid, Tick::ZERO).data, whole.comp_frame(cid, Tick::ZERO).data); + } +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 3a2110e0d..1e95bbc11 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -509,6 +509,16 @@ pub fn per_char_3d(ctx: &EvalCtx, layer: &Layer) -> bool { layer.is_3d() && layer.props.sub("text").is_some_and(|t| ctx.b(layer, t, "perChar3d")) } +/// Animator blur can read glyph pixels beyond a viewport raster window. Conservatively +/// keep the whole raster even when the current selector happens to select no characters. +pub(crate) fn animator_blur(ctx: &EvalCtx, layer: &Layer) -> bool { + layer.props.group("text/animators").is_some_and(|anims| { + anims.groups().filter(|anim| anim.enabled).any(|anim| { + anim.sub("properties").and_then(|props| props.get("blur")).is_some_and(|blur| ctx.value(layer, blur).as_vec2().iter().any(|value| *value != 0.0)) + }) + }) +} + /// One drawn character. #[derive(Clone, Debug)] pub struct PlacedGlyph { From 360e21f539658eb4545733f396f4cb190c45f3ae Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 23:36:18 +0100 Subject: [PATCH 018/227] Release: EffectCraft v0.23.1 --- Cargo.lock | 78 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f45bf8414..8a83ae444 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.21.0" +version = "0.23.1" dependencies = [ "cpal", "craft-update", @@ -1446,7 +1446,7 @@ dependencies = [ [[package]] name = "effectcraft-aep" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1460,7 +1460,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1474,14 +1474,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.21.0" +version = "0.23.1" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.21.0" +version = "0.23.1" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1500,7 +1500,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.21.0" +version = "0.23.1" dependencies = [ "proptest", "serde", @@ -1508,7 +1508,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.21.0" +version = "0.23.1" dependencies = [ "chrono", "effectcraft-color", @@ -1526,7 +1526,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.21.0" +version = "0.23.1" dependencies = [ "chrono", "craft-docpath", @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1596,7 +1596,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.21.0" +version = "0.23.1" dependencies = [ "boa_engine", "effectcraft-color", @@ -1613,7 +1613,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.21.0" +version = "0.23.1" dependencies = [ "proptest", "serde", @@ -1621,7 +1621,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.21.0" +version = "0.23.1" dependencies = [ "bytemuck", "effectcraft-color", @@ -1644,7 +1644,7 @@ dependencies = [ [[package]] name = "effectcraft-helper-host" -version = "0.21.0" +version = "0.23.1" dependencies = [ "bytemuck", "effectcraft-effects", @@ -1655,14 +1655,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.21.0" +version = "0.23.1" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1689,7 +1689,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1708,7 +1708,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1720,7 +1720,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.21.0" +version = "0.23.1" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1738,7 +1738,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.21.0" +version = "0.23.1" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1771,7 +1771,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-geom", "image", @@ -1784,14 +1784,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.21.0" +version = "0.23.1" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1803,7 +1803,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.21.0" +version = "0.23.1" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1819,7 +1819,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1831,7 +1831,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.21.0" +version = "0.23.1" dependencies = [ "craft-ecproj-format", "effectcraft-color", @@ -1845,14 +1845,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.21.0" +version = "0.23.1" dependencies = [ "miniz_oxide 0.9.1", ] [[package]] name = "effectcraft-raster" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1862,7 +1862,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1885,7 +1885,7 @@ dependencies = [ [[package]] name = "effectcraft-sam2" -version = "0.21.0" +version = "0.23.1" dependencies = [ "bytemuck", "candle-core", @@ -1900,7 +1900,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.21.0" +version = "0.23.1" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1918,7 +1918,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.21.0" +version = "0.23.1" dependencies = [ "miniz_oxide 0.9.1", "ndarray", @@ -1927,7 +1927,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1939,7 +1939,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1951,7 +1951,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.21.0" +version = "0.23.1" dependencies = [ "proptest", "serde", @@ -1959,7 +1959,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1971,7 +1971,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.21.0" +version = "0.23.1" dependencies = [ "bytemuck", "chrono", @@ -2012,11 +2012,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.21.0" +version = "0.23.1" [[package]] name = "effectcraft-web" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -7711,7 +7711,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.21.0" +version = "0.23.1" dependencies = [ "chrono", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index b85c3c6b8..aa1df1a6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.21.0" +version = "0.23.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 9e5810828810ade19000630328a40a453b9ebcd7 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 08:49:26 +0530 Subject: [PATCH 019/227] #3103: import dropped and picked After Effects projects; name the project imports alike Dropping an .aep or .aepx on the window, or picking one in File > Import > File... or Multiple Files... (also from the Home screen and the Project panel), imports it as File > Import > Adobe After Effects Project... does, instead of failing as unsupported footage: the losses prompt first when parts cannot come over, its items in a folder named after the file, its first composition open. - Several projects are asked about one at a time: the others wait in the prompt's queue, and Cancel skips only the one it asks about. A prompt another dialog replaced (Save changes?) strands nothing: its projects are tried with the next import or queued behind the next prompt. - The other files of a drop or pick import as footage, into the Project panel only when a project is in the drop. While the prompt shows, a Photoshop file or image sequence imports with its defaults instead of replacing the prompt, an Interpret Footage question leaves a note instead, and a dropped .ecproj is skipped with a note. While Save changes? waits for a dropped .ecproj, the rest of the drop is skipped with a note instead of landing in the replaced project. - Errors of every picked project reach the status line on both the blocking and the non-blocking file dialog. - The Import File dialogs list .aep, .aepx, .ecproj and .ecprojx; Replace Footage offers footage only. - For scripts and agents, file.import hands After Effects projects to file.importAep; a refusal (new EngineError::Refused) names the menu entry and `file.importAep` with `allowLosses`. - File > Import / Export name the entry Adobe After Effects Project..., as Adobe Premiere Pro Project...; File > Import keeps After Effects' order with the project imports together and the formats only EffectCraft imports after a separator. All 13 menu catalogs and the losses dialog title are updated. - Docs: aep-import, aep-export, footage, file-format-parity, agents. Supersedes #3312. --- crates/engine/src/cmd.rs | 1 + crates/engine/src/cmd/aep.rs | 25 ++-- crates/engine/src/cmd/file.rs | 18 ++- crates/engine/src/error.rs | 4 + crates/engine/src/menus.rs | 15 ++- crates/engine/src/tests/aep_export.rs | 4 +- crates/engine/src/tests/aep_import.rs | 88 ++++++++++++- crates/ui-egui/src/i18n.rs | 58 +++++---- crates/ui-egui/src/i18n/ui.rs | 38 ++++-- crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/menus/frontend.rs | 101 +++++++++++++-- crates/ui-egui/src/menus/invoke.rs | 6 + crates/ui-egui/src/menus/tests.rs | 123 ++++++++++++++++++ crates/ui-egui/src/panels/aep_losses.rs | 101 ++++++++++++++- crates/ui-egui/src/panels/home.rs | 2 +- crates/ui-egui/src/prefs_live.rs | 49 ++++++- crates/ui-egui/tests/it/ui_drag_drop.rs | 162 ++++++++++++++++++++++++ docs/aep-export.md | 2 +- docs/aep-import.md | 13 +- docs/agents.md | 4 + docs/file-format-parity.md | 2 +- docs/footage.md | 3 + 22 files changed, 733 insertions(+), 90 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 076be70ec..40603e8cd 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -161,6 +161,7 @@ mod params; mod schema; use super::commands; +pub use aep::is_aep_path; pub use enabled::always; pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run}; pub use params::match_path_of; diff --git a/crates/engine/src/cmd/aep.rs b/crates/engine/src/cmd/aep.rs index 575171f89..3445a478b 100644 --- a/crates/engine/src/cmd/aep.rs +++ b/crates/engine/src/cmd/aep.rs @@ -1,5 +1,5 @@ -//! File ▸ Import ▸ After Effects Project… (`effectcraft_aep::import`) and File ▸ Export ▸ After Effects -//! Project… (`effectcraft_aep::export`). +//! File ▸ Import ▸ Adobe After Effects Project… (`effectcraft_aep::import`) and File ▸ Export ▸ +//! Adobe After Effects Project… (`effectcraft_aep::export`). //! //! Adds the items of an `.aep` (or its XML form, `.aepx`) to the project in a folder named after //! the file (`docs/aep-import.md` lists what arrives). What the importer cannot bring over (effects @@ -16,6 +16,12 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, b_p, bad, str_p}; use crate::{EngineError, Result, Session, cmd}; +/// Whether `path` is an After Effects project (`.aep`, or its XML form `.aepx`): imported as a +/// project (`file.importAep`), not as footage. +pub fn is_aep_path(path: &str) -> bool { + std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("aep") || e.eq_ignore_ascii_case("aepx")) +} + fn import_aep(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.importAep", "missing `path`"))?.to_string(); let allow_losses = b_p(p, "allowLosses").unwrap_or(false); @@ -40,11 +46,12 @@ fn import_aep(s: &mut Session, p: &Value) -> Result { return Ok(json!({"dryRun": true, "report": report})); } let importer = s.importer.clone(); - let res = s.edit("Import After Effects Project", None, |proj, st| { + let res = s.edit("Import Adobe After Effects Project", None, |proj, st| { let r = effectcraft_aep::import::import(proj, &bytes, &options).map_err(|e| match e { - AepImportError::Refused(_) => { - EngineError::Other(format!("{e} (nothing was imported: pass `allowLosses` to import anyway, or `dryRun` for the full report)")) - } + AepImportError::Refused(_) => EngineError::Refused { + losses: e.to_string(), + advice: "nothing was imported: pass `allowLosses` to import anyway, or `dryRun` for the full report", + }, other => EngineError::Other(format!("cannot import {path}: {other}")), })?; if apply { @@ -112,7 +119,7 @@ fn export_aep(s: &mut Session, p: &Value) -> Result { }; let res = effectcraft_aep::export::export_with(&s.project, &ExportOptions { allow_losses }, &folder_time).map_err(|e| match e { AepExportError::Refused(_) => { - EngineError::Other(format!("{e} (nothing was written: pass `allowLosses` to export anyway, or `dryRun` for the full report)")) + EngineError::Refused { losses: e.to_string(), advice: "nothing was written: pass `allowLosses` to export anyway, or `dryRun` for the full report" } } other => EngineError::Other(format!("cannot export {path}: {other}")), })?; @@ -130,7 +137,7 @@ pub fn specs() -> Vec { vec![ cmd!( "file.importAep", - "After Effects Project...", + "Adobe After Effects Project...", ["File", "Import"], None, "{path (.aep or .aepx), allowLosses?: bool (import although parts cannot be imported), dryRun?: bool (only return the report), applySettings?: bool (take over the file's project settings; default: when the project has no compositions)}", @@ -139,7 +146,7 @@ pub fn specs() -> Vec { ), cmd!( "file.exportAep", - "After Effects Project...", + "Adobe After Effects Project...", ["File", "Export"], None, "{path (.aep), allowLosses?: bool (export although parts cannot be exported), dryRun?: bool (only return the report)}", diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8129759c5..890a9def1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -404,8 +404,11 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { let mut comps = vec![]; let mut items = vec![]; let mut errors = vec![]; - // EffectCraft projects import as projects, as After Effects imports an .aep. - let (project_paths, mut paths): (Vec, Vec) = paths.into_iter().partition(|x| super::import_project::is_project_path(x)); + // EffectCraft projects import as projects, as After Effects imports an .aep; so do After + // Effects projects, refused when parts of them cannot come over. The app asks about those + // before it gets here (`panels::aep_losses`); a script or agent gets the refusal. + let (project_paths, paths): (Vec, Vec) = paths.into_iter().partition(|x| super::import_project::is_project_path(x)); + let (aep_paths, mut paths): (Vec, Vec) = paths.into_iter().partition(|x| super::is_aep_path(x)); let mut projects = vec![]; for path in project_paths { match s.execute("file.importProject", json!({"path": path})) { @@ -413,6 +416,17 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { Err(e) => errors.push(format!("{path}: {e}")), } } + for path in aep_paths { + match s.execute("file.importAep", json!({"path": path})) { + Ok(r) => projects.push(r), + // `file.import` has no `allowLosses`: point at the entry that lists the losses, and at + // the command that takes the consent. + Err(EngineError::Refused { losses, .. }) => errors.push(format!( + "{path}: {losses}; File ▸ Import ▸ Adobe After Effects Project… lists them and can import the rest (`file.importAep` with `allowLosses`)" + )), + Err(e) => errors.push(format!("{path}: {e}")), + } + } if let Some(retain) = retain { let mut rest = vec![]; for path in paths { diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index 75e9e4467..e1f5bfa3c 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -15,6 +15,10 @@ pub enum EngineError { Project(#[from] effectcraft_project::ProjectError), #[error("{0}")] Other(String), + /// Refused because parts would be lost in a conversion; nothing changed. `losses` names them, + /// `advice` how to go on (`file.importAep` / `file.exportAep` without `allowLosses`). + #[error("{losses} ({advice})")] + Refused { losses: String, advice: &'static str }, } pub type Result = std::result::Result; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 70acbd1fb..f0ef8bd57 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -23,6 +23,12 @@ //! Libraries, Bridge, Media Encoder, Behance, Creative Cloud…) are intentionally absent; the //! Essential Graphics workspace uses the Properties panel and the Essential Graphics panel, whose //! templates use EffectCraft's own open `.ectemplate` format. +//! +//! Where EffectCraft has entries After Effects lacks, After Effects' entries keep their order and +//! the others get a fixed place. File ▸ Import: the project imports sit together where After +//! Effects has Adobe Premiere Pro Project…, in alphabetical order and named in full (Adobe After +//! Effects Project…, Adobe Premiere Pro Project…, EffectCraft Project…); the formats only +//! EffectCraft imports follow a separator at the end, in alphabetical order (#3103). use std::sync::OnceLock; @@ -577,14 +583,15 @@ File Import File... | file.import Multiple Files... | file.importMultiple + Adobe After Effects Project... | file.importAep Adobe Premiere Pro Project... | file.importTimeline EffectCraft Project... | file.importProject + Vanishing Point (.vpe)... | file.importVanishingPoint Placeholder... | file.importPlaceholder Solid... | file.importSolid - After Effects Project... | file.importAep - Lottie... | file.importLottie - Vanishing Point (.vpe)... | file.importVanishingPoint + --- Essential Graphics Template... | essential.importTemplate + Lottie... | file.importLottie Import Recent Footage @dynamic:recentFootage --- @@ -592,7 +599,7 @@ File Export Add to Render Queue | renderQueue.add Adobe Premiere Pro Project... | file.exportTimeline - After Effects Project... | file.exportAep + Adobe After Effects Project... | file.exportAep Lottie JSON... | file.exportLottie Essential Graphics Template... | essential.exportTemplate --- diff --git a/crates/engine/src/tests/aep_export.rs b/crates/engine/src/tests/aep_export.rs index 41c2ebfee..a716ef7bc 100644 --- a/crates/engine/src/tests/aep_export.rs +++ b/crates/engine/src/tests/aep_export.rs @@ -1,4 +1,4 @@ -//! File ▸ Export ▸ After Effects Project… through the command registry. +//! File ▸ Export ▸ Adobe After Effects Project… through the command registry. use serde_json::json; @@ -77,7 +77,7 @@ fn bad_input_is_an_error_not_a_crash() { fn aep_export_menu_entry_exists() { let entries = crate::menus::entries(); let find = |cmd: &str| entries.iter().find(|(_, e)| e.command == cmd).map(|(p, e)| (p.join(" > "), e.label.clone())); - assert_eq!(find("file.exportAep"), Some(("File > Export".into(), "After Effects Project...".into()))); + assert_eq!(find("file.exportAep"), Some(("File > Export".into(), "Adobe After Effects Project...".into()))); } #[test] diff --git a/crates/engine/src/tests/aep_import.rs b/crates/engine/src/tests/aep_import.rs index 8f09f1c0b..7aba82983 100644 --- a/crates/engine/src/tests/aep_import.rs +++ b/crates/engine/src/tests/aep_import.rs @@ -1,4 +1,4 @@ -//! File ▸ Import ▸ After Effects Project… through the command registry, with the synthetic +//! File ▸ Import ▸ Adobe After Effects Project… through the command registry, with the synthetic //! fixtures of `effectcraft-aep`. use serde_json::json; @@ -147,11 +147,95 @@ fn bad_input_is_an_error_not_a_crash() { assert!(s.project.items.is_empty()); } +/// File ▸ Import: After Effects' own entries in its order, the project imports together where +/// After Effects has Adobe Premiere Pro Project… (alphabetical, named in full), then a separator +/// and the formats only EffectCraft imports, alphabetical (#3103). +#[test] +fn the_import_menu_groups_the_project_imports() { + use crate::menus::MenuNode; + let file = crate::menus::menu_bar().iter().find_map(|n| match n { + MenuNode::Submenu { label, children } if label == "File" => Some(children), + _ => None, + }); + let import = file.into_iter().flatten().find_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Import" => Some(children), + _ => None, + }); + let labels: Vec<&str> = import + .into_iter() + .flatten() + .map(|n| match n { + MenuNode::Item(e) => e.label.as_str(), + MenuNode::Separator => "---", + MenuNode::Submenu { label, .. } => label.as_str(), + MenuNode::Dynamic { .. } => "@dynamic", + }) + .collect(); + assert_eq!( + labels, + [ + "File...", + "Multiple Files...", + "Adobe After Effects Project...", + "Adobe Premiere Pro Project...", + "EffectCraft Project...", + "Vanishing Point (.vpe)...", + "Placeholder...", + "Solid...", + "---", + "Essential Graphics Template...", + "Lottie...", + ] + ); +} + +/// `file.import` takes After Effects projects as After Effects' File… does, for scripts and +/// agents: one without losses imports as `file.importAep` does; one with losses is refused and +/// changes nothing, naming the menu entry that lists the losses; a file that is not one says why. +#[test] +fn file_import_takes_after_effects_projects() { + assert!(crate::cmd::is_aep_path("/a/Intro.aep") && crate::cmd::is_aep_path("/a/Intro.AEPX")); + assert!(!crate::cmd::is_aep_path("/a/Intro.ecproj") && !crate::cmd::is_aep_path("/a/aep") && !crate::cmd::is_aep_path("/a/clip.aep.mp4")); + let dir = std::env::temp_dir().join(format!("ec-file-import-aep-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let message = |r: crate::Result| match r { + Ok(v) => v["errors"].to_string(), + Err(e) => e.to_string(), + }; + + let intro = dir.join("Intro.aep").to_string_lossy().to_string(); + let mut source = Session::default(); + source.execute("comp.new", json!({"name": "Intro", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap(); + source.execute("file.exportAep", json!({"path": intro, "allowLosses": true})).unwrap(); + let mut s = Session::default(); + let r = s.execute("file.import", json!({"paths": [intro]})).unwrap(); + assert_eq!(r["projects"].as_array().map(Vec::len), Some(1), "{r}"); + let folder = s.project.items.values().find(|i| i.name == "Intro.aep").map(|i| i.id); + assert!(folder.is_some(), "the project's folder"); + assert!(s.project.items.values().any(|i| i.name == "Intro" && i.parent == folder), "its composition in the folder"); + + let lossy = concat!(env!("CARGO_MANIFEST_DIR"), "/../aep/tests/fixtures/synthetic.aep"); + let mut s = Session::default(); + let refused = message(s.execute("file.import", json!({"paths": [lossy]}))); + assert!(refused.contains("synthetic.aep") && refused.contains("File ▸ Import ▸ Adobe After Effects Project… lists them"), "{refused}"); + assert!(refused.contains("`file.importAep` with `allowLosses`"), "and the command that takes the consent: {refused}"); + assert!(s.project.items.is_empty(), "nothing imported"); + + let junk = dir.join("Junk.AEPX"); + std::fs::write(&junk, b"not a project").unwrap(); + let mut s = Session::default(); + let error = message(s.execute("file.import", json!({"paths": [junk.to_string_lossy()]}))); + assert!(error.contains("Junk.AEPX"), "{error}"); + assert!(s.project.items.is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn aep_menu_entry_exists() { let entries = crate::menus::entries(); let find = |cmd: &str| entries.iter().find(|(_, e)| e.command == cmd).map(|(p, e)| (p.join(" > "), e.label.clone())); - assert_eq!(find("file.importAep"), Some(("File > Import".into(), "After Effects Project...".into()))); + assert_eq!(find("file.importAep"), Some(("File > Import".into(), "Adobe After Effects Project...".into()))); } #[test] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index c998538d4..a549edd5c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -506,8 +506,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("file.import", "File...", "文件..."), ("file.importMultiple", "Multiple Files...", "多个文件..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro 项目..."), - ("file.importAep", "After Effects Project...", "After Effects 项目..."), - ("file.exportAep", "After Effects Project...", "After Effects 项目..."), + ("file.importAep", "Adobe After Effects Project...", "Adobe After Effects 项目..."), + ("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects 项目..."), ("file.importProject", "EffectCraft Project...", "EffectCraft 项目..."), ("file.importPlaceholder", "Placeholder...", "占位符..."), ("file.importSolid", "Solid...", "纯色..."), @@ -1146,8 +1146,8 @@ const SWEDISH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Fil..."), ("file.importMultiple", "Multiple Files...", "Flera filer..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-projekt..."), - ("file.importAep", "After Effects Project...", "After Effects-projekt..."), - ("file.exportAep", "After Effects Project...", "After Effects-projekt..."), + ("file.importAep", "Adobe After Effects Project...", "Adobe After Effects-projekt..."), + ("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects-projekt..."), ("file.importProject", "EffectCraft Project...", "EffectCraft-projekt..."), ("file.importPlaceholder", "Placeholder...", "Platshållare..."), ("file.importSolid", "Solid...", "Enfärgad..."), @@ -1788,8 +1788,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("file.import", "File...", "檔案..."), ("file.importMultiple", "Multiple Files...", "多個檔案..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro 專案..."), - ("file.importAep", "After Effects Project...", "After Effects 專案..."), - ("file.exportAep", "After Effects Project...", "After Effects 專案..."), + ("file.importAep", "Adobe After Effects Project...", "Adobe After Effects 專案..."), + ("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects 專案..."), ("file.importProject", "EffectCraft Project...", "EffectCraft 專案..."), ("file.importPlaceholder", "Placeholder...", "預留位置..."), ("file.importSolid", "Solid...", "純色..."), @@ -2427,8 +2427,8 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("file.import", "File...", "ファイル..."), ("file.importMultiple", "Multiple Files...", "複数のファイル..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Proプロジェクト..."), - ("file.importAep", "After Effects Project...", "After Effectsプロジェクト..."), - ("file.exportAep", "After Effects Project...", "After Effectsプロジェクト..."), + ("file.importAep", "Adobe After Effects Project...", "Adobe After Effectsプロジェクト..."), + ("file.exportAep", "Adobe After Effects Project...", "Adobe After Effectsプロジェクト..."), ("file.importPlaceholder", "Placeholder...", "プレースホルダー..."), ("file.importSolid", "Solid...", "平面..."), ("file.importLottie", "Lottie...", "Lottie..."), @@ -3069,8 +3069,8 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Файл..."), ("file.importMultiple", "Multiple Files...", "Кілька файлів..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Проєкт Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Проєкт After Effects..."), - ("file.exportAep", "After Effects Project...", "Проєкт After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Проєкт Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Проєкт Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Проєкт EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Заповнювач..."), ("file.importSolid", "Solid...", "Суцільний колір..."), @@ -3712,8 +3712,8 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Archivo..."), ("file.importMultiple", "Multiple Files...", "Varios archivos..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Proyecto de Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Proyecto de After Effects..."), - ("file.exportAep", "After Effects Project...", "Proyecto de After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Proyecto de Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Proyecto de Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Proyecto de EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Marcador de posición..."), ("file.importSolid", "Solid...", "Sólido..."), @@ -4353,8 +4353,8 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Arquivo..."), ("file.importMultiple", "Multiple Files...", "Vários arquivos..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Projeto do Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Projeto do After Effects..."), - ("file.exportAep", "After Effects Project...", "Projeto do After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Projeto do Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Projeto do Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Projeto do EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Espaço reservado..."), ("file.importSolid", "Solid...", "Sólido..."), @@ -5002,8 +5002,8 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Файл..."), ("file.importMultiple", "Multiple Files...", "Несколько файлов..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Проект Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Проект After Effects..."), - ("file.exportAep", "After Effects Project...", "Проект After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Проект Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Проект Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Проект EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Заполнитель..."), ("file.importSolid", "Solid...", "Сплошной цвет..."), @@ -5643,8 +5643,8 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Fichier..."), ("file.importMultiple", "Multiple Files...", "Plusieurs fichiers..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Projet Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Projet After Effects..."), - ("file.exportAep", "After Effects Project...", "Projet After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Projet Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Projet Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Projet EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Espace réservé..."), ("file.importSolid", "Solid...", "Solide..."), @@ -6284,8 +6284,8 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Datei..."), ("file.importMultiple", "Multiple Files...", "Mehrere Dateien..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-Projekt..."), - ("file.importAep", "After Effects Project...", "After Effects-Projekt..."), - ("file.exportAep", "After Effects Project...", "After Effects-Projekt..."), + ("file.importAep", "Adobe After Effects Project...", "Adobe After Effects-Projekt..."), + ("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects-Projekt..."), ("file.importProject", "EffectCraft Project...", "EffectCraft-Projekt..."), ("file.importPlaceholder", "Placeholder...", "Platzhalter..."), ("file.importSolid", "Solid...", "Farbfläche..."), @@ -6925,8 +6925,8 @@ const POLISH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Plik..."), ("file.importMultiple", "Multiple Files...", "Wiele plików..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Projekt After Effects..."), - ("file.exportAep", "After Effects Project...", "Projekt After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Projekt Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Projekt Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Projekt EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Element zastępczy..."), ("file.importSolid", "Solid...", "Plansza..."), @@ -7563,8 +7563,8 @@ const CZECH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Soubor..."), ("file.importMultiple", "Multiple Files...", "Více souborů..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Projekt After Effects..."), - ("file.exportAep", "After Effects Project...", "Projekt After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Projekt Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Projekt Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Projekt EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Zástupný záznam..."), ("file.importSolid", "Solid...", "Plná vrstva..."), @@ -8201,8 +8201,8 @@ const GEORGIAN: &[(&str, &str, &str)] = &[ ("file.import", "File...", "ფაილი..."), ("file.importMultiple", "Multiple Files...", "რამდენიმე ფაილი..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-ს პროექტი..."), - ("file.importAep", "After Effects Project...", "After Effects-ის პროექტი..."), - ("file.exportAep", "After Effects Project...", "After Effects-ის პროექტი..."), + ("file.importAep", "Adobe After Effects Project...", "Adobe After Effects-ის პროექტი..."), + ("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects-ის პროექტი..."), ("file.importProject", "EffectCraft Project...", "EffectCraft-ის პროექტი..."), ("file.importPlaceholder", "Placeholder...", "ჩანაცვლების ობიექტი..."), ("file.importSolid", "Solid...", "სოლიდი..."), @@ -9208,7 +9208,8 @@ mod tests { assert!(!japanese(&app)); } - /// File ▸ Import ▸ After Effects Project… has its row in every menu catalog. + /// File ▸ Import / Export ▸ Adobe After Effects Project… has its row in every menu catalog, + /// under the label the menu draws (a row under an older label would fall back to English). #[test] fn the_aep_import_and_export_entries_are_in_every_menu_catalog() { for (code, catalog) in [ @@ -9227,7 +9228,8 @@ mod tests { ("sv", SWEDISH), ] { for id in ["file.importAep", "file.exportAep"] { - assert!(catalog.iter().any(|(command, _, _)| *command == id), "{code} has no {id} row"); + let drawn = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == id).map(|(_, e)| e.label.clone()).unwrap_or_default(); + assert!(catalog.iter().any(|(command, english, _)| *command == id && *english == drawn), "{code} has no {id} row for {drawn:?}"); } } } diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 259fe2d60..a16b879d7 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -242,7 +242,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Brush Dynamics", "ブラシのダイナミクス"), ("Caching frames", "フレームをキャッシュ中"), ("Cancel", "キャンセル"), - ("Import After Effects Project", "After Effectsプロジェクトを読み込み"), + ("Import Adobe After Effects Project", "Adobe After Effectsプロジェクトを読み込み"), ("Some parts of this project cannot be imported exactly (left out or changed):", "このプロジェクトの一部は正確に読み込めません(省略または変更されます):"), ("Import Anyway", "このまま読み込み"), ("Casts Shadows", "シャドウを落とす"), @@ -1383,7 +1383,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Brush Dynamics", "画笔动态"), ("Caching frames", "正在缓存帧"), ("Cancel", "取消"), - ("Import After Effects Project", "导入 After Effects 项目"), + ("Import Adobe After Effects Project", "导入 Adobe After Effects 项目"), ("Some parts of this project cannot be imported exactly (left out or changed):", "此项目的某些部分无法准确导入(将被省略或更改):"), ("Import Anyway", "仍然导入"), ("Casts Shadows", "投射阴影"), @@ -2499,7 +2499,7 @@ const SWEDISH: &[(&str, &str)] = &[ ("Brush Dynamics", "Penseldynamik"), ("Caching frames", "Cachelagrar bildrutor"), ("Cancel", "Avbryt"), - ("Import After Effects Project", "Importera After Effects-projekt"), + ("Import Adobe After Effects Project", "Importera Adobe After Effects-projekt"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Vissa delar av projektet kan inte importeras exakt (utelämnas eller ändras):", @@ -3188,6 +3188,14 @@ const SWEDISH: &[(&str, &str)] = &[ ("A built-in preset has this name", "En inbyggd förinställning har det här namnet"), ("Cannot import dropped files: {}", "Kan inte importera släppta filer: {}"), ("Cannot open dropped project {}: {}", "Kan inte öppna det släppta projektet {}: {}"), + ( + "Imported footage has an unlabeled alpha channel: choose how to interpret it in File ▸ Interpret Footage ▸ Main…", + "Importerat material har en omärkt alfakanal: välj hur den ska tolkas i Arkiv ▸ Tolka material ▸ Huvud…", + ), + ( + "Skipped {} more file(s) while a dialog is open; import them again after it", + "Hoppade över {} fil(er) till medan en dialogruta är öppen; importera dem igen efteråt", + ), ("Click in the Composition panel to sample text {}", "Klicka i panelen Komposition för att hämta text {}"), ("Click to sample text {}", "Klicka för att hämta text {}"), ("Comment of {}: {}", "Kommentar till {}: {}"), @@ -3768,7 +3776,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Brush Dynamics", "筆刷動態"), ("Caching frames", "正在快取幀"), ("Cancel", "取消"), - ("Import After Effects Project", "匯入 After Effects 專案"), + ("Import Adobe After Effects Project", "匯入 Adobe After Effects 專案"), ("Some parts of this project cannot be imported exactly (left out or changed):", "此專案的部分內容無法準確匯入(將被省略或變更):"), ("Import Anyway", "仍要匯入"), ("Casts Shadows", "投射陰影"), @@ -4834,7 +4842,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Brush Dynamics", "Динаміка пензля"), ("Caching frames", "Кешування кадрів"), ("Cancel", "Скасувати"), - ("Import After Effects Project", "Імпортувати проєкт After Effects"), + ("Import Adobe After Effects Project", "Імпортувати проєкт Adobe After Effects"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Деякі частини цього проєкту неможливо імпортувати точно (їх пропущено або змінено):", @@ -5415,7 +5423,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Brush Dynamics", "Dinâmica do pincel"), ("Caching frames", "Armazenando quadros em cache"), ("Cancel", "Cancelar"), - ("Import After Effects Project", "Importar projeto do After Effects"), + ("Import Adobe After Effects Project", "Importar projeto do Adobe After Effects"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Algumas partes deste projeto não podem ser importadas exatamente (omitidas ou alteradas):", @@ -6188,7 +6196,7 @@ const POLISH: &[(&str, &str)] = &[ ("Brush Dynamics", "Dynamika pędzla"), ("Caching frames", "Buforowanie klatek"), ("Cancel", "Anuluj"), - ("Import After Effects Project", "Importuj projekt After Effects"), + ("Import Adobe After Effects Project", "Importuj projekt Adobe After Effects"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Niektórych części tego projektu nie można zaimportować dokładnie (zostaną pominięte lub zmienione):", @@ -6944,7 +6952,7 @@ const CZECH: &[(&str, &str)] = &[ ("Brush Dynamics", "Dynamika štětce"), ("Caching frames", "Ukládání snímků do mezipaměti"), ("Cancel", "Zrušit"), - ("Import After Effects Project", "Importovat projekt After Effects"), + ("Import Adobe After Effects Project", "Importovat projekt Adobe After Effects"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Některé části tohoto projektu nelze importovat přesně (budou vynechány nebo změněny):", @@ -7519,7 +7527,7 @@ const SPANISH: &[(&str, &str)] = &[ ("Brush Dynamics", "Dinámica del pincel"), ("Caching frames", "Almacenando fotogramas en caché"), ("Cancel", "Cancelar"), - ("Import After Effects Project", "Importar proyecto de After Effects"), + ("Import Adobe After Effects Project", "Importar proyecto de Adobe After Effects"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Algunas partes de este proyecto no se pueden importar con exactitud (se omiten o cambian):", @@ -8085,7 +8093,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Brush Dynamics", "ფუნჯის დინამიკა"), ("Caching frames", "კადრების ქეშირება"), ("Cancel", "გაუქმება"), - ("Import After Effects Project", "After Effects-ის პროექტის იმპორტი"), + ("Import Adobe After Effects Project", "Adobe After Effects-ის პროექტის იმპორტი"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "ამ პროექტის ზოგიერთი ნაწილის ზუსტად იმპორტი შეუძლებელია (გამოტოვებული ან შეცვლილი):", @@ -9058,7 +9066,7 @@ const FRENCH: &[(&str, &str)] = &[ ("Brush Dynamics", "Dynamique du pinceau"), ("Caching frames", "Mise en cache des images"), ("Cancel", "Annuler"), - ("Import After Effects Project", "Importer un projet After Effects"), + ("Import Adobe After Effects Project", "Importer un projet Adobe After Effects"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Certaines parties de ce projet ne peuvent pas être importées à l’identique (omises ou modifiées) :", @@ -9801,6 +9809,14 @@ const FRENCH: &[(&str, &str)] = &[ ), ("Only one project can be opened per drop; skipped {} more", "Un seul projet peut être ouvert par dépôt ; {} de plus ignoré(s)"), ("Cannot open dropped project {}: {}", "Impossible d’ouvrir le projet déposé {} : {}"), + ( + "Imported footage has an unlabeled alpha channel: choose how to interpret it in File ▸ Interpret Footage ▸ Main…", + "Le métrage importé a une couche alpha non étiquetée : choisissez son interprétation dans Fichier ▸ Interpréter le métrage ▸ Principal…", + ), + ( + "Skipped {} more file(s) while a dialog is open; import them again after it", + "{} fichier(s) de plus ignoré(s) pendant qu’une boîte de dialogue est ouverte ; importez-les ensuite", + ), ("Cannot import dropped files: {}", "Impossible d’importer les fichiers déposés : {}"), ("A built-in preset has this name", "Un préréglage intégré porte ce nom"), ("Replaces your preset with this name", "Remplace votre préréglage portant ce nom"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb28b0bcd..9020b8fc0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -27,8 +27,8 @@ pub(crate) use entries::{entry_checked, entry_enabled, entry_label, entry_shortc pub use frontend::frontend; #[cfg(test)] use frontend::zoom_step; -pub(crate) use frontend::{dialog_answered, run_engine}; -pub use invoke::{invoke, panel_command_id, reveal}; +pub(crate) use frontend::{dialog_answered, import_aep_projects, run_engine}; +pub use invoke::{invoke, panel_command_id, reveal, waits_on_dialog}; #[cfg(test)] use shortcuts::modifiers_at_start; pub use shortcuts::{Binding, bindings, chord_matches, forwardable_during_text_edit, handle_shortcuts, handle_tab_shortcut, parse_shortcut, shortcut_text}; diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index bd1294330..bf981fc67 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -502,6 +502,15 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu return run_engine(app, ctx, "window.assignWorkspaceShortcut", q); } "file.interpretFootage" => { + // A background import finishing while another dialog waits (the After Effects losses + // prompt of the same drop) does not replace it: that dialog's work would be lost. The + // footage keeps the default interpretation; the note says where to change it. + if app.dialog.is_some() { + app.ui.status = + crate::i18n::tr("Imported footage has an unlabeled alpha channel: choose how to interpret it in File ▸ Interpret Footage ▸ Main…") + .to_string(); + return Ok(Value::Null); + } // Import ▸ Interpret Unlabeled Alpha As: Ask User. if let Some(i) = p.get("item").and_then(Value::as_u64) { app.session.state.project_selection = vec![effectcraft_engine::project::ItemId(i)]; @@ -601,7 +610,8 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option> { enum Ask { Folder, - Import, + /// Footage, or a file with one of these extensions. + Import(&'static [&'static str]), OpenProject, Save(std::borrow::Cow<'static, str>), Open(&'static [&'static str]), @@ -617,7 +627,9 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> path.with_file_name(format!("{stem}{suffix}.{ext}")).to_string_lossy().into_owned().into() }; let (key, ask) = match id { - "file.import" | "file.importMultiple" => ("paths", Ask::Import), + // Projects too, as After Effects' File… takes an .aep: they import as projects + // (`run_with_picked`, and `file.import` for EffectCraft projects). + "file.import" | "file.importMultiple" => ("paths", Ask::Import(&["aep", "aepx", "ecproj", "ecprojx"])), "file.open" => ("path", Ask::OpenProject), "file.save" if app.session.path.is_none() => ("path", Ask::Save(project_as("", "ecproj"))), "file.saveAs" => ("path", Ask::Save(project_as("", "ecproj"))), @@ -644,7 +656,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> "effect.plugins.install" if params.get("bundle").is_some() => ("folder", Ask::Folder), "effect.plugins.install" if params.get("folder").is_none() && params.get("path").is_none() => ("path", Ask::Open(&["wasm"])), "effect.helpers.load" => ("path", Ask::Folder), - "file.replaceFootage" => ("path", Ask::Import), + "file.replaceFootage" => ("path", Ask::Import(&[])), "file.relinkFootage" => ("folder", Ask::Folder), "file.collectFiles" => ("folder", Ask::Save("Collected Files".into())), // An open XML project gets ` copy` too: the copy never writes over the open file. @@ -672,6 +684,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv", ]; let footage = |more: &[&'static str]| effectcraft_project::VIDEO_EXTENSIONS.iter().chain(more).copied().collect::>(); + let importable = |more: &[&'static str]| footage(MEDIA).into_iter().chain(more.iter().copied()).collect::>(); // A host whose blocking dialogs are unsafe inside this frame shows the dialog without // blocking — macOS: the nested event loop re-entered winit's handler on a drag over the // window and aborted the app (#511); Linux: the desktop portal's answer never gets pumped, @@ -680,7 +693,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> 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: footage(MEDIA).iter().map(|e| e.to_string()).collect(), many: key == "paths" }, + Ask::Import(more) => crate::FileDialog::Open { exts: importable(more).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 }, @@ -702,9 +715,9 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> let Some(f) = app.hooks.pick_folder.as_ref() else { return Some(Err("no folder dialog available (pass `folder`)".into())) }; f().map(Value::from) } - Ask::Import => { + Ask::Import(more) => { let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) }; - picked_value(key, f(&footage(MEDIA))) + picked_value(key, f(&importable(more))) } Ask::OpenProject => { let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; @@ -758,7 +771,8 @@ pub(crate) fn show_result_errors(app: &mut EffectcraftApp, result: &Value) { .map(|e| match (e.get("file").and_then(Value::as_str), e.get("error").and_then(Value::as_str)) { (Some(file), Some(error)) => format!("{file}: {error}"), (_, Some(error)) => error.to_string(), - _ => e.to_string(), + // A plain message (`file.import`'s errors) reads as itself, not as quoted JSON. + _ => e.as_str().map_or_else(|| e.to_string(), str::to_string), }) .collect(); let mut msg = parts.iter().take(3).cloned().collect::>().join("; "); @@ -770,6 +784,14 @@ pub(crate) fn show_result_errors(app: &mut EffectcraftApp, result: &Value) { /// Run command `id` with the path picked for it in parameter `key`. fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, v: Value) -> Result { + if matches!(id, "file.import" | "file.importMultiple") + && let Some(picked) = v.as_array() + && picked.iter().filter_map(Value::as_str).any(effectcraft_engine::commands::is_aep_path) + { + let (aep, rest): (Vec, Vec) = + picked.iter().filter_map(Value::as_str).map(str::to_string).partition(|p| effectcraft_engine::commands::is_aep_path(p)); + return import_picked_with_aep(app, id, key, params, aep, rest); + } let mut p = params.as_object().cloned().unwrap_or_default(); p.insert(key.to_string(), v); // Picked files import in the background, with the Importing card showing progress (#270). @@ -777,10 +799,13 @@ fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value p.insert("background".into(), Value::Bool(true)); } // Photoshop files ask how to import them first; numbered stills whether to import them as an - // image sequence. + // image sequence. Not while the losses prompt of an After Effects project picked with them + // shows: one dialog at a time, so they import with the defaults. let picked = Value::Object(p.clone()); - if id == "file.import" && crate::panels::dialogs::open_form(app, id, &picked) - || matches!(id, "file.import" | "file.importMultiple") && crate::panels::forms::open_import_sequence(app, &picked) + let asking = crate::panels::aep_losses::asking(app); + if !asking + && (id == "file.import" && crate::panels::dialogs::open_form(app, id, &picked) + || matches!(id, "file.import" | "file.importMultiple") && crate::panels::forms::open_import_sequence(app, &picked)) { return Ok(json!({"dialog": id})); } @@ -804,6 +829,62 @@ fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value r } +/// Import After Effects projects dropped or picked together, as File ▸ Import ▸ Adobe After +/// Effects Project… does each: one that would lose parts asks first (the losses prompt), and the +/// ones after it wait in its queue until it is answered (`panels::aep_losses`). Returns the errors +/// of those that could not be imported; each names its file. +pub(crate) fn import_aep_projects(app: &mut EffectcraftApp, paths: Vec) -> Vec { + let mut errors = Vec::new(); + // An unanswered prompt's projects go first. + let mut paths = crate::panels::aep_losses::take_unanswered(app).into_iter().chain(paths); + while let Some(path) = paths.next() { + if crate::panels::aep_losses::asking(app) { + let queue = &mut app.dialog_state.aep_losses.queue; + queue.push(path); + queue.extend(paths); + break; + } + if let Err(e) = run_with_picked(app, "file.importAep", "path", &json!({}), json!(path)) { + errors.push(e); + } + } + errors +} + +/// File ▸ Import ▸ File… with After Effects projects picked: they import first, as projects +/// ([`import_aep_projects`]), then the other files as footage. Their errors lead the result's +/// `errors` and the status line (callers of the blocking dialog drop the result), or the message +/// when the footage import failed. +fn import_picked_with_aep(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, aep: Vec, rest: Vec) -> Result { + let errors = import_aep_projects(app, aep); + let r = if !rest.is_empty() { + run_with_picked(app, id, key, params, json!(rest)) + } else if crate::panels::aep_losses::asking(app) { + Ok(json!({"dialog": "aepLosses"})) + } else { + Ok(json!({})) + }; + if errors.is_empty() { + return r; + } + match r { + Ok(mut result) => { + if !result.is_object() { + result = json!({"result": result}); + } + let own = result.get("errors").and_then(Value::as_array).cloned().unwrap_or_default(); + result["errors"] = Value::Array(errors.into_iter().map(Value::from).chain(own).collect()); + show_result_errors(app, &result); + Ok(result) + } + Err(e) => { + let message = format!("{}; {e}", errors.join("; ")); + app.ui.status = message.clone(); + Err(message) + } + } +} + /// View ▸ Zoom In / Zoom Out: the next standard magnification (as a share of screen pixels, so /// 100 % is one comp pixel per screen pixel at any display scaling) above or below `cur`, which is /// in points per comp pixel like the result (#882). diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index bebb7f767..4349e6b33 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -183,6 +183,12 @@ fn only_comp(p: &Value) -> Option { m.get("comp")?.as_u64().map(effectcraft_engine::project::ItemId) } +/// Whether [`invoke`]'s result says the command waits on a dialog (a guard or a form deferred it +/// until the user answers, `{"dialog": …}`). +pub fn waits_on_dialog(result: &Value) -> bool { + result.get("dialog").is_some() +} + /// Execute a UI or engine command by id. pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { crate::control::check_script_render(app, id)?; diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index b12483b0a..12aac3cd9 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -730,3 +730,126 @@ fn effect_plugin_install_dialog_router() { assert!(r.is_err(), "{r:?}"); assert!(pending.lock().unwrap().is_empty(), "no dialog for an inline folder"); } + +/// File ▸ Import ▸ File… in the non-blocking dialog (macOS, Linux) offers After Effects projects +/// next to footage, and a picked one imports as File ▸ Import ▸ Adobe After Effects Project… does: its +/// folder, its composition open. +#[test] +fn import_file_dialog_offers_and_imports_after_effects_projects() { + use std::sync::{Arc, Mutex}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + let dir = std::env::temp_dir().join(format!("ec-import-aep-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let aep = dir.join("Intro.aep").to_string_lossy().to_string(); + let mut source = effectcraft_engine::Session::default(); + source.execute("comp.new", json!({"name": "Intro", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap(); + source.execute("file.exportAep", json!({"path": aep, "allowLosses": true})).unwrap(); + + let pending: Arc>> = Arc::default(); + let queue = pending.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog))); + invoke(&mut app, &ctx, "file.import", json!({})).unwrap(); + let Some(crate::FileDialog::Open { exts, many }) = pending.lock().unwrap().pop() else { panic!("an Open dialog was shown") }; + assert!(many && exts.iter().any(|e| e == "aep") && exts.iter().any(|e| e == "aepx"), "{exts:?}"); + assert!(exts.iter().any(|e| e == "png"), "footage too"); + + super::frontend::dialog_answered(&mut app, crate::DialogAnswer { command: "file.import".into(), key: "paths", params: json!({}), paths: vec![aep] }); + let comp = app.session.project.item(app.session.active_comp_id().expect("a composition is open")).unwrap(); + assert_eq!(comp.name, "Intro"); + assert_eq!(app.session.project.item(comp.parent.expect("in a folder")).unwrap().name, "Intro.aep"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Footage with unlabeled alpha asks how to interpret it (Interpret Unlabeled Alpha As: Ask +/// User), but not over a dialog that already waits (the After Effects losses prompt of the same +/// drop, which a background import can finish behind): that dialog stays, and a note says where +/// to choose the interpretation. +#[test] +fn the_unlabeled_alpha_question_does_not_replace_an_open_dialog() { + use effectcraft_engine::project::{Footage, FootageKind, ItemKind}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + let footage = Footage { path: "/m/matte.tga".into(), kind: FootageKind::Still, width: 4, height: 4, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut app.session.project).add_item("matte.tga", Default::default(), None, ItemKind::Footage(footage)).0; + app.dialog = Some(crate::Dialog::AepLosses); + super::frontend(&mut app, &ctx, "file.interpretFootage", json!({"item": item, "reason": "unlabeledAlpha"})).unwrap(); + assert_eq!(app.dialog, Some(crate::Dialog::AepLosses), "the waiting dialog stays"); + assert!(app.ui.status.contains("Interpret Footage"), "{}", app.ui.status); + assert!(app.session.state.project_selection.is_empty(), "the selection is left alone"); + + app.dialog = None; + super::frontend(&mut app, &ctx, "file.interpretFootage", json!({"item": item, "reason": "unlabeledAlpha"})).unwrap(); + assert_eq!(app.dialog, Some(crate::Dialog::Form), "with no dialog open it asks"); +} + +/// File ▸ Import ▸ File… and Multiple Files… offer projects next to footage; File ▸ Replace +/// Footage offers footage only. +#[test] +fn only_the_import_dialogs_offer_projects() { + use std::sync::{Arc, Mutex}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let pending: Arc>> = Arc::default(); + let queue = pending.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog))); + let projects = ["aep", "aepx", "ecproj", "ecprojx"]; + for (id, offered) in [("file.import", true), ("file.importMultiple", true), ("file.replaceFootage", false)] { + assert!(super::frontend::file_dialog(&mut app, id, &json!({})).is_some(), "{id} asks for a file"); + let Some(crate::FileDialog::Open { exts, .. }) = pending.lock().unwrap().pop() else { panic!("{id}: an Open dialog") }; + assert!(exts.iter().any(|e| e == "png"), "{id} offers footage"); + assert_eq!(projects.iter().all(|p| exts.iter().any(|e| e == p)), offered, "{id}: {exts:?}"); + assert!(offered || !projects.iter().any(|p| exts.iter().any(|e| e == p)), "{id} offers no project: {exts:?}"); + } +} + +/// Picked After Effects projects that cannot be imported keep their errors next to the other +/// files': a broken `.aep` and a broken `.ecproj` are both named in the status line. +#[test] +fn picked_projects_report_every_error() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let dir = std::env::temp_dir().join(format!("ec-picked-errors-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let aep = dir.join("broken.aep"); + let ecproj = dir.join("broken.ecproj"); + std::fs::write(&aep, b"not an aep").unwrap(); + std::fs::write(&ecproj, b"not a project").unwrap(); + let paths = vec![aep.to_string_lossy().to_string(), ecproj.to_string_lossy().to_string()]; + super::frontend::dialog_answered(&mut app, crate::DialogAnswer { command: "file.import".into(), key: "paths", params: json!({}), paths: paths.clone() }); + let status = &app.ui.status; + assert!(status.contains("broken.aep") && status.contains("broken.ecproj"), "{status}"); + assert!(!status.contains('"'), "messages, not quoted JSON: {status}"); + assert!(app.dialog.is_none() && app.session.project.items.is_empty()); + + // The blocking dialog (Windows), whose callers drop the result: the status line still has both. + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + app.hooks.pick_files = Some(Box::new(move |_: &[&str]| paths.clone())); + let _ = invoke(&mut app, &ctx, "file.import", json!({})); + let status = &app.ui.status; + assert!(status.contains("broken.aep") && status.contains("broken.ecproj"), "{status}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A picked After Effects project with parts that cannot come over asks first; one picked with +/// it waits for the answer. +#[test] +fn a_picked_project_with_losses_asks_and_the_next_waits() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let dir = std::env::temp_dir().join(format!("ec-picked-losses-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let lossy = dir.join("Synthetic.aep"); + std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../aep/tests/fixtures/synthetic.aep"), &lossy).unwrap(); + let intro = dir.join("Intro.aep").to_string_lossy().to_string(); + let mut source = effectcraft_engine::Session::default(); + source.execute("comp.new", json!({"name": "Intro", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap(); + source.execute("file.exportAep", json!({"path": intro, "allowLosses": true})).unwrap(); + let paths = vec![lossy.to_string_lossy().to_string(), intro.clone()]; + super::frontend::dialog_answered(&mut app, crate::DialogAnswer { command: "file.import".into(), key: "paths", params: json!({}), paths }); + assert_eq!(app.dialog, Some(crate::Dialog::AepLosses)); + assert_eq!(app.dialog_state.aep_losses.queue, [intro]); + assert!(app.session.project.items.is_empty(), "nothing imported before the answer"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/panels/aep_losses.rs b/crates/ui-egui/src/panels/aep_losses.rs index 746e83b86..14010a5df 100644 --- a/crates/ui-egui/src/panels/aep_losses.rs +++ b/crates/ui-egui/src/panels/aep_losses.rs @@ -1,8 +1,10 @@ -//! File ▸ Import / Export ▸ After Effects Project… from the menu: what the conversion cannot +//! File ▸ Import / Export ▸ Adobe After Effects Project… from the menu: what the conversion cannot //! carry over is shown first (the command's dry run), and the user chooses to cancel or to go on //! with those losses (`allowLosses`). Cancel changes nothing; a conversion without losses runs at //! once. Agents running `file.importAep` / `file.exportAep` with `engine.execute` are never asked: -//! they pass `allowLosses` or get the refusal. +//! they pass `allowLosses` or get the refusal. After Effects projects dropped or picked together +//! (File ▸ Import ▸ File…) are asked about one at a time: the others wait in the queue until the +//! prompt is answered, and Cancel skips only the one it asks about. //! //! Automation ids: `dialog.aepLosses.continue`, `dialog.aepLosses.cancel`. @@ -17,11 +19,32 @@ use crate::{Dialog, EffectcraftApp}; #[derive(Clone, Debug, Default)] pub struct Pending { pub command: Option<(String, Value, Value)>, + /// After Effects projects dropped or picked with it, imported once it is answered. + pub queue: Vec, } /// How many detail lines of a loss category the prompt shows. const DETAILS: usize = 3; +/// Whether the prompt is showing with a conversion waiting on it. +pub fn asking(app: &EffectcraftApp) -> bool { + app.dialog == Some(Dialog::AepLosses) && app.dialog_state.aep_losses.command.is_some() +} + +/// The projects of a prompt another dialog replaced (Save changes? on quitting) before it was +/// answered: its import's file, then its queue. Taken, so they are tried again instead of waiting +/// for an answer that never comes (a replaced export is dropped: it wrote nothing). +pub fn take_unanswered(app: &mut EffectcraftApp) -> Vec { + if app.dialog == Some(Dialog::AepLosses) { + return Vec::new(); + } + let losses = &mut app.dialog_state.aep_losses; + let Some((id, params, _)) = losses.command.take() else { return std::mem::take(&mut losses.queue) }; + let mut paths: Vec = params.get("path").and_then(Value::as_str).filter(|_| id == "file.importAep").map(str::to_string).into_iter().collect(); + paths.append(&mut losses.queue); + paths +} + /// Whether command `id` with `params` needs the prompt first. When it does, the prompt opens /// holding the command and the caller must not run it. `Err` when the dry run fails (the file /// cannot be read or is not a project), with the message. @@ -39,6 +62,9 @@ pub fn guard(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result = None; super::dialogs::modal(ctx, title, vec2(520.0, 340.0), t, |ui| { let text = if import { @@ -114,11 +140,25 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { /// The prompt's answer: Cancel (`false`) drops the conversion, Import / Export Anyway runs it /// with `allowLosses`. fn finish(app: &mut EffectcraftApp, ctx: &egui::Context, go: bool) { - app.dialog = None; - let Some((id, params, _)) = app.dialog_state.aep_losses.command.take() else { return }; + let Some((id, params, _)) = app.dialog_state.aep_losses.command.take() else { + app.dialog = None; + return; + }; + let mut errors = Vec::new(); if go { - // An error is already in the status bar. - let _ = crate::menus::run_engine(app, ctx, &id, with_losses(¶ms)); + // The prompt stays up while the conversion runs: a question queued by a footage import + // that finished meanwhile (Interpret Footage for unlabeled alpha, drained by `run_engine`) + // leaves a note instead of opening a dialog the next queued prompt would replace. + if let Err(e) = crate::menus::run_engine(app, ctx, &id, with_losses(¶ms)) { + errors.push(e); + } + } + app.dialog = None; + // The projects dropped or picked with this one: Cancel skips only this one. + let queue = std::mem::take(&mut app.dialog_state.aep_losses.queue); + errors.extend(crate::menus::import_aep_projects(app, queue)); + if !errors.is_empty() { + app.ui.status = errors.join("; "); } } @@ -142,6 +182,53 @@ mod tests { assert!(lines(&json!({})).is_empty()); } + /// Projects dropped or picked together wait only behind a prompt that shows; with none + /// showing they are tried at once (a missing file is an error naming it). + #[test] + fn projects_wait_only_behind_a_showing_prompt() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.dialog_state.aep_losses.command = Some(("file.importAep".into(), json!({"path": "/a/One.aep"}), json!({}))); + app.dialog = Some(Dialog::AepLosses); + assert!(crate::menus::import_aep_projects(&mut app, vec!["/no/such/Two.aep".into(), "/no/such/Three.aep".into()]).is_empty()); + assert_eq!(app.dialog_state.aep_losses.queue, ["/no/such/Two.aep", "/no/such/Three.aep"]); + + // Cancel: the queued ones are tried next (both missing here, so both are reported). + let ctx = egui::Context::default(); + finish(&mut app, &ctx, false); + assert!(app.dialog_state.aep_losses.queue.is_empty()); + assert!(app.ui.status.contains("Two.aep") && app.ui.status.contains("Three.aep"), "{}", app.ui.status); + } + + /// A prompt another dialog replaced (Save changes? on quitting) holds nothing back: the next + /// import tries its project and queue first, then the new one. + #[test] + fn a_replaced_prompt_holds_nothing_back() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.dialog_state.aep_losses.command = Some(("file.importAep".into(), json!({"path": "/no/such/One.aep"}), json!({}))); + app.dialog_state.aep_losses.queue = vec!["/no/such/Two.aep".into()]; + app.dialog = Some(Dialog::UnsavedChanges); + let errors = crate::menus::import_aep_projects(&mut app, vec!["/no/such/Three.aep".into()]); + let named = |n: &str| errors.iter().position(|e| e.contains(n)); + assert_eq!((named("One.aep"), named("Two.aep"), named("Three.aep")), (Some(0), Some(1), Some(2)), "{errors:?}"); + assert!(app.dialog_state.aep_losses.command.is_none() && app.dialog_state.aep_losses.queue.is_empty()); + assert_eq!(app.dialog, Some(Dialog::UnsavedChanges), "the other dialog stays"); + } + + /// A new prompt keeps an unanswered one's projects: they wait behind it. + #[test] + fn a_new_prompt_keeps_an_unanswered_ones_projects() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.dialog_state.aep_losses.command = Some(("file.importAep".into(), json!({"path": "/a/One.aep"}), json!({}))); + app.dialog_state.aep_losses.queue = vec!["/a/Two.aep".into()]; + app.dialog = Some(Dialog::UnsavedChanges); + // Rec. 2020 has no After Effects counterpart: exporting asks. + std::sync::Arc::make_mut(&mut app.session.project).settings.working_space = Some(effectcraft_engine::color::ColorSpace::Rec2020); + let dir = TempDir::new(); + assert_eq!(guard(&mut app, "file.exportAep", &json!({"path": dir.0.join("out.aep").to_string_lossy()})), Ok(true)); + assert_eq!(app.dialog, Some(Dialog::AepLosses)); + assert_eq!(app.dialog_state.aep_losses.queue, ["/a/One.aep", "/a/Two.aep"]); + } + #[test] fn agents_and_consented_calls_are_not_asked() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 3b735e47e..e61d523b1 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -481,7 +481,7 @@ fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str match crate::menus::invoke(app, ctx, id, params) { Err(e) => app.ui.status = e, // Asking to save the open project first: Home stays until it is answered. - Ok(r) if r.get("dialog").is_some() => {} + Ok(r) if crate::menus::waits_on_dialog(&r) => {} // Leave Home unless only the list changed or a file dialog was cancelled. Ok(_) => { let cancelled = matches!(id, "file.open" | "file.import") && (app.session.path.clone(), app.session.revision) == before; diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index 63beb3c39..af8e27856 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -75,12 +75,45 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { if proj.len() > 1 { errors.push(crate::i18n::tr_args("Only one project can be opened per drop; skipped {} more", &[&proj.len().saturating_sub(1)])); } + // While the After Effects losses prompt waits, opening a project would replace it (and the + // project the answer imports into): the project waits for the answer, dropped again after it. + let proj = if crate::panels::aep_losses::asking(app) && !proj.is_empty() { + errors.push(crate::i18n::tr_args("Skipped {} more file(s) while a dialog is open; import them again after it", &[&1])); + Vec::new() + } else { + proj + }; - if let Some(p) = proj.first() - && let Err(e) = crate::menus::invoke(app, &ctx, "file.open", json!({"path": p})) - { - errors.push(crate::i18n::tr_args("Cannot open dropped project {}: {}", &[&p, &e])); + // After Effects projects import as projects (File ▸ Import ▸ Adobe After Effects Project…), + // each into a folder named after its file, opening its first composition. + let (aep, mut rest): (Vec, Vec) = rest.into_iter().partition(|p| effectcraft_engine::commands::is_aep_path(p)); + // A project in the drop changes the active composition: the other files go to the Project + // panel only, not into a composition. + let has_project = !proj.is_empty() || !aep.is_empty(); + + // Opened first, so the other files land in the project it opens. While it waits on Save + // changes? the rest of the drop is skipped with a note: it would land in the project about + // to be replaced. + let mut waiting = false; + if let Some(p) = proj.first() { + match crate::menus::invoke(app, &ctx, "file.open", json!({"path": p})) { + Ok(r) => waiting = crate::menus::waits_on_dialog(&r), + Err(e) => errors.push(crate::i18n::tr_args("Cannot open dropped project {}: {}", &[&p, &e])), + } } + let mut aep = aep; + if waiting { + let skipped = rest.len() + aep.len(); + if skipped > 0 { + errors.push(crate::i18n::tr_args("Skipped {} more file(s) while a dialog is open; import them again after it", &[&skipped])); + } + rest.clear(); + aep.clear(); + } + + // One with losses asks first; the projects after it wait for the answer, the other files + // import meanwhile. The errors name their files. + errors.extend(crate::menus::import_aep_projects(app, aep)); if !rest.is_empty() { let viewer = app @@ -101,7 +134,7 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { .is_some_and(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])).contains(p)) }); - let at = if proj.is_empty() && !over_timeline { + let at = if !has_project && !over_timeline { ctx.input(|i| i.pointer.hover_pos()).filter(|p| viewer.is_some_and(|v| v.contains(*p))).and_then(|p| crate::panels::viewer::screen_to_comp(&ctx, p)) } else { None @@ -116,11 +149,13 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { let mut params = json!({ "paths": rest, "drag": true, - "addToComp": at.is_some() || over_timeline, + "addToComp": !has_project && (at.is_some() || over_timeline), "position": at, "background": true }); - if !layered { + // While the losses prompt of a project dropped with it shows, a Photoshop file imports + // with Default Drag Import As: one dialog at a time. + if !layered || crate::panels::aep_losses::asking(app) { params["importAs"] = json!(app.session.prefs.drag_import_as()); } if alt { diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index 5a0d94ddf..d7e99046b 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -722,3 +722,165 @@ fn illustrator_import_offers_and_applies_retain_layer_sizes() { assert_eq!((f.width, f.height), (30, 20)); assert!(f.layer.as_ref().unwrap().layer_size); } + +/// An After Effects project, written by File ▸ Export ▸ Adobe After Effects Project… to a folder of its +/// own: composition "Intro" with a solid. +fn aep_file(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("ec-ui-aep-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Intro.aep"); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Intro", "width": 320, "height": 180, "frameRate": 30, "duration": 2})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Backdrop", "color": "#204060"})).unwrap(); + s.execute("file.exportAep", json!({"path": path.to_string_lossy(), "allowLosses": true})).unwrap(); + path +} + +/// The open composition is "Intro", in the folder named after the imported file. +fn intro_is_open_in_its_folder(h: &Harness<'_, EffectcraftApp>) { + let s = &h.state().session; + let comp = s.project.item(s.active_comp_id().expect("a composition is open")).unwrap(); + assert_eq!(comp.name, "Intro"); + let folder = s.project.item(comp.parent.expect("in a folder")).unwrap(); + assert_eq!(folder.name, "Intro.aep"); + assert!(matches!(folder.kind, effectcraft_engine::project::ItemKind::Folder)); +} + +/// Dropped After Effects projects import as File ▸ Import ▸ Adobe After Effects Project… does +/// (they were refused as unsupported footage): each in a folder named after its file, the last +/// one's composition open. With nothing lost, nothing is asked. +#[test] +fn dropped_after_effects_projects_import_and_open_their_composition() { + let (mut h, _) = harness(); + let aep = aep_file("drop"); + h.event(Event::PointerMoved(rect(&h, "viewer.area").center())); + h.step(); + let outro = aep.with_file_name("Outro.aep"); + std::fs::copy(&aep, &outro).unwrap(); + h.input_mut().dropped_files.push(Arc::new(Dropped(outro))); + h.input_mut().dropped_files.push(Arc::new(Dropped(aep))); + h.run_steps(3); + assert_eq!(h.state().dialog, None, "nothing is lost, so nothing to ask"); + assert!(h.state().session.project.items.values().any(|i| i.name == "Outro.aep"), "the first project too"); + intro_is_open_in_its_folder(&h); + assert_eq!(h.state().ui.status, "", "no errors"); +} + +/// File ▸ Import ▸ File… offers After Effects projects; a picked one imports as a project, and +/// the other files picked with it as footage. +#[test] +fn import_file_dialog_imports_a_picked_after_effects_project_with_the_other_files() { + let (mut h, _) = harness(); + let aep = aep_file("pick"); + let still = aep.with_file_name("still.png"); + std::fs::write(&still, b"").unwrap(); + let offered = Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen = offered.clone(); + let picked = vec![aep.to_string_lossy().to_string(), still.to_string_lossy().to_string()]; + h.state_mut().session.importer = Some(Arc::new(Stills)); + h.state_mut().hooks.pick_files = Some(Box::new(move |exts: &[&str]| { + *seen.lock().unwrap() = exts.iter().map(|e| e.to_string()).collect(); + picked.clone() + })); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({})).unwrap(); + assert!(["aep", "aepx"].iter().all(|e| offered.lock().unwrap().iter().any(|o| o == e)), "{:?}", offered.lock().unwrap()); + step_until(&mut h, |h| footage_items(h).iter().any(|(n, _)| n == "still.png")); + intro_is_open_in_its_folder(&h); +} + +/// Footage dropped with an After Effects project goes to the Project panel only, also over the +/// Timeline: the project opens its own composition, which the footage does not join. +#[test] +fn footage_dropped_with_an_after_effects_project_stays_in_the_project_panel() { + let (mut h, _) = harness(); + let aep = aep_file("with-footage"); + let still = aep.with_file_name("still.png"); + std::fs::write(&still, b"").unwrap(); + h.state_mut().session.importer = Some(Arc::new(Stills)); + h.event(Event::PointerMoved(rect(&h, "panel.Timeline").center())); + h.step(); + h.input_mut().dropped_files.push(Arc::new(Dropped(still))); + h.input_mut().dropped_files.push(Arc::new(Dropped(aep))); + h.run_steps(3); + step_until(&mut h, |h| footage_items(h).iter().any(|(n, _)| n == "still.png")); + h.run_steps(2); + intro_is_open_in_its_folder(&h); + let s = &h.state().session; + assert!(s.project.comps().all(|(_, c)| c.layers.iter().all(|l| l.name != "still.png")), "no composition got a still.png layer"); +} + +/// A dropped After Effects project with parts that cannot come over asks first (the losses +/// prompt). The projects dropped with it wait for the answer; the other files import meanwhile, +/// a Photoshop file with Default Drag Import As (one dialog at a time). Cancel skips only the +/// project it asks about; Import Anyway imports it. +#[test] +fn a_dropped_after_effects_project_with_losses_asks_first() { + let (mut h, _) = harness(); + let intro = aep_file("losses"); + let lossy = intro.with_file_name("Synthetic.aep"); + std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../aep/tests/fixtures/synthetic.aep"), &lossy).unwrap(); + h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) })); + h.state_mut().session.prefs.import.drag_import_as = "footage".into(); + for path in [lossy.clone(), intro.clone(), intro.with_file_name("Layers.PSD"), intro.with_file_name("still.png")] { + h.input_mut().dropped_files.push(Arc::new(Dropped(path))); + } + h.run_steps(3); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::AepLosses), "the drop asks first"); + assert_eq!(h.state_mut().dialog_state_mut().aep_losses.queue, [intro.to_string_lossy().to_string()], "the other project waits"); + let named = |h: &Harness<'_, EffectcraftApp>, name: &str| h.state().session.project.items.values().any(|i| i.name == name); + assert!(!named(&h, "Synthetic.aep") && !named(&h, "Intro.aep"), "no project imported before the answer"); + step_until(&mut h, |h| ["Layers.PSD", "still.png"].iter().all(|n| footage_items(h).iter().any(|(f, _)| f == n))); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::AepLosses), "the footage did not replace the prompt"); + + click_id(&mut h, "dialog.aepLosses.cancel"); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert!(!named(&h, "Synthetic.aep"), "Cancel skips the project it asked about"); + intro_is_open_in_its_folder(&h); + + h.input_mut().dropped_files.push(Arc::new(Dropped(lossy))); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::AepLosses)); + click_id(&mut h, "dialog.aepLosses.continue"); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert!(named(&h, "Synthetic.aep"), "Import Anyway imports it"); +} + +/// An After Effects project with nothing lost imports at once, so a Photoshop file dropped with +/// it still asks how to import it (Import Photoshop File). +#[test] +fn a_photoshop_file_dropped_with_an_after_effects_project_still_asks() { + let (mut h, _) = harness(); + h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) })); + let aep = aep_file("with-psd"); + h.input_mut().dropped_files.push(Arc::new(Dropped(aep.with_file_name("Layers.PSD")))); + h.input_mut().dropped_files.push(Arc::new(Dropped(aep))); + h.run_steps(2); + intro_is_open_in_its_folder(&h); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form), "Import Photoshop File asks"); +} + +/// A dropped file that is not an After Effects project reports why, changes nothing, and the +/// other files of the drop still import. +#[test] +fn a_corrupt_dropped_after_effects_project_reports_and_keeps_the_other_files() { + let (mut h, _) = harness(); + let dir = aep_file("corrupt").parent().unwrap().to_path_buf(); + let broken = dir.join("Broken.aep"); + std::fs::write(&broken, b"not an After Effects project").unwrap(); + let still = dir.join("still.png"); + std::fs::write(&still, b"").unwrap(); + h.state_mut().session.importer = Some(Arc::new(Stills)); + let comp = h.state().session.active_comp_id(); + h.input_mut().dropped_files.push(Arc::new(Dropped(broken))); + h.input_mut().dropped_files.push(Arc::new(Dropped(still))); + h.run_steps(3); + step_until(&mut h, |h| footage_items(h).iter().any(|(n, _)| n == "still.png")); + let status = h.state().ui.status.clone(); + assert!(status.contains("Broken.aep"), "the error names the file: {status}"); + assert!(!h.state().session.project.items.values().any(|i| i.name == "Broken.aep")); + assert_eq!(h.state().session.active_comp_id(), comp, "the open composition stays"); +} diff --git a/docs/aep-export.md b/docs/aep-export.md index e8b89b424..3d30441a1 100644 --- a/docs/aep-export.md +++ b/docs/aep-export.md @@ -2,7 +2,7 @@ > **Last reviewed:** 2026-10-19 · **Last updated:** 2026-11-05 · **Change:** a 3D layer's changed Material Options are reported, shape and text animator colours outside 0 to 1 are clamped and reported, and a popup expression is no longer reported where the option order is the same (2026-11-05); before that: composition settings the base writes at their defaults (Preserve Frame Rate / Resolution, motion blur samples, adaptive sample limit) and render queue items are reported, and a sequence's folder time is read only when its file passes the document-path policy (2026-11-04); before that: every chunk is built in code, no template is read (2026-10-29); before that: Curves and Mesh Warp are written (R53); before that: a switched-off shape item or layer style is written switched off (R52, 986-988); before that: a reversed layer (negative stretch) is written as After Effects stores it (R36, 981-983); before that: an explicit None (`NO_LAYER`) on Compound Blur, Displacement Map or Set Matte is written as None, and Layer Control on a named layer no longer reports a source choice; before that: layer styles are written (R51, 950-980; read back in After Effects: `export-tests\styles`); before that: an image sequence's folder modification time is written when the caller knows it (`sspc@118`, R50: without it After Effects rescans the folder on open and warns about every gap of a sparse sequence in a modal dialog); before that: an output space equal to the working space is not reported (After Effects renders in the working space, 902; the importer sets it so for Rec.709 Gamma 2.4); before that: one effect table for both directions again: the 27 effects of the importer round 3 and the Layer Control are written too (prototypes from 903 and 076, layer parameters by layer id; not read back in After Effects yet); before that: the importer round 3 is merged: Offset Paths' Copy Offset is written as EffectCraft's value + 1 (826-828, the importer's inverse); before that: sparse sequences checked (860, written as After Effects stores them); before that: the Rec.709 Gamma 2.4 working space is written with a profile authored here (R49, g5); before that: text animators with range selectors are written (R48, t8); before that: a footage layer running past its source is ended there on import, as After Effects does, and less than a frame past is not a loss (LayerTiming); before that: Anchor Point and mask path ease are written (R47); before that: shape operators (polystar, gradients, trim paths, round corners, merge / offset paths, repeater) and keys and expressions on shape properties are written (R46, o2); before that: every effect of the importer's table is written, through one shared table and records built per parameter type (R45, e2); before that: time remapping is written (319, 354, r1, R44); before that: the character and paragraph settings are read back in After Effects (t6) and right-to-left justification is written relative to the direction (t7, R43); before that: the character and paragraph settings the importer decodes are written (scales, baseline shift, kerning, ligatures, tsume, fill / stroke switches and width, justified variants, indents, spacing, composer, direction); before that: track mattes, markers, cameras, lights and shape layers (R39-R42), keys on stretched layers, conformed clips, tga / psd / ai / eps, project settings (620-634, R37), superscript / subscript (526-528); before: precomposition layers (124, 257, 258, 600-602), the effects Tint, Gradient Ramp and Exposure (356-397, 610), footage (351, 353, 603-607, 611-613), text layers (110, 245, t1-t4), the 3D switch (015) and time stretch (018, 316, 317) are exported · **Target:** Adobe After Effects 2026 -File ▸ Export ▸ After Effects Project… (`file.exportAep`) writes the open project as an `.aep` that +File ▸ Export ▸ Adobe After Effects Project… (`file.exportAep`) writes the open project as an `.aep` that After Effects 2026 opens. It is deliberately narrow: what it writes is exact, tested and checked against After Effects itself; everything else is **reported** and never dropped silently. The importer ([aep-import.md](aep-import.md)) is the mirror image; both rest on the clean-room format diff --git a/docs/aep-import.md b/docs/aep-import.md index f41ba3081..c704e0a89 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,9 +1,16 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-07 · **Change:** After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-10 · **Change:** dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 -File ▸ Import ▸ After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` +File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). +A project dropped on the window, or picked in an Import File dialog (File ▸ Import ▸ File… or +Multiple Files…, the Home screen, the Project panel), imports the same way. Several are asked +about one at a time: while the losses prompt shows, the others wait, and Cancel skips only the +one it asks about. The other files of the drop or pick import as footage, into the Project panel +only (not into a composition, as the project opens its own); while the prompt shows, a Photoshop file or image sequence imports with its defaults instead of +opening its own dialog. Agents giving an `.aep` to `file.import` get the refusal when parts would +be lost (`file.importAep` takes `allowLosses` and `dryRun`). It is deliberately narrow: what it brings over is exact and tested, everything else is **reported** and never dropped silently. Wider coverage grows with the reader's verified field layouts ([aep-format.md](aep-format.md)); this page lists what exists today. @@ -496,7 +503,7 @@ generated corpus and its case numbers, not a claim about what was or was not see ## Export -The other direction, File ▸ Export ▸ After Effects Project… (`file.exportAep`), is described in +The other direction, File ▸ Export ▸ Adobe After Effects Project… (`file.exportAep`), is described in [aep-export.md](aep-export.md). ## Not done yet diff --git a/docs/agents.md b/docs/agents.md index ebf158029..ae9b1a6f6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -322,6 +322,10 @@ build real projects through these interfaces; they are worked examples of everyt then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. +* **Projects**: `file.import` takes `.ecproj` / `.ecprojx` and After Effects projects (`.aep`, `.aepx`) + too, each into a folder named after it (`projects` in the result). An After Effects project that + would lose parts is refused (an `errors` entry, nothing imported): `file.importAep {"path": …, + "dryRun": true}` lists the losses, `"allowLosses": true` imports the rest. * **Image sequences**: `file.import {"paths":["/r/shot_0001.png"], "sequence": true}` imports the file's whole numbered run as one item (several picked frames: that range; a folder: each run in it, also without `sequence`). Without `"sequence": true` a picked or dropped numbered file imports diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0e98bda58..fd908a50f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -22,7 +22,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Format | After Effects | EffectCraft read | Write | Fidelity / notes | Tested by | |---|---|---|---|---|---| -| Project `.aep` | R/W (native) | partial | partial | Main file format; the clean-room method is in [aep-format.md](aep-format.md), the maintainers' decision on it is open (#190). `crates/aep` reads and writes the RIFX container losslessly (byte-identical round trip) with a typed view of the decoded fields, from our own generated files only ([aep-format.md](aep-format.md)). File ▸ Import ▸ After Effects Project… (`file.importAep`, `effectcraft_aep::import`): folders, project settings, compositions, every layer kind, Transform, keyframes, expressions, masks, text with animators, shapes, cameras, lights, track mattes, markers, time remapping, layer styles, the built-in effects of a table shared with the exporter (Lumetri Color, Curves, Levels, Glow, Fractal Noise, Gradient Ramp, … and the expression controls), footage and image sequences; everything else is reported ([aep-import.md](aep-import.md)). File ▸ Export ▸ After Effects Project… (`file.exportAep`, `effectcraft_aep::export`) writes the same scope with every chunk built in code; exports were opened and read back in After Effects 2026 ([aep-export.md](aep-export.md)); everything else is reported. Effects outside the table (plug-ins, …) are not converted | `crates/aep` tests (`tests/container`, `tests/import`, `tests/export`) | +| Project `.aep` | R/W (native) | partial | partial | Main file format; the clean-room method is in [aep-format.md](aep-format.md), the maintainers' decision on it is open (#190). `crates/aep` reads and writes the RIFX container losslessly (byte-identical round trip) with a typed view of the decoded fields, from our own generated files only ([aep-format.md](aep-format.md)). File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`, `effectcraft_aep::import`): folders, project settings, compositions, every layer kind, Transform, keyframes, expressions, masks, text with animators, shapes, cameras, lights, track mattes, markers, time remapping, layer styles, the built-in effects of a table shared with the exporter (Lumetri Color, Curves, Levels, Glow, Fractal Noise, Gradient Ramp, … and the expression controls), footage and image sequences; everything else is reported ([aep-import.md](aep-import.md)). File ▸ Export ▸ Adobe After Effects Project… (`file.exportAep`, `effectcraft_aep::export`) writes the same scope with every chunk built in code; exports were opened and read back in After Effects 2026 ([aep-export.md](aep-export.md)); everything else is reported. Effects outside the table (plug-ins, …) are not converted | `crates/aep` tests (`tests/container`, `tests/import`, `tests/export`) | | XML project `.aepx` | R/W | partial | none | Read into the same chunk tree as `.aep` by `crates/aep` (`read_aepx`; mapping in [aep-format.md](aep-format.md) section 1), so `file.importAep` imports it with the same coverage as `.aep`; not written | `crates/aep` `aepx` tests (container and import) | | Templates `.aet` / `.aetx` | R/W | none | none | Own `.ectemplate` (Home ▸ Templates, Save as Template) | `templates.rs` tests | | Motion Graphics template `.mogrt` | W (and R in Premiere) | none | none | Own `.ectemplate` Essential Graphics templates; FilmCraft rejects our `.fcgt` export (#611) | `essential.rs` tests | diff --git a/docs/footage.md b/docs/footage.md index 7841dc84c..7a0639c02 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -70,6 +70,9 @@ next to it. Agents: `file.importProject {"path": "/projects/titles.ecproj"}` returns the comps, the root comps, `primaryComp` (the only root comp, if there is one) and any missing media. +After Effects projects (`.aep`, `.aepx`) given to File ▸ Import ▸ File… or dropped on the window +import as File ▸ Import ▸ Adobe After Effects Project… does ([aep-import.md](aep-import.md)). + ## Finding a layer's source in the Project panel Right-click a layer in the Timeline or Composition panel and choose **Reveal Layer Source in From 51ef332c05dd96c6c04b6f50b01125730e66b75b Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 09:23:04 +0530 Subject: [PATCH 020/227] #2859: keep timeline marquee anchored while scrolling --- crates/ui-egui/src/panels/timeline.rs | 92 +----- crates/ui-egui/src/panels/timeline/marquee.rs | 168 +++++++++++ crates/ui-egui/src/panels/timeline/tests.rs | 7 +- .../ui-egui/tests/it/ui_timeline_marquee.rs | 261 +++++++++++++++++- 4 files changed, 442 insertions(+), 86 deletions(-) create mode 100644 crates/ui-egui/src/panels/timeline/marquee.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d7017e7fd..be0ef05d5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -22,7 +22,9 @@ use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; -#[derive(Clone, Debug)] +mod marquee; + +#[derive(Clone, Debug, PartialEq)] enum RowKind { Layer, Group { @@ -328,7 +330,7 @@ fn focus_expression(ctx: &egui::Context, prop: u64, select: [usize; 2]) { ctx.data_mut(|d| d.insert_temp(expr_focus_id(), ExprFocus { prop, select, pass })); } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] struct Row { layer: LayerId, depth: usize, @@ -1969,8 +1971,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { g.max_scroll = (total_h - g.rows_rect.height() + g.rh).max(0.0); g.rows = rows; reveal_scroll(app, g); - wheel_zoom_scroll(app, ui, g); - pick_whip_edge_scroll(app, g); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); let ectx = EvalCtx { @@ -1986,6 +1986,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }; let mut rw = begin_rows(app, ui, tl); let g = &mut rw; + marquee::prepare(app, ui, g); + wheel_zoom_scroll(app, ui, &g.tl); + pick_whip_edge_scroll(app, &g.tl); + marquee::scroll(app, g); draw_rows(app, ui, g, &ectx); ui.set_clip_rect(g.full_clip); background_menus(app, ui, g); @@ -1997,7 +2001,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { group_reorder_drag(ui, g); quick_offset_follow(g); key_drag_follow(app, g); - box_select(app, ui, g); + marquee::finish(app, ui, g); current_time_indicator(app, g); let footer = footer_bar(app, ui, g); footer_zoom(app, ui, g, footer); @@ -4460,7 +4464,7 @@ fn background_menus(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) { } } -/// Click on the empty outline (its drag is the Timeline's one box, [`box_select`]). +/// Click on the empty outline (its drag is the Timeline's one box, [`marquee`]). fn outline_marquee(ui: &mut egui::Ui, g: &mut Rw) { let actions = &mut g.tl.actions; let outline_empty = g.outline_empty.clone(); @@ -4578,7 +4582,7 @@ fn alt_scale_and_graph(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, // below the last row) deselects the layers and their keys and properties, as in After } -/// Click on empty track area (its drag is the Timeline's one box, [`box_select`]). +/// Click on empty track area (its drag is the Timeline's one box, [`marquee`]). fn graph_empty_area(ui: &mut egui::Ui, g: &mut Rw) { let actions = &mut g.tl.actions; let empty = g.empty.clone(); @@ -4797,80 +4801,6 @@ fn keys_in_box(comp: &Comp, rows: &[Row], tm: &TMap, top: f32, rh: f32, br: Rect keys } -/// What a box over the Timeline selects: the layer rows it crosses (locked layers stay out) and -/// the keys whose markers it touches. A box that reaches the outline (started on either side) -/// selects the rows it crosses; one kept inside the time graph selects keys only, as before. -/// Keys exist only in the time graph, so the box is cut at its left edge (`graph_x0`; a key -/// scrolled out under the outline isn't touched). -/// `graph_on`: the Graph Editor shows no key markers here. -#[allow(clippy::too_many_arguments)] -fn box_targets( - comp: &Comp, - rows: &[Row], - hit_rows: &[(Rect, Row)], - tm: &TMap, - top: f32, - rh: f32, - graph_x0: f32, - graph_on: bool, - br: Rect, -) -> (Vec, Vec) { - let layers = hit_rows - .iter() - .filter(|(r, row)| br.min.x < graph_x0 && matches!(row.kind, RowKind::Layer) && r.intersects(br)) - .filter(|(_, row)| comp.layer(row.layer).is_some_and(|l| !l.switches.locked)) - .map(|(_, row)| row.layer.0) - .collect(); - let in_graph = Rect::from_min_max(pos2(br.min.x.max(graph_x0), br.min.y), pos2(br.max.x, br.max.y)); - let keys = if graph_on || in_graph.width() < 0.0 { vec![] } else { keys_in_box(comp, rows, tm, top, rh, in_graph) }; - (layers, keys) -} - -/// The Timeline's one selection box, from the empty outline or the empty time graph: it spans both -/// columns as it is dragged (the pointer may cross the boundary either way) and on release selects -/// the layer rows it crosses and the keys inside it, as in After Effects. -fn box_select(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) { - let t = g.tl.t; - let tm = g.tl.tm; - let rows_rect = g.tl.rows_rect; - let rh = g.tl.rh; - let graph_x0 = g.tl.graph_x0; - let graph_on = g.graph_on; - let ctx = g.tl.ctx.clone(); - let comp = g.tl.comp.clone(); - let rows = g.tl.rows.clone(); - let origin = [&g.outline_empty, &g.empty].into_iter().find(|r| r.dragged() || r.drag_stopped()).cloned(); - let Some(origin) = origin else { return }; - let box_of = |o: &egui::Response| { - let start = ctx.input(|i| i.pointer.press_origin()); - start.zip(o.interact_pointer_pos().or_else(|| ctx.input(|i| i.pointer.latest_pos()))).map(|(a, b)| Rect::from_two_pos(a, b).intersect(rows_rect)) - }; - let id = egui::Id::unique("tl-box"); - if origin.dragged() - && let Some(br) = box_of(&origin) - { - let bp = g.tl.p.with_clip_rect(rows_rect); - bp.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12)); - bp.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); - ctx.data_mut(|d| d.insert_temp(id, br)); - } - if origin.drag_stopped() - && let Some(br) = ctx.data(|d| d.get_temp::(id)) - { - ctx.data_mut(|d| d.remove::(id)); - let (layers, touched) = box_targets(&comp, &rows, &g.hit_rows, &tm, rows_rect.min.y - app.ui.timeline.scroll_y, rh, graph_x0, graph_on, br); - let mods = ui.input(|i| i.modifiers); - let add = mods.shift || mods.command; - let actions = &mut g.tl.actions; - // A box that catches nothing leaves nothing selected (layers included), unless it adds. - actions.push(("layer.select".into(), json!({"layers": layers, "add": add}))); - if !touched.is_empty() { - let keys: Vec = touched.iter().map(|k| json!({"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()})).collect(); - actions.push(("keys.select".into(), json!({"keys": keys, "add": true, "selectProperties": true}))); - } - } -} - fn current_time_indicator(app: &mut EffectcraftApp, g: &Rw) { let t = g.tl.t; let time = g.tl.time; diff --git a/crates/ui-egui/src/panels/timeline/marquee.rs b/crates/ui-egui/src/panels/timeline/marquee.rs new file mode 100644 index 000000000..5b1cd1df9 --- /dev/null +++ b/crates/ui-egui/src/panels/timeline/marquee.rs @@ -0,0 +1,168 @@ +//! A marquee owns content coordinates; clipping belongs only to its painter. + +use std::sync::Arc; + +use effectcraft_engine::project::Comp; +use egui::{Id, Pos2, Rect, Stroke, StrokeKind, pos2}; +use serde_json::json; + +use super::{Row, RowKind, Rw, TMap, edge_scroll_speed, keys_in_box, row_height}; +use crate::EffectcraftApp; + +#[derive(Clone)] +struct Marquee { + owner: Id, + comp: Arc, + rows: Arc>, + graph_on: bool, + layout: (Rect, f32, f32), + time: f64, + y: f32, + outline_x: Option, + dragging: bool, +} + +fn id() -> Id { + Id::unique("tl-marquee") +} + +/// Capture on press, before wheel handling, even if the drag threshold is not crossed yet. +pub(super) fn prepare(app: &EffectcraftApp, ui: &egui::Ui, g: &Rw) { + let ctx = &g.tl.ctx; + let (pressed, down, released, escape, focused) = + ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released(), i.key_pressed(egui::Key::Escape), i.focused)); + if let Some(mut drag) = ctx.data(|d| d.get_temp::(id())) { + let owned = ctx.dragged_id() == Some(drag.owner); + let dropped = released && ctx.drag_stopped_id() == Some(drag.owner); + let valid = ui.is_enabled() + && focused + && !escape + && Arc::ptr_eq(&drag.comp, &g.tl.comp) + && *drag.rows == *g.tl.rows + && drag.graph_on == g.graph_on + && drag.layout == (g.tl.rect, g.tl.rh, g.tl.graph_x0) + && (dropped || (down && (owned || (!drag.dragging && ctx.dragged_id().is_none())))); + drag.dragging |= owned; + ctx.data_mut(|d| { + if valid { + d.insert_temp(id(), drag); + } else { + d.remove::(id()); + } + }); + } + if pressed && ui.is_enabled() && focused && !escape { + let owner = [&g.outline_empty, &g.empty].into_iter().find(|r| r.is_pointer_button_down_on()); + if let Some(owner) = owner + && let Some(p) = ctx.input(|i| i.pointer.press_origin()) + { + ctx.data_mut(|d| { + d.insert_temp( + id(), + Marquee { + owner: owner.id, + comp: g.tl.comp.clone(), + rows: g.tl.rows.clone(), + graph_on: g.graph_on, + layout: (g.tl.rect, g.tl.rh, g.tl.graph_x0), + time: g.tl.tm.t(p.x.max(g.tl.graph_x0)), + y: p.y - g.tl.rows_rect.min.y + app.ui.timeline.scroll_y, + outline_x: (p.x < g.tl.graph_x0).then_some(p.x), + dragging: false, + }, + ); + }); + } + } +} + +/// Only the marquee owner scrolls, with the same bounded edge speed as a pick whip. +pub(super) fn scroll(app: &mut EffectcraftApp, g: &Rw) { + let ctx = &g.tl.ctx; + if let Some(drag) = ctx.data(|d| d.get_temp::(id())) + && ctx.dragged_id() == Some(drag.owner) + && ctx.input(|i| i.pointer.primary_down()) + && let Some(p) = ctx.input(|i| i.pointer.latest_pos()) + { + let speed = edge_scroll_speed(g.tl.rows_rect, p.y, g.tl.rh); + let old = app.ui.timeline.scroll_y; + app.ui.timeline.scroll_y = (old + speed * ctx.input(|i| i.stable_dt).min(0.1)).clamp(0.0, g.tl.max_scroll); + if app.ui.timeline.scroll_y != old { + ctx.request_repaint(); + } + } +} + +pub(super) struct SelectionBox { + pub keys: Rect, + pub outline: bool, +} + +impl Marquee { + fn selection(&self, g: &Rw, scroll: f32, pointer: Pos2) -> (Rect, SelectionBox) { + let tm = g.tl.tm; + let anchor = pos2(self.outline_x.unwrap_or_else(|| tm.x(self.time)), g.tl.rows_rect.min.y + self.y - scroll); + let paint = Rect::from_two_pos(anchor, pointer); + let graph = self.outline_x.is_none() || pointer.x >= g.tl.graph_x0; + let keys = if graph && !g.graph_on { + Rect::from_two_pos(pos2(tm.x(self.time), anchor.y), pos2(pointer.x.max(g.tl.graph_x0), pointer.y)) + } else { + Rect::NOTHING + }; + (paint, SelectionBox { keys, outline: self.outline_x.is_some() || pointer.x < g.tl.graph_x0 }) + } +} + +/// Query all logical rows; the renderer's visible hit rows are not selection data. +pub(super) fn targets( + comp: &Comp, + rows: &[Row], + tm: &TMap, + top: f32, + rh: f32, + paint: Rect, + selection: &SelectionBox, +) -> (Vec, Vec) { + let mut layers = vec![]; + let mut y = top; + for row in rows { + let bottom = y + row_height(row, rh); + if selection.outline + && matches!(row.kind, RowKind::Layer) + && y <= paint.max.y + && bottom >= paint.min.y + && comp.layer(row.layer).is_some_and(|l| !l.switches.locked) + { + layers.push(row.layer.0); + } + y = bottom; + } + (layers, keys_in_box(comp, rows, tm, top, rh, selection.keys)) +} + +pub(super) fn finish(app: &EffectcraftApp, ui: &egui::Ui, g: &mut Rw) { + let ctx = &g.tl.ctx; + let Some(drag) = ctx.data(|d| d.get_temp::(id())) else { return }; + let released = ctx.input(|i| i.pointer.primary_released()); + let dropped = released && ctx.drag_stopped_id() == Some(drag.owner); + if ctx.dragged_id() != Some(drag.owner) && !dropped { + return; + } + let Some(pointer) = ctx.input(|i| i.pointer.latest_pos()) else { return }; + let (paint, selection) = drag.selection(g, app.ui.timeline.scroll_y, pointer); + if dropped { + ctx.data_mut(|d| d.remove::(id())); + let (layers, keys) = targets(&g.tl.comp, &g.tl.rows, &g.tl.tm, g.tl.rows_rect.min.y - app.ui.timeline.scroll_y, g.tl.rh, paint, &selection); + let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); + g.tl.actions.push(("layer.select".into(), json!({"layers": layers, "add": add}))); + if !keys.is_empty() { + let keys: Vec<_> = keys.iter().map(|k| json!({"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()})).collect(); + g.tl.actions.push(("keys.select".into(), json!({"keys": keys, "add": true, "selectProperties": true}))); + } + } else { + let clip = if selection.outline { g.tl.rows_rect } else { g.right_clip }; + let painter = g.tl.p.with_clip_rect(clip); + painter.rect_filled(paint, 0.0, g.tl.t.accent.gamma_multiply(0.12)); + painter.rect_stroke(paint, 0.0, Stroke::new(1.0, g.tl.t.accent), StrokeKind::Inside); + } +} diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 983d80ae6..d4a801160 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -959,10 +959,11 @@ fn a_box_over_both_columns_selects_the_rows_it_crosses_and_the_keys_inside() { rows.push(Row { layer: LayerId(*id), depth: 1, kind: RowKind::Prop { uid: uid(*id) } }); } // Rows are 20 pt and 400 pt wide: the outline is left of x 200, a key at 0 s sits at x 205. - let hit_rows: Vec<(Rect, Row)> = - rows.iter().enumerate().map(|(i, r)| (Rect::from_min_size(pos2(0.0, 20.0 * i as f32), vec2(400.0, 20.0)), r.clone())).collect(); let tm = TMap { x0: 205.0, start: 0.0, pps: 100.0 }; - let go = |br: Rect| box_targets(comp, &rows, &hit_rows, &tm, 0.0, 20.0, 200.0, false, br); + let go = |br: Rect| { + let keys = if br.max.x < 200.0 { Rect::NOTHING } else { Rect::from_min_max(pos2(br.min.x.max(200.0), br.min.y), br.max) }; + marquee::targets(comp, &rows, &tm, 0.0, 20.0, br, &marquee::SelectionBox { keys, outline: br.min.x < 200.0 }) + }; let keyed = |ks: &[effectcraft_engine::KeyRef]| ks.iter().map(|k| k.layer.0).collect::>(); // From the outline across the boundary: layer A's row (y 0..20) and B's row, and their keys. let (ls, ks) = go(Rect::from_min_max(pos2(10.0, 5.0), pos2(260.0, 65.0))); diff --git a/crates/ui-egui/tests/it/ui_timeline_marquee.rs b/crates/ui-egui/tests/it/ui_timeline_marquee.rs index bf6dab74e..ce3179751 100644 --- a/crates/ui-egui/tests/it/ui_timeline_marquee.rs +++ b/crates/ui-egui/tests/it/ui_timeline_marquee.rs @@ -13,9 +13,9 @@ use serde_json::json; const REACH: f32 = 5.5; /// A comp with one solid, its Opacity keys at 1 s and 2 s (linear) revealed. -fn harness() -> (Harness<'static, EffectcraftApp>, u64) { +fn harness_for(duration: f64) -> (Harness<'static, EffectcraftApp>, u64) { let mut s = Session::default(); - s.execute("comp.new", json!({"name": "Box", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + s.execute("comp.new", json!({"name": "Box", "width": 320, "height": 180, "frameRate": 30, "duration": duration})).unwrap(); let id = s.execute("layer.newSolid", json!({"name": "L", "color": "#e04020", "width": 80, "height": 80})).unwrap()["layer"].as_u64().unwrap(); for (t, v) in [(1.0, 0.0), (2.0, 100.0)] { s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": v})).unwrap(); @@ -29,6 +29,10 @@ fn harness() -> (Harness<'static, EffectcraftApp>, u64) { (h, id) } +fn harness() -> (Harness<'static, EffectcraftApp>, u64) { + harness_for(4.0) +} + /// The center of the key at `secs`: the middle of its hit rect, which is centered on the glyph. fn key_center(h: &Harness<'_, EffectcraftApp>, id: u64, secs: f64) -> Pos2 { let comp = h.state().session.active_comp().unwrap(); @@ -122,3 +126,256 @@ fn a_box_dragged_across_the_outline_and_the_graph_selects_rows_and_keys() { assert_eq!(keys, [1.0, 1.0]); assert_eq!(selected(&h), (both.clone(), both), "graph → outline"); } + +fn rows_harness() -> (Harness<'static, EffectcraftApp>, Vec) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Rows", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let mut ids = vec![]; + for i in 0..30 { + let id = s.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": 1.0, "value": 50})).unwrap(); + ids.push(id); + } + s.execute("layer.select", json!({"layers": ids})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap(); + h.run_steps(4); + (h, ids) +} + +#[test] +fn scrolling_rows_during_a_marquee_keeps_offscreen_keys_in_the_box() { + let (mut h, ids) = rows_harness(); + let top = *ids.last().unwrap(); + let c = key_center(&h, top, 1.0); + let from = c - vec2(20.0, 5.0); + let to = c + vec2(20.0, 30.0); + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(Event::PointerMoved(to)); + h.run_steps(2); + h.event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -400.0), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + h.run_steps(20); + assert!(h.state().ui.timeline.scroll_y > 300.0, "the real wheel handler scrolled rows"); + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let selected: Vec<_> = h.state().session.state.selected_keys.iter().map(|k| k.layer.0).collect(); + assert!(selected.contains(&top), "the key on layer {top} scrolled offscreen but remains inside the content-anchored marquee; actual layers {selected:?}"); +} + +fn press(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(Event::PointerMoved(to)); + h.run_steps(2); +} + +fn release(h: &mut Harness<'_, EffectcraftApp>, to: Pos2) { + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn wheel(h: &mut Harness<'_, EffectcraftApp>, delta: Vec2) { + h.event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta, + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + h.run_steps(20); +} + +fn times(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.state.selected_keys.iter().map(|k| k.time.seconds()).collect() +} + +#[test] +fn horizontal_scroll_keeps_hidden_keys_without_selecting_outline_layers() { + let (mut h, id) = harness_for(20.0); + h.state_mut().ui.timeline.pps = Some(200.0); + h.run_steps(3); + let c = key_center(&h, id, 1.0); + let to = c + vec2(20.0, 5.0); + press(&mut h, c - vec2(20.0, 5.0), to); + wheel(&mut h, vec2(-400.0, 0.0)); + assert!(h.state().ui.timeline.start > 1.5, "time panned: {}", h.state().ui.timeline.start); + let graph_left = h.state().auto.find("timeline.ruler").unwrap().rect[0]; + let box_shapes: Vec<_> = + h.output().shapes.iter().filter(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect.max == to && (r.rect.height() - 10.0).abs() < 0.01)).collect(); + assert!(!box_shapes.is_empty(), "the held marquee is drawn"); + assert!(box_shapes.iter().all(|s| s.clip_rect.min.x >= graph_left), "a graph-only box cannot paint over the outline when its anchor scrolls out"); + release(&mut h, to); + assert_eq!(times(&h), [1.0, 2.0]); + // Keys select their owning layer, but panning the anchor under the outline must not select + // unrelated layer rows. The separate cross-column test below covers those rows. + assert_eq!(h.state().session.state.selected_layers, [LayerId(id)]); +} + +#[test] +fn shrinking_after_scrolling_removes_keys_again_and_selection_does_not_edit_the_project() { + let (mut h, id) = harness_for(20.0); + h.state_mut().ui.timeline.pps = Some(200.0); + h.run_steps(3); + let before = h.state().session.project.to_json(); + let revision = h.state().session.revision; + let undo = h.state().session.history.undo.len(); + let c = key_center(&h, id, 1.0); + let to = c + vec2(20.0, 5.0); + press(&mut h, c - vec2(20.0, 5.0), to); + wheel(&mut h, vec2(-400.0, 0.0)); + wheel(&mut h, vec2(400.0, 0.0)); + assert!(h.state().ui.timeline.start < 0.01); + release(&mut h, to); + assert_eq!(times(&h), [1.0], "shrinking is not a union of every key passed"); + assert_eq!(h.state().session.project.to_json(), before); + assert_eq!(h.state().session.revision, revision); + assert_eq!(h.state().session.history.undo.len(), undo); +} + +#[test] +fn cancellation_preserves_selection_and_does_not_restart_until_a_new_press() { + for cancel in ["escape", "ownership", "focus", "layout", "resize", "edit", "comp"] { + let (mut h, id) = harness(); + h.state_mut().session.execute("keys.select", json!({"keys": [{"layer":id,"path":"transform/opacity","time":2.0}]})).unwrap(); + h.run_steps(2); + let c = key_center(&h, id, 1.0); + let to = c + vec2(10.0, 5.0); + press(&mut h, c - vec2(20.0, 5.0), to); + assert_eq!(times(&h), [2.0], "selection commits only on release"); + match cancel { + "escape" => h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }), + "ownership" => h.ctx.set_dragged_id(egui::Id::unique("another-owner")), + "focus" => h.input_mut().focused = false, + "layout" => h.state_mut().ui.timeline.search = "no matching rows".into(), + "resize" => { + h.set_size(vec2(1400.0, 900.0)); + } + "edit" => { + h.state_mut().session.execute("prop.set", json!({"layer":id,"path":"transform/opacity","value":75})).unwrap(); + } + "comp" => { + h.state_mut().session.execute("comp.new", json!({"name":"Other"})).unwrap(); + } + _ => unreachable!(), + } + h.run_steps(2); + let expected = times(&h); + h.event(Event::PointerMoved(to + vec2(1.0, 0.0))); + h.step(); + release(&mut h, to); + assert_eq!(times(&h), expected, "{cancel} cancelled without applying the box"); + if !matches!(cancel, "comp" | "edit") { + assert_eq!(times(&h), [2.0], "{cancel} preserves the prior selection"); + } + } +} + +#[test] +fn release_uses_the_release_position_and_additive_modifiers_and_excludes_locked_keys() { + for modifiers in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND] { + let (mut h, id) = harness(); + h.state_mut().session.execute("keys.select", json!({"keys": [{"layer":id,"path":"transform/opacity","time":2.0}]})).unwrap(); + h.run_steps(2); + let c = key_center(&h, id, 1.0); + press(&mut h, c - vec2(25.0, 5.0), c - vec2(15.0, 5.0)); + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerMoved(c + vec2(10.0, 5.0))); + h.event(Event::PointerButton { pos: c + vec2(10.0, 5.0), button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(2); + let mut actual = times(&h); + actual.sort_by(f64::total_cmp); + assert_eq!(actual, if modifiers == Modifiers::NONE { vec![1.0] } else { vec![1.0, 2.0] }); + h.state_mut().session.execute("layer.setSwitch", json!({"layers":[id],"switch":"lock","value":true})).unwrap(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert!(marquee(&mut h, c - vec2(20.0, 5.0), c + vec2(10.0, 5.0)).is_empty(), "locked keys stay excluded"); + } +} + +#[test] +fn crossing_into_the_outline_after_scrolling_selects_offscreen_layer_rows() { + let (mut h, ids) = rows_harness(); + let c = key_center(&h, *ids.last().unwrap(), 1.0); + let outline = h.state().auto.find("timeline.layerMarquee").unwrap().rect; + let to = pos2(outline[0] + 40.0, c.y + 30.0); + press(&mut h, c + vec2(20.0, -5.0), to); + wheel(&mut h, vec2(0.0, -400.0)); + assert!(h.state().ui.timeline.scroll_y > 300.0); + release(&mut h, to); + let selected = &h.state().session.state.selected_layers; + assert!(selected.contains(&LayerId(ids[ids.len() - 2])), "the second header is inside the box although it scrolled offscreen"); + assert!(selected.len() > 5, "all intervening rows are selected"); +} + +#[test] +fn a_stationary_pointer_at_either_edge_scrolls_only_until_release_or_cancel() { + let (mut h, ids) = rows_harness(); + let c = key_center(&h, *ids.last().unwrap(), 1.0); + let area = h.state().auto.find("timeline.layerMarquee").unwrap().rect; + let bottom = pos2(c.x + 20.0, area[1] + area[3] - 2.0); + press(&mut h, c - vec2(20.0, 5.0), bottom); + let before = h.state().ui.timeline.scroll_y; + h.run_steps(30); + assert!(h.state().ui.timeline.scroll_y > before + 50.0, "a stationary drag requests more frames and scrolls"); + let top = pos2(bottom.x, area[1] + 2.0); + h.event(Event::PointerMoved(top)); + h.step(); + let before = h.state().ui.timeline.scroll_y; + h.run_steps(10); + assert!(h.state().ui.timeline.scroll_y < before, "the top edge scrolls back"); + h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + let before = h.state().ui.timeline.scroll_y; + h.run_steps(10); + assert_eq!(h.state().ui.timeline.scroll_y, before, "Escape stops edge scrolling"); + release(&mut h, top); + assert!(times(&h).is_empty(), "cancelled box selects nothing"); +} + +#[test] +fn wheel_on_press_captures_the_old_anchor_and_wheel_on_release_uses_the_new_rows() { + for scroll_on_press in [true, false] { + let (mut h, ids) = rows_harness(); + let top = *ids.last().unwrap(); + let c = key_center(&h, top, 1.0); + let from = c - vec2(20.0, 5.0); + let to = c + vec2(20.0, 30.0); + if scroll_on_press { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + } else { + press(&mut h, from, to); + } + h.event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -400.0), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + if !scroll_on_press { + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + } + h.step(); + if scroll_on_press { + h.event(Event::PointerMoved(to)); + h.run_steps(20); + release(&mut h, to); + } + assert!(h.state().session.state.selected_keys.iter().any(|k| k.layer == LayerId(top)), "scroll_on_press={scroll_on_press}"); + } +} From eec3c51296abec2c035c52979b52d4acc82e988a Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 09:35:56 +0530 Subject: [PATCH 021/227] #3103: doc comments for the touched import and prompt functions --- crates/engine/src/cmd/aep.rs | 5 +++++ crates/engine/src/tests/aep_export.rs | 1 + crates/engine/src/tests/aep_import.rs | 1 + crates/ui-egui/src/panels/aep_losses.rs | 2 ++ crates/ui-egui/src/panels/home.rs | 2 ++ 5 files changed, 11 insertions(+) diff --git a/crates/engine/src/cmd/aep.rs b/crates/engine/src/cmd/aep.rs index 3445a478b..eab218100 100644 --- a/crates/engine/src/cmd/aep.rs +++ b/crates/engine/src/cmd/aep.rs @@ -22,6 +22,8 @@ pub fn is_aep_path(path: &str) -> bool { std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("aep") || e.eq_ignore_ascii_case("aepx")) } +/// `file.importAep`: the `.aep` / `.aepx` at `path` into the open project, in a folder named after +/// the file; refused ([`EngineError::Refused`]) when parts would be lost and `allowLosses` is not set. fn import_aep(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.importAep", "missing `path`"))?.to_string(); let allow_losses = b_p(p, "allowLosses").unwrap_or(false); @@ -97,6 +99,8 @@ fn import_aep(s: &mut Session, p: &Value) -> Result { })) } +/// `file.exportAep`: the open project as an `.aep` at `path`; refused ([`EngineError::Refused`]) +/// when parts would be lost and `allowLosses` is not set. fn export_aep(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.exportAep", "missing `path`"))?.to_string(); let allow_losses = b_p(p, "allowLosses").unwrap_or(false); @@ -133,6 +137,7 @@ fn export_aep(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "bytes": res.bytes.len(), "report": res.report})) } +/// File ▸ Import / Export ▸ Adobe After Effects Project…. pub fn specs() -> Vec { vec![ cmd!( diff --git a/crates/engine/src/tests/aep_export.rs b/crates/engine/src/tests/aep_export.rs index a716ef7bc..fc5ea29bc 100644 --- a/crates/engine/src/tests/aep_export.rs +++ b/crates/engine/src/tests/aep_export.rs @@ -73,6 +73,7 @@ fn bad_input_is_an_error_not_a_crash() { assert!(empty.execute_checked("file.exportAep", json!({"path": path})).is_ok()); } +/// File ▸ Export ▸ Adobe After Effects Project… is in the menu under that label. #[test] fn aep_export_menu_entry_exists() { let entries = crate::menus::entries(); diff --git a/crates/engine/src/tests/aep_import.rs b/crates/engine/src/tests/aep_import.rs index 7aba82983..c62987ddf 100644 --- a/crates/engine/src/tests/aep_import.rs +++ b/crates/engine/src/tests/aep_import.rs @@ -231,6 +231,7 @@ fn file_import_takes_after_effects_projects() { let _ = std::fs::remove_dir_all(&dir); } +/// File ▸ Import ▸ Adobe After Effects Project… is in the menu under that label. #[test] fn aep_menu_entry_exists() { let entries = crate::menus::entries(); diff --git a/crates/ui-egui/src/panels/aep_losses.rs b/crates/ui-egui/src/panels/aep_losses.rs index 14010a5df..4eb71104d 100644 --- a/crates/ui-egui/src/panels/aep_losses.rs +++ b/crates/ui-egui/src/panels/aep_losses.rs @@ -90,6 +90,8 @@ pub fn lines(report: &Value) -> Vec<(String, Vec)> { .collect() } +/// Draw the prompt for the conversion waiting on it: its losses by category, Cancel and Import / +/// Export Anyway (Escape cancels). pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let Some((id, _, report)) = app.dialog_state.aep_losses.command.clone() else { app.dialog = None; diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index e61d523b1..601f4f1ef 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -475,6 +475,8 @@ fn home_page( } } +/// Run the commands the Home screen's buttons and recent files asked for, and leave Home once one +/// opened something (not while a dialog it opened waits, nor when only the list changed). fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, serde_json::Value)>) { for (id, params) in actions { let before = (app.session.path.clone(), app.session.revision); From 384184a12b496f465065962d0578f388a4f44cb3 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 09:40:22 +0530 Subject: [PATCH 022/227] #2859: document marquee gesture contracts and regression cases --- crates/ui-egui/src/panels/timeline/marquee.rs | 2 ++ crates/ui-egui/tests/it/ui_timeline_marquee.rs | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/crates/ui-egui/src/panels/timeline/marquee.rs b/crates/ui-egui/src/panels/timeline/marquee.rs index 5b1cd1df9..1c64a7963 100644 --- a/crates/ui-egui/src/panels/timeline/marquee.rs +++ b/crates/ui-egui/src/panels/timeline/marquee.rs @@ -99,6 +99,7 @@ pub(super) struct SelectionBox { } impl Marquee { + /// Reproject the content anchor without clipping the logical selection bounds. fn selection(&self, g: &Rw, scroll: f32, pointer: Pos2) -> (Rect, SelectionBox) { let tm = g.tl.tm; let anchor = pos2(self.outline_x.unwrap_or_else(|| tm.x(self.time)), g.tl.rows_rect.min.y + self.y - scroll); @@ -140,6 +141,7 @@ pub(super) fn targets( (layers, keys_in_box(comp, rows, tm, top, rh, selection.keys)) } +/// Commit an owned primary release; otherwise paint without changing selection. pub(super) fn finish(app: &EffectcraftApp, ui: &egui::Ui, g: &mut Rw) { let ctx = &g.tl.ctx; let Some(drag) = ctx.data(|d| d.get_temp::(id())) else { return }; diff --git a/crates/ui-egui/tests/it/ui_timeline_marquee.rs b/crates/ui-egui/tests/it/ui_timeline_marquee.rs index ce3179751..9d72f08aa 100644 --- a/crates/ui-egui/tests/it/ui_timeline_marquee.rs +++ b/crates/ui-egui/tests/it/ui_timeline_marquee.rs @@ -127,6 +127,7 @@ fn a_box_dragged_across_the_outline_and_the_graph_selects_rows_and_keys() { assert_eq!(selected(&h), (both.clone(), both), "graph → outline"); } +/// Thirty expanded layers force row virtualization and leave room for wheel and edge scrolling. fn rows_harness() -> (Harness<'static, EffectcraftApp>, Vec) { let mut s = Session::default(); s.execute("comp.new", json!({"name": "Rows", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); @@ -145,6 +146,7 @@ fn rows_harness() -> (Harness<'static, EffectcraftApp>, Vec) { (h, ids) } +/// The originally enclosed key stays in the box when wheel scrolling takes its row offscreen. #[test] fn scrolling_rows_during_a_marquee_keeps_offscreen_keys_in_the_box() { let (mut h, ids) = rows_harness(); @@ -202,6 +204,7 @@ fn times(h: &Harness<'_, EffectcraftApp>) -> Vec { h.state().session.state.selected_keys.iter().map(|k| k.time.seconds()).collect() } +/// Panning time retains hidden keys while both painting and row selection respect the graph boundary. #[test] fn horizontal_scroll_keeps_hidden_keys_without_selecting_outline_layers() { let (mut h, id) = harness_for(20.0); @@ -224,6 +227,7 @@ fn horizontal_scroll_keeps_hidden_keys_without_selecting_outline_layers() { assert_eq!(h.state().session.state.selected_layers, [LayerId(id)]); } +/// Returning the viewport shrinks the box again without editing the document or its history. #[test] fn shrinking_after_scrolling_removes_keys_again_and_selection_does_not_edit_the_project() { let (mut h, id) = harness_for(20.0); @@ -245,6 +249,7 @@ fn shrinking_after_scrolling_removes_keys_again_and_selection_does_not_edit_the_ assert_eq!(h.state().session.history.undo.len(), undo); } +/// Invalidated gestures leave the prior selection intact even if the pointer keeps moving. #[test] fn cancellation_preserves_selection_and_does_not_restart_until_a_new_press() { for cancel in ["escape", "ownership", "focus", "layout", "resize", "edit", "comp"] { @@ -283,6 +288,7 @@ fn cancellation_preserves_selection_and_does_not_restart_until_a_new_press() { } } +/// A release-frame pointer move takes effect, modifiers add, and locked keys remain excluded. #[test] fn release_uses_the_release_position_and_additive_modifiers_and_excludes_locked_keys() { for modifiers in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND] { @@ -305,6 +311,7 @@ fn release_uses_the_release_position_and_additive_modifiers_and_excludes_locked_ } } +/// Cross-column selection uses logical headers, including those no longer rendered after scrolling. #[test] fn crossing_into_the_outline_after_scrolling_selects_offscreen_layer_rows() { let (mut h, ids) = rows_harness(); @@ -320,6 +327,7 @@ fn crossing_into_the_outline_after_scrolling_selects_offscreen_layer_rows() { assert!(selected.len() > 5, "all intervening rows are selected"); } +/// Edge scrolling continues without pointer motion and stops as soon as the gesture is cancelled. #[test] fn a_stationary_pointer_at_either_edge_scrolls_only_until_release_or_cancel() { let (mut h, ids) = rows_harness(); @@ -345,6 +353,7 @@ fn a_stationary_pointer_at_either_edge_scrolls_only_until_release_or_cancel() { assert!(times(&h).is_empty(), "cancelled box selects nothing"); } +/// Batched wheel events cannot move the press anchor or make release query stale row positions. #[test] fn wheel_on_press_captures_the_old_anchor_and_wheel_on_release_uses_the_new_rows() { for scroll_on_press in [true, false] { From 499f9ce455fcf6b9d9a69ec99c076936f1c8b7b7 Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 10 Nov 2026 02:24:29 -0300 Subject: [PATCH 023/227] #3395: setValue stores string effect parameters, such as Apply Color LUT's LUT --- crates/script/src/prelude.js | 2 ++ crates/script/src/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index b9108b2a1..360913289 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -867,6 +867,8 @@ Property.prototype.property = function () { return null; }; if (!idx) return null; return this.__layer.containingComp.layer(idx).__id; } + // A string parameter (a LUT or look file, a text field) takes the text, or a File's path (#3395). + if (info.type === "string") return __str(v); if (info.type === "enum") return __num(v) - 1; if (info.type === "bool") return typeof v === "boolean" ? v : __num(v) !== 0; if (info.type === "color") { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 78a16e5d3..d04018e67 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1949,3 +1949,30 @@ fn setting_comp_width_and_height_leaves_the_layers_where_they_are() { let (_, c) = comp_named(&s, "w"); assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([100.0, 100.0, 0.0])); } + +/// #3395: setValue on a string effect parameter stores the text, or a File's path, as `prop.set` +/// does. Before, every string fell through to the number check ("value must be a number"). +/// Hidden string parameters (Mesh Warp's Distortion Mesh) stay refused. +#[test] +fn set_value_stores_a_string_parameter() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Str", 64, 36, 1, 1, 24); + var l = c.layers.addSolid([1, 0, 0], "s", 64, 36, 1); + var fx = l.property("ADBE Effect Parade"); + var lut = fx.addProperty("Apply Color LUT").property("LUT"); + lut.setValue("/luts/look.cube"); + var fromString = lut.value; + lut.setValue(new File("/luts/other.cube")); + var mesh = fx.addProperty("Mesh Warp").property("Distortion Mesh"); + var hidden; + try { mesh.setValue("x"); hidden = "no error"; } catch (e) { hidden = e.message; } + [lut.propertyValueType === PropertyValueType.CUSTOM_VALUE, fromString, lut.value, hidden] + "#, + ); + assert_eq!(o.result, json!([true, "/luts/look.cube", "/luts/other.cube", "\"Distortion Mesh\" can not be set"])); + let (_, c) = comp_named(&s, "Str"); + assert_eq!(layer(c, "s").props.prop("effects/#1/lut").unwrap().value, KV::Str("/luts/other.cube".into())); +} From 58a1f3df174ce9664563a72f2071bf5766129e04 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 11:01:42 +0530 Subject: [PATCH 024/227] #2859: test the real marquee selection path The unit test re-implemented Marquee::selection's box/outline split, so it only covered `targets`. Move the anchor math into `Anchor::press` / `Anchor::selection` over a plain `View`, call it from the test, and add a scrolled-drag case that fails with a screen-fixed anchor. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline/marquee.rs | 54 +++++++++++++------ crates/ui-egui/src/panels/timeline/tests.rs | 25 +++++---- 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline/marquee.rs b/crates/ui-egui/src/panels/timeline/marquee.rs index 1c64a7963..d4eb56c76 100644 --- a/crates/ui-egui/src/panels/timeline/marquee.rs +++ b/crates/ui-egui/src/panels/timeline/marquee.rs @@ -16,10 +16,32 @@ struct Marquee { rows: Arc>, graph_on: bool, layout: (Rect, f32, f32), + anchor: Anchor, + dragging: bool, +} + +/// The pressed corner in content coordinates: a time (or an outline x) and a scroll-free y. +#[derive(Clone, Copy)] +pub(super) struct Anchor { time: f64, y: f32, outline_x: Option, - dragging: bool, +} + +/// The timeline layout a marquee is projected through. +#[derive(Clone, Copy)] +pub(super) struct View { + pub tm: TMap, + /// Screen y of the first row at scroll 0. + pub top: f32, + pub graph_x0: f32, + pub graph_on: bool, +} + +impl View { + fn of(g: &Rw) -> Self { + Self { tm: g.tl.tm, top: g.tl.rows_rect.min.y, graph_x0: g.tl.graph_x0, graph_on: g.graph_on } + } } fn id() -> Id { @@ -65,9 +87,7 @@ pub(super) fn prepare(app: &EffectcraftApp, ui: &egui::Ui, g: &Rw) { rows: g.tl.rows.clone(), graph_on: g.graph_on, layout: (g.tl.rect, g.tl.rh, g.tl.graph_x0), - time: g.tl.tm.t(p.x.max(g.tl.graph_x0)), - y: p.y - g.tl.rows_rect.min.y + app.ui.timeline.scroll_y, - outline_x: (p.x < g.tl.graph_x0).then_some(p.x), + anchor: Anchor::press(&View::of(g), app.ui.timeline.scroll_y, p), dragging: false, }, ); @@ -98,19 +118,21 @@ pub(super) struct SelectionBox { pub outline: bool, } -impl Marquee { +impl Anchor { + /// Anchor a press at screen point `p` with the rows scrolled by `scroll`. + pub(super) fn press(v: &View, scroll: f32, p: Pos2) -> Self { + Self { time: v.tm.t(p.x.max(v.graph_x0)), y: p.y - v.top + scroll, outline_x: (p.x < v.graph_x0).then_some(p.x) } + } + /// Reproject the content anchor without clipping the logical selection bounds. - fn selection(&self, g: &Rw, scroll: f32, pointer: Pos2) -> (Rect, SelectionBox) { - let tm = g.tl.tm; - let anchor = pos2(self.outline_x.unwrap_or_else(|| tm.x(self.time)), g.tl.rows_rect.min.y + self.y - scroll); + pub(super) fn selection(&self, v: &View, scroll: f32, pointer: Pos2) -> (Rect, SelectionBox) { + let tm = v.tm; + let anchor = pos2(self.outline_x.unwrap_or_else(|| tm.x(self.time)), v.top + self.y - scroll); let paint = Rect::from_two_pos(anchor, pointer); - let graph = self.outline_x.is_none() || pointer.x >= g.tl.graph_x0; - let keys = if graph && !g.graph_on { - Rect::from_two_pos(pos2(tm.x(self.time), anchor.y), pos2(pointer.x.max(g.tl.graph_x0), pointer.y)) - } else { - Rect::NOTHING - }; - (paint, SelectionBox { keys, outline: self.outline_x.is_some() || pointer.x < g.tl.graph_x0 }) + let graph = self.outline_x.is_none() || pointer.x >= v.graph_x0; + let keys = + if graph && !v.graph_on { Rect::from_two_pos(pos2(tm.x(self.time), anchor.y), pos2(pointer.x.max(v.graph_x0), pointer.y)) } else { Rect::NOTHING }; + (paint, SelectionBox { keys, outline: self.outline_x.is_some() || pointer.x < v.graph_x0 }) } } @@ -151,7 +173,7 @@ pub(super) fn finish(app: &EffectcraftApp, ui: &egui::Ui, g: &mut Rw) { return; } let Some(pointer) = ctx.input(|i| i.pointer.latest_pos()) else { return }; - let (paint, selection) = drag.selection(g, app.ui.timeline.scroll_y, pointer); + let (paint, selection) = drag.anchor.selection(&View::of(g), app.ui.timeline.scroll_y, pointer); if dropped { ctx.data_mut(|d| d.remove::(id())); let (layers, keys) = targets(&g.tl.comp, &g.tl.rows, &g.tl.tm, g.tl.rows_rect.min.y - app.ui.timeline.scroll_y, g.tl.rh, paint, &selection); diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index d4a801160..e42923e4a 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -960,25 +960,32 @@ fn a_box_over_both_columns_selects_the_rows_it_crosses_and_the_keys_inside() { } // Rows are 20 pt and 400 pt wide: the outline is left of x 200, a key at 0 s sits at x 205. let tm = TMap { x0: 205.0, start: 0.0, pps: 100.0 }; - let go = |br: Rect| { - let keys = if br.max.x < 200.0 { Rect::NOTHING } else { Rect::from_min_max(pos2(br.min.x.max(200.0), br.min.y), br.max) }; - marquee::targets(comp, &rows, &tm, 0.0, 20.0, br, &marquee::SelectionBox { keys, outline: br.min.x < 200.0 }) + let view = marquee::View { tm, top: 0.0, graph_x0: 200.0, graph_on: false }; + // Press at `from` with the rows scrolled by `s0`, release at `to` scrolled by `s1`. + let drag = |from: Pos2, s0: f32, to: Pos2, s1: f32| { + let (paint, sel) = marquee::Anchor::press(&view, s0, from).selection(&view, s1, to); + marquee::targets(comp, &rows, &tm, -s1, 20.0, paint, &sel) }; + let go = |from: Pos2, to: Pos2| drag(from, 0.0, to, 0.0); let keyed = |ks: &[effectcraft_engine::KeyRef]| ks.iter().map(|k| k.layer.0).collect::>(); // From the outline across the boundary: layer A's row (y 0..20) and B's row, and their keys. - let (ls, ks) = go(Rect::from_min_max(pos2(10.0, 5.0), pos2(260.0, 65.0))); + let (ls, ks) = go(pos2(10.0, 5.0), pos2(260.0, 65.0)); assert_eq!(ls, [layers[0], layers[1]]); assert_eq!(keyed(&ks), [layers[0], layers[1]]); - // The same box dragged the other way is the same rectangle: the same rows, the same keys. - let (ls, ks) = go(Rect::from_two_pos(pos2(260.0, 65.0), pos2(10.0, 5.0))); + // The same box dragged the other way: the same rows, the same keys. + let (ls, ks) = go(pos2(260.0, 65.0), pos2(10.0, 5.0)); assert_eq!((ls, keyed(&ks)), (vec![layers[0], layers[1]], vec![layers[0], layers[1]])); // Only over the outline: rows, no keys; only over the graph: keys, and the rows crossed. - let (ls, ks) = go(Rect::from_min_max(pos2(10.0, 5.0), pos2(150.0, 65.0))); + let (ls, ks) = go(pos2(10.0, 5.0), pos2(150.0, 65.0)); assert_eq!((ls.len(), ks.len()), (2, 0), "a key under the outline isn't touched"); // Kept inside the time graph: keys only, no rows (a marquee over empty track area). - let (ls, ks) = go(Rect::from_min_max(pos2(204.0, 5.0), pos2(260.0, 65.0))); + let (ls, ks) = go(pos2(204.0, 5.0), pos2(260.0, 65.0)); assert_eq!((ls.len(), keyed(&ks)), (0, vec![layers[0], layers[1]])); // A locked layer is not selected as a row (the engine drops its keys in keys.select). - let (ls, _) = go(Rect::from_min_max(pos2(10.0, 5.0), pos2(260.0, 119.0))); + let (ls, _) = go(pos2(10.0, 5.0), pos2(260.0, 119.0)); + assert_eq!(ls, [layers[0], layers[1]]); + // Scrolled 20 pt mid-drag: the pressed corner stays on layer A's row (#2859), so A is still + // selected; a screen-fixed anchor would start the box below A and select only B. + let (ls, _) = drag(pos2(10.0, 5.0), 0.0, pos2(260.0, 45.0), 20.0); assert_eq!(ls, [layers[0], layers[1]]); } From 29db04ed62e57abc302e74701766229c79f5f09a Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 10 Nov 2026 03:35:33 -0300 Subject: [PATCH 025/227] #3395: hidden string parameters stay refused by setValueAtTime and the other key setters --- crates/script/src/prelude.js | 6 +++++- crates/script/src/tests.rs | 12 +++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 360913289..f2a7a8230 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -868,7 +868,11 @@ Property.prototype.property = function () { return null; }; return this.__layer.containingComp.layer(idx).__id; } // A string parameter (a LUT or look file, a text field) takes the text, or a File's path (#3395). - if (info.type === "string") return __str(v); + // A hidden one (Mesh Warp's mesh) stays refused by the key setters too, as by setValue. + if (info.type === "string") { + if (info.hidden) throw __err("\"" + info.name + "\" can not be set"); + return __str(v); + } if (info.type === "enum") return __num(v) - 1; if (info.type === "bool") return typeof v === "boolean" ? v : __num(v) !== 0; if (info.type === "color") { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index d04018e67..d0884dbcb 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1952,7 +1952,7 @@ fn setting_comp_width_and_height_leaves_the_layers_where_they_are() { /// #3395: setValue on a string effect parameter stores the text, or a File's path, as `prop.set` /// does. Before, every string fell through to the number check ("value must be a number"). -/// Hidden string parameters (Mesh Warp's Distortion Mesh) stay refused. +/// Hidden string parameters (Mesh Warp's Distortion Mesh) stay refused, keys included. #[test] fn set_value_stores_a_string_parameter() { let mut s = session(); @@ -1967,12 +1967,14 @@ fn set_value_stores_a_string_parameter() { var fromString = lut.value; lut.setValue(new File("/luts/other.cube")); var mesh = fx.addProperty("Mesh Warp").property("Distortion Mesh"); - var hidden; - try { mesh.setValue("x"); hidden = "no error"; } catch (e) { hidden = e.message; } - [lut.propertyValueType === PropertyValueType.CUSTOM_VALUE, fromString, lut.value, hidden] + function attempt(f) { try { f(); return "no error"; } catch (e) { return e.message; } } + var hidden = attempt(function () { mesh.setValue("x"); }); + var keyed = attempt(function () { mesh.setValueAtTime(0, "x"); }); + [lut.propertyValueType === PropertyValueType.CUSTOM_VALUE, fromString, lut.value, hidden, keyed, mesh.numKeys] "#, ); - assert_eq!(o.result, json!([true, "/luts/look.cube", "/luts/other.cube", "\"Distortion Mesh\" can not be set"])); + let refused = "\"Distortion Mesh\" can not be set"; + assert_eq!(o.result, json!([true, "/luts/look.cube", "/luts/other.cube", refused, refused, 0])); let (_, c) = comp_named(&s, "Str"); assert_eq!(layer(c, "s").props.prop("effects/#1/lut").unwrap().value, KV::Str("/luts/other.cube".into())); } From f31463abfeab3b5fb26bba001089ca4471b773d8 Mon Sep 17 00:00:00 2001 From: nnaannoo <165100905+nanox333@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:42:55 +0100 Subject: [PATCH 026/227] Build: take craft-libs at e11f9d9a so its workspace loads with current Cargo (#3351) Moves the six craft-libs 92d392c pins to e11f9d9a so cargo vendor (and the Nix flake) can load the craft-libs workspace. Refs #3291. --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 12 ++++++------ crates/pdf/README.md | 2 +- xtask/src/pdf_deps.rs | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a83ae444..686440ab9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" [[package]] name = "craft-ecproj-format" @@ -1107,12 +1107,12 @@ dependencies = [ [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "hayro-syntax", "thiserror 2.0.21", @@ -1152,7 +1152,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "dispatch2", "egui", @@ -1163,7 +1163,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -3321,7 +3321,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "hayro-ccitt", ] @@ -3335,7 +3335,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", diff --git a/Cargo.toml b/Cargo.toml index aa1df1a6b..10055a63f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,9 @@ rust-version = "1.95" repository = "https://github.com/storytold/effectcraft" [workspace.dependencies] -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", version = "=0.1.0", default-features = false, features = ["read"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } +craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "ca1b5e2edb8568335c6c14f390307bd5beae8238" } # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } @@ -117,7 +117,7 @@ half = "2.7" # ui # Shared frontend components; keep this dependency above the engine boundary. # Shared UI and the complete egui family select reviewed immutable commits below. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["serde"] } # `default_fonts` and `monochrome_emoji_fonts` are explicit: the app's `FontDefinitions::default()` # and its `✔`/`▣` chrome glyphs need both, and kittest's own dev-dependencies do not propagate. egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] } @@ -242,8 +242,8 @@ lto = "thin" codegen-units = 1 [patch.crates-io] -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.2" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.3.0" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", version = "=0.3.0" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/crates/pdf/README.md b/crates/pdf/README.md index eb960cc6e..b38c9032e 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -144,7 +144,7 @@ ffmpeg elsewhere) to check the code tables against an independent implementation ## Shared parser setup and bounds The workspace pins `craft-pdf` to `craft-libs` Git revision -`92d392cff132eaf53e13afff77f38b5c11b73aa0`, with `default-features = false` and only the explicit `read` +`e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545`, with `default-features = false` and only the explicit `read` feature. This excludes the backend's bundled fonts and Adobe CMaps and preserves EffectCraft's font and asset policies. The shared parser does not change the supported drawing operators, image codecs, continuously rasterized SVG tree, editable shapes, or EPS interpreter. diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs index 78694c7a6..e61ab5012 100644 --- a/xtask/src/pdf_deps.rs +++ b/xtask/src/pdf_deps.rs @@ -5,7 +5,7 @@ use std::process::Command; use serde_json::Value; -const REV: &str = "92d392cff132eaf53e13afff77f38b5c11b73aa0"; +const REV: &str = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"; const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("hayro-syntax", "0.7.2"), ("hayro-jbig2", "0.3.0")]; pub fn run(root: &Path) -> Result<(), String> { From 87000339ed920a967c5cebea62a64b9cac3c084b Mon Sep 17 00:00:00 2001 From: nnaannoo <165100905+nanox333@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:43:16 +0100 Subject: [PATCH 027/227] Build: drop the unused egui patches and fail CI on [[patch.unused]] (#3404) Removes the unused egui_demo_lib, egui_inspection and egui_system_font_provider patches and adds cargo xtask lock-patches to cargo xtask ci. Fixes #3403. --- Cargo.lock | 15 --------- Cargo.toml | 5 ++- docs/testing.md | 2 +- xtask/src/lock_patches.rs | 71 +++++++++++++++++++++++++++++++++++++++ xtask/src/main.rs | 10 ++++-- 5 files changed, 81 insertions(+), 22 deletions(-) create mode 100644 xtask/src/lock_patches.rs diff --git a/Cargo.lock b/Cargo.lock index 686440ab9..03e789f08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8037,18 +8037,3 @@ dependencies = [ "syn 3.0.6", "winnow", ] - -[[patch.unused]] -name = "egui_demo_lib" -version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" - -[[patch.unused]] -name = "egui_inspection" -version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" - -[[patch.unused]] -name = "egui_system_font_provider" -version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 10055a63f..6f96cb012 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -254,8 +254,7 @@ egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc79968 egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +# Patch a crate only together with the dependency that needs it: an unused patch makes the Nix +# build, which uses vendored sources, fail (`cargo xtask lock-patches`). winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/docs/testing.md b/docs/testing.md index 54152b9dc..e6cd8df68 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2,7 +2,7 @@ ```sh cargo test --workspace # everything -cargo xtask ci # fmt, clippy -D warnings, tests, pdf-deps, layers, assets, wasm +cargo xtask ci # fmt, clippy -D warnings, tests, pdf-deps, lock-patches, layers, assets, wasm ``` The After Effects test projects of `crates/aep` are not in the repository; without them the diff --git a/xtask/src/lock_patches.rs b/xtask/src/lock_patches.rs new file mode 100644 index 000000000..30cc2c8b2 --- /dev/null +++ b/xtask/src/lock_patches.rs @@ -0,0 +1,71 @@ +//! Fail when `Cargo.lock` records a `[patch]` entry nothing uses. +//! +//! An unused patch only makes cargo warn, but the Nix package builds offline from vendored +//! sources, which hold only the packages the build uses: it cannot find the patched package and +//! refuses to build (#3403). Patch a crate together with the dependency that needs it. + +use std::path::Path; + +pub fn run(root: &Path) -> Result<(), String> { + let lock = std::fs::read_to_string(root.join("Cargo.lock")).map_err(|e| format!("Cargo.lock: {e}"))?; + let unused = unused_patches(&lock); + if unused.is_empty() { + println!("Cargo.lock: every [patch] entry is used"); + return Ok(()); + } + Err(format!( + "Cargo.lock has [[patch.unused]] entries: {}. Remove them from [patch] in Cargo.toml (add a patch together with the dependency that needs it); the Nix build, which uses vendored sources, fails on them (#3403)", + unused.join(", ") + )) +} + +/// The package names of the `[[patch.unused]]` entries in the text of a `Cargo.lock`. +fn unused_patches(lock: &str) -> Vec { + let mut names = Vec::new(); + let mut in_unused = false; + for line in lock.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_unused = line == "[[patch.unused]]"; + } else if let Some(name) = line.strip_prefix("name = ").filter(|_| in_unused) { + names.push(name.trim_matches('"').to_string()); + } + } + names +} + +#[cfg(test)] +mod tests { + use super::*; + + const LOCK: &str = r#" +[[package]] +name = "egui" +version = "0.37.0" + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc#6d518fc" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +"#; + + #[test] + fn lists_the_unused_patches_and_only_those() { + assert_eq!(unused_patches(LOCK), ["egui_demo_lib", "egui_inspection"]); + } + + #[test] + fn a_lock_without_unused_patches_is_accepted() { + assert!(unused_patches("[[package]]\nname = \"egui\"\nversion = \"0.37.0\"\n").is_empty()); + } + + #[test] + fn the_repository_lock_has_no_unused_patches() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + assert!(run(&root).is_ok(), "{:?}", run(&root)); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 24023ee88..06d32e51a 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). @@ -6,6 +6,7 @@ //! and the web app. //! - `web [--dev] [--serve PORT]`: build the web app (`apps/effectcraft-web`) into //! `/web/dist` with `wasm-bindgen` (docs/web.md); `--serve` serves it on localhost. +//! - `lock-patches`: `Cargo.lock` records no unused `[patch]` entry (the Nix build cannot vendor one). //! - `assets`: every asset file (image, icon, font, LUT, audio, video…) has a complete //! `.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1). //! - `aep-corpus [--dir DIR]`: fetch the After Effects test corpus at the commit pinned in @@ -19,6 +20,7 @@ mod aep_corpus; mod bench; mod ico; +mod lock_patches; mod pdf_deps; mod version; @@ -534,12 +536,13 @@ type Stage = fn() -> Result<(), String>; /// Every stage runs even when an earlier one fails, so one run reports all the problems. fn ci() -> Result<(), String> { - let stages: [(&str, Stage); 8] = [ + let stages: [(&str, Stage); 9] = [ ("audio backend", audio_backend), ("fmt", || run(Command::new(env!("CARGO")).args(["fmt", "--check"]))), ("clippy", || run(Command::new(env!("CARGO")).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), ("tests", tests), ("pdf-deps", || pdf_deps::run(&root())), + ("lock-patches", || lock_patches::run(&root())), ("layers", layers), ("assets", assets), ("wasm", wasm), @@ -555,6 +558,7 @@ fn main() -> ExitCode { "wasm" => wasm(), "assets" => assets(), "pdf-deps" => pdf_deps::run(&root()), + "lock-patches" => lock_patches::run(&root()), "aep-corpus" => aep_corpus::run(&root(), &std::env::args().skip(2).collect::>()), "ci" => ci(), "audio-backend" => audio_backend(), @@ -568,7 +572,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 9f5d7d1ac11adfe1933de8b6207e58743e1518f5 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:20:57 +0100 Subject: [PATCH 028/227] sonar: leave out the AEP corpus generators and document the expression eval wrappers --- sonar-project.properties | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/sonar-project.properties b/sonar-project.properties index 23a37f67e..7d13a08c4 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,2 +1,16 @@ sonar.projectKey=storytold_effectcraft sonar.organization=storytold + +# crates/aep/tests/fixtures/tools/ holds the contributor's offline generators of the AEP test +# corpus (Node and ExtendScript run by hand against a local folder, see its README). They are not +# shipped or run by the app or CI, so they are not analysed. +sonar.exclusions=crates/aep/tests/fixtures/tools/** + +# S1523 / S2137 (eval, Function): the expression runtime in crates/expr/src/prelude.js replaces +# `eval` and `Function` on purpose, with wrappers that check the code they are given before it +# runs (After Effects expressions may call them). The rules flag exactly that wrapping. +sonar.issue.ignore.multicriteria=prelude_eval,prelude_redefine +sonar.issue.ignore.multicriteria.prelude_eval.ruleKey=javascript:S1523 +sonar.issue.ignore.multicriteria.prelude_eval.resourceKey=crates/expr/src/prelude.js +sonar.issue.ignore.multicriteria.prelude_redefine.ruleKey=javascript:S2137 +sonar.issue.ignore.multicriteria.prelude_redefine.resourceKey=crates/expr/src/prelude.js From d9fde92d834bddef310bdeadab96cb9ca024db55 Mon Sep 17 00:00:00 2001 From: ash Date: Tue, 10 Nov 2026 17:31:34 +0530 Subject: [PATCH 029/227] Viewer: a click selects the topmost layer over a selected one (#3389) (#3405) * #3389: a viewer click selects the topmost layer over a selected one Clicks in the viewer went through pick(), which prefers a selected layer under the pointer so a covered layer can be dragged (#230). A large selected layer lower in the stack therefore captured every click inside its bounds, and the layers in front of it could not be selected there (#1535, #3389). Add click_pick(), the same hit test without that preference: the topmost layer, keeping the selection when it is already selected. The left click (Selection and fallback tools, Roto Brush), the double-click and the right-click menu use it; drag starts keep pick(), so dragging a covered selected layer works as before. Co-Authored-By: Claude Opus 5.5 * #3389: rustfmt the viewer's shapes imports Co-Authored-By: Claude Opus 5.5 * #3389: test the right-click where both layers cover the pointer The right-click check ran after the drag had moved the layer behind out from under the pointer, so the old pick() passed it too. It now runs before the drag with the layer behind selected, then closes the menu. pick()'s doc names the Puppet tool's pin placement as a click that uses it. Co-Authored-By: Claude Opus 5.5 * #3389: a click on a covered path of the selected layer keeps that layer With clicks going to the topmost layer, a click on a mask or shape path of the selected layer where a layer in front covers it selected the layer in front. Its paths went away, so a double-click there could no longer free-transform the path. A plain click on a shown path now stays on its layer, as pick() did; Shift-click still toggles the topmost one. Co-Authored-By: Claude Opus 5.5 * #3389: a right-click on a covered path of the selected layer keeps it The context menu picked as a left click did before the path fix, so a right-click on the selected layer's path, under a layer in front, selected the layer in front. It now keeps the path's layer like a left click. pick() and click_pick() docs describe the Puppet and path exceptions exactly. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewer.rs | 24 ++++-- crates/ui-egui/src/panels/viewer/shapes.rs | 21 +++++- crates/ui-egui/tests/it/ui_viewer.rs | 87 ++++++++++++++++++++-- 3 files changed, 116 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index cd0006c0f..b8fb4bb2b 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -838,9 +838,12 @@ fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &m // A right-click an overlay took (the Cryptomatte eyedropper's) opens the menu too. let forwarded = ui.data_mut(|d| d.remove_temp::(egui::Id::unique(FORWARDED_SECONDARY))); if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.secondary_clicked()).or(forwarded) { - // Picking an already selected layer preserves the group; an unselected hit - // becomes the target, using the same visible/unlocked hit test as left-click. - pick(app, ectx, map.to_comp(pos), false); + // The layer a left click there would select: the topmost one, keeping the group when it + // is already selected (#1535), or the selected layer whose shown path is under the + // pointer. The menu's list reaches the layers under it. + if ov::segment_at(&v.paths, &map, pos, 6.0).is_none() { + click_pick(app, ectx, map.to_comp(pos), false); + } let point = map.to_comp(pos); let hits: Vec<(u64, String)> = layers_at(ectx, point).map(|l| (l.id.0, l.name.clone())).collect(); ui.data_mut(|d| d.insert_temp(menu_hits_id, hits)); @@ -1845,15 +1848,19 @@ fn phase_clicked(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response } // The Roto Brush paints in the Layer panel only: select as usual and say so. rt if rt.roto_kind(false).is_some() => { - if pick(app, ectx, cpt, mods.shift).is_none() { + if click_pick(app, ectx, cpt, mods.shift).is_none() { let _ = app.session.execute("edit.deselectAll", json!({})); } app.ui.status = crate::i18n::tr(ROTO_HINT).to_string(); } _ => { - if pick(app, ectx, cpt, mods.shift).is_none() { + let on_path = ov::segment_at(&paths, &map, pos, 6.0).is_some(); + if on_path && !mods.shift { + // A click on a path of a selected layer stays on that layer, even where a + // layer in front covers it, so a double-click there free-transforms the path. + } else if click_pick(app, ectx, cpt, mods.shift).is_none() { let _ = app.session.execute("edit.deselectAll", json!({})); - } else if !mods.shift && !app.session.state.selected_vertices.is_empty() && ov::segment_at(&paths, &map, pos, 6.0).is_none() { + } else if !mods.shift && !app.session.state.selected_vertices.is_empty() && !on_path { // A click away from the paths, even on their layer, deselects the mask // points (the layer stays selected), as in After Effects (#513). let _ = app.session.execute("mask.selectVertices", json!({"vertices": []})); @@ -1904,7 +1911,8 @@ fn phase_dblclick(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Respons { let cpt = map.to_comp(pos); let roto = app.ui.tool.roto_kind(false).is_some(); - if let Some(l) = pick(app, ectx, cpt, false).and_then(|l| comp.layer(l)) { + // The topmost layer, as the double-click's first click selected it (#1535). + if let Some(l) = click_pick(app, ectx, cpt, false).and_then(|l| comp.layer(l)) { match &l.source { effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item), // With a Roto Brush tool, double-clicking a layer opens it in the Layer panel, @@ -2002,8 +2010,8 @@ use geometry::{ use geometry::{box_corners, project_clipped}; pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad}; use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius}; +pub(crate) use shapes::{click_pick, layer_at, layer_at_including_locked, layers_at, pick, selectable_layers}; use shapes::{create_shape, draw_shape_ghost, shape_drag}; -pub(crate) use shapes::{layer_at, layer_at_including_locked, layers_at, pick, selectable_layers}; #[cfg(test)] use tools::constrain_45; use tools::{add_motion_key, axis_at, axis_drag, drag_axes, draw_rigs, parent_world_inverse, pen_cursor, pen_cursor_icon, pen_press, select_motion_key}; diff --git a/crates/ui-egui/src/panels/viewer/shapes.rs b/crates/ui-egui/src/panels/viewer/shapes.rs index 76892cbaf..ce9b256cd 100644 --- a/crates/ui-egui/src/panels/viewer/shapes.rs +++ b/crates/ui-egui/src/panels/viewer/shapes.rs @@ -39,10 +39,24 @@ fn quad_area(q: &[[f64; 2]; 4]) -> f64 { (0..4).map(|i| q[i][0] * q[(i + 1) % 4][1] - q[(i + 1) % 4][0] * q[i][1]).sum::().abs() / 2.0 } -/// The layer a press at a comp point acts on, selected (Shift toggles the topmost layer). As in +/// The layer a drag from a comp point acts on, selected (Shift toggles the topmost layer). As in /// After Effects, a selected layer under the point comes before the layers in front of it, so it -/// can be dragged where they cover it; otherwise the topmost layer (#230). +/// can be dragged where they cover it; otherwise the topmost layer (#230). Clicks use +/// [`click_pick`], except a Puppet tool click while no layer has pins, which uses this so a pin +/// can go on a selected layer behind others. pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option { + pick_layer(app, ectx, cpt, toggle, true) +} + +/// The layer a click (press and release without a drag) at a comp point acts on: always the +/// topmost layer, selected unless it already is (Shift toggles it). Only a drag goes to a selected +/// layer behind others ([`pick`]), so a click can still select a layer in front of it (#1535). +/// The viewer doesn't call it for a click on a shown path, which stays on the path's layer. +pub(crate) fn click_pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option { + pick_layer(app, ectx, cpt, toggle, false) +} + +fn pick_layer(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool, prefer_selected: bool) -> Option { if toggle { let hit = layer_at(ectx, cpt)?.id; let _ = app.session.execute("layer.select", json!({"layers": [hit.0], "toggle": true})); @@ -50,7 +64,8 @@ pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], togg } let selected = &app.session.state.selected_layers; let hits: Vec = layers_at(ectx, cpt).map(|l| l.id).collect(); - let hit = *hits.iter().find(|id| selected.contains(id)).or(hits.first())?; + let preferred = if prefer_selected { hits.iter().find(|id| selected.contains(id)) } else { None }; + let hit = *preferred.or(hits.first())?; if !selected.contains(&hit) { let _ = app.session.execute("layer.select", json!({"layers": [hit.0]})); } diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index e01608e16..7934168cd 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -198,11 +198,10 @@ fn viewer_click_picks_nothing_while_the_solo_layer_is_inactive() { assert!(h.state().session.state.selected_layers.is_empty(), "{back:?} stays excluded by solo"); } -/// A selected layer under the pointer takes the press before the layers in front of it, as in -/// After Effects: it stays selected and a drag moves it. With none selected there, the topmost -/// layer does (#230). +/// A click, left or right, selects the topmost layer under the pointer, even over a selected +/// layer behind it (#1535); a drag still moves a selected layer behind others (#230). #[test] -fn viewer_press_prefers_a_selected_layer_under_the_pointer() { +fn viewer_click_selects_the_topmost_layer_and_a_drag_moves_the_selected_one() { let mut h = selection_harness(); let comp = h.state().session.active_comp().unwrap(); let (front, back) = (comp.layers[0].id, comp.layers[1].id); @@ -210,7 +209,20 @@ fn viewer_press_prefers_a_selected_layer_under_the_pointer() { h.run_steps(3); let at = screen(&h, [320.0, 180.0]); click(&mut h, at); - assert_eq!(h.state().session.state.selected_layers, vec![back], "a click keeps the selected layer behind"); + assert_eq!(h.state().session.state.selected_layers, vec![front], "a click selects the layer in front of the selected one (#1535)"); + // A right-click selects what a left click would, so the menu acts on the layer in front, + // here where both layers cover the pointer and the layer behind is selected. + h.state_mut().session.execute("layer.select", json!({"layers": [back.0]})).unwrap(); + h.run_steps(3); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(2); + assert_eq!(h.state().session.state.selected_layers, vec![front], "a right-click selects the layer in front"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + h.state_mut().session.execute("layer.select", json!({"layers": [back.0]})).unwrap(); h.run_steps(40); let (front0, back0) = (position_and_anchor(&h, front).0, position_and_anchor(&h, back).0); drag(&mut h, at, at + vec2(60.0, 0.0)); @@ -224,6 +236,71 @@ fn viewer_press_prefers_a_selected_layer_under_the_pointer() { assert_eq!(h.state().session.state.selected_layers, vec![front], "nothing selected: the topmost layer"); } +/// A click, left or right, on a path of the selected layer, where a layer in front covers it, stays +/// on that path's layer rather than selecting the layer in front, so a double-click there +/// free-transforms the path (#1535 keeps this from `pick`). +#[test] +fn viewer_double_click_on_a_covered_path_of_the_selected_layer_free_transforms_it() { + let mut h = selection_harness(); + let comp = h.state().session.active_comp().unwrap(); + let back = comp.layers[1].id; + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [back.0]})).unwrap(); + // In Back's own space (80×80): its top edge runs through comp (320, 150), under Front. + s.execute("mask.new", json!({"layer": back.0, "vertices": [[10, 10], [70, 10], [70, 70], [10, 70]], "closed": true})).unwrap(); + h.run_steps(40); + let at = screen(&h, [320.0, 150.0]); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + // A right-click there acts on the path's layer too, as a left click does. + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(2); + assert_eq!(h.state().session.state.selected_layers, vec![back], "a right-click on the path keeps its layer"); + h.key_press(egui::Key::Escape); + h.run_steps(40); + // Two clicks a frame apart: the first selects as a click does, the second double-clicks. + for _ in 0..2 { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + h.step(); + } + h.run_steps(3); + assert_eq!(h.state().session.state.selected_layers, vec![back], "the path's layer stays selected"); + assert_eq!(h.state().session.state.selected_vertices.len(), 4, "all the path's points are selected"); + assert!(h.state().auto.find("viewer.freeTransform.handle.0").is_some(), "the free-transform box is up"); +} + +/// A double-click where a precomp layer covers the selected layer opens the precomp: like a single +/// click, it acts on the topmost layer, which its first click selects (#1535). +#[test] +fn viewer_double_click_opens_the_topmost_layer_over_a_selected_one() { + let mut s = Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 80, "height": 80, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Back", "color": "#406080", "width": 200, "height": 200})).unwrap(); + s.execute("layer.addItem", json!({"item": "Inner"})).unwrap(); + let back = s.active_comp().unwrap().layers[1].id; + s.execute("layer.select", json!({"layers": [back.0]})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(40); + let at = screen(&h, [320.0, 180.0]); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + // Two clicks a frame apart: the first selects as a click does, the second double-clicks. + for _ in 0..2 { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + h.step(); + } + h.run_steps(3); + assert_eq!(h.state().session.active_comp_id().map(|c| c.0), Some(inner), "the precomp in front opens"); +} + /// Each composition keeps its Resolution, saved with the project as in After Effects: chosen for /// comp A in the viewer's popup, it is still A's after showing comp B, costs no undo step, keeps /// A's cached frames and is read back from the saved file (#417). From 31da676415e2022c1cd417d4d3b395148742e01f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:10:10 +0100 Subject: [PATCH 030/227] #2104: bound audio comparisons across reused precomps --- crates/ui-egui/src/audio.rs | 18 +++++++++++------ crates/ui-egui/src/audio/live_tests.rs | 28 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 015794305..a8cb54d6d 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -18,7 +18,7 @@ //! **Scrubbing:** Ctrl/Cmd-dragging the current-time indicator plays a short snippet of the mix //! at each new frame ([`AudioScrub`], `playback.scrubAudio`), as in After Effects. -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; @@ -338,11 +338,17 @@ impl Drop for AudioPlayback { /// Effects switch, Audio Levels or effects, or the same for a nested comp. Other edits (video /// properties, names, labels) do not change the mix unless an audio expression can read them. fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { - fn comps_differ(old: &Project, new: &Project, comp: ItemId, depth: usize) -> bool { - let (Some(a), Some(b)) = (old.comp(comp), new.comp(comp)) else { return old.comp(comp).is_some() != new.comp(comp).is_some() }; - if depth >= 16 { + audio_changed_inner(old, new, comp, &mut HashSet::new()) +} + +/// Each shared precomp is compared once per depth. A depth limit alone still visits an +/// exponentially large number of paths in a small graph of reused precomps, blocking the UI. +fn audio_changed_inner(old: &Project, new: &Project, comp: ItemId, seen: &mut HashSet<(ItemId, usize)>) -> bool { + fn comps_differ(old: &Project, new: &Project, comp: ItemId, depth: usize, seen: &mut HashSet<(ItemId, usize)>) -> bool { + if depth >= 16 || !seen.insert((comp, depth)) { return false; } + let (Some(a), Some(b)) = (old.comp(comp), new.comp(comp)) else { return old.comp(comp).is_some() != new.comp(comp).is_some() }; a.layers.len() != b.layers.len() || a.layers.iter().zip(&b.layers).any(|(x, y)| { x.id != y.id @@ -360,7 +366,7 @@ fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { || audio_has_expression(y) || match &x.source { LayerSource::Footage { item } => footage_differs(old, new, *item), - LayerSource::Comp { item } => comps_differ(old, new, *item, depth + 1), + LayerSource::Comp { item } => comps_differ(old, new, *item, depth + 1, seen), _ => false, } }) @@ -386,7 +392,7 @@ fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { }; f(old) != f(new) } - comps_differ(old, new, comp, 0) + comps_differ(old, new, comp, 0, seen) } /// Mix one block at the feeder's cursor into the feed and move the cursor on (wrapping or diff --git a/crates/ui-egui/src/audio/live_tests.rs b/crates/ui-egui/src/audio/live_tests.rs index 6be2909cd..28ea3be3e 100644 --- a/crates/ui-egui/src/audio/live_tests.rs +++ b/crates/ui-egui/src/audio/live_tests.rs @@ -179,3 +179,31 @@ fn video_edits_keep_the_queue_unless_audio_expressions_can_read_them() { } } } + +#[test] +fn shared_nested_comps_are_compared_once_per_depth() { + let mut project = Project::default(); + let mut child = None; + for _ in 0..10 { + let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + if let Some(item) = child { + for _ in 0..10 { + let mut layer = build::layer(&mut project, &comp, "Nested", LayerSource::Comp { item }, (64, 36), None); + layer.switches.audio = false; + comp.layers.push(layer); + } + } + child = Some(project.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(comp.into()))); + } + let root = child.unwrap(); + let mut edited = project.clone(); + edited.comp_mut(root).unwrap().layers[0].name = "Renamed".into(); + let mut seen = std::collections::HashSet::new(); + assert!(!super::audio_changed_inner(&project, &edited, root, &mut seen)); + assert_eq!(seen.len(), 10, "compare the ten comps, not a billion paths through them"); + let leaf = *seen.iter().find(|(_, depth)| *depth == 9).map(|(item, _)| item).unwrap(); + let mut layer = project.comp(root).unwrap().layers[0].clone(); + layer.source = LayerSource::Null; + edited.comp_mut(leaf).unwrap().layers.push(layer); + assert!(super::audio_changed(&project, &edited, root), "memoization must still find a nested edit"); +} From 47e804f86fbdf5713f4be319d7cd7db62756c033 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:21:16 +0100 Subject: [PATCH 031/227] Fix launch guard ownership during concurrent startup (#1459) * docs: track startup adapter recovery investigation (#477) * Fix launch guard ownership during concurrent startup * Launch guard (#477): fold the ownership tests into the tests module Review cleanup, no behaviour change: - Drop docs/issue-477-startup-recovery.md (investigation notes, not docs). - Move the investigation_477 and root_patch_477_tests cases into the existing tests module under behaviour names, without println! output; every assertion is kept. - Rewrap the module doc comment. * #477: preserve inaccessible launch markers on Windows --------- Co-authored-by: FredApps --- apps/effectcraft/src/launch_guard.rs | 284 +++++++++++++++++++++++++-- 1 file changed, 271 insertions(+), 13 deletions(-) diff --git a/apps/effectcraft/src/launch_guard.rs b/apps/effectcraft/src/launch_guard.rs index fac91c05b..a99981cd7 100644 --- a/apps/effectcraft/src/launch_guard.rs +++ b/apps/effectcraft/src/launch_guard.rs @@ -8,9 +8,9 @@ //! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). A //! launch that finds the previous one's marker switches Window Graphics to OpenGL, which those //! drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's own override) still -//! wins over both. The marker is locked while its launch runs, so a second copy opened meanwhile -//! isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics back to -//! Automatic ([`opengl_failed`]) rather than failing every launch. +//! wins over both. A separate guard file is locked while its launch runs, so a second copy opened +//! meanwhile isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics +//! back to Automatic ([`opengl_failed`]) rather than failing every launch. use std::fs::File; use std::path::{Path, PathBuf}; @@ -21,6 +21,46 @@ use eframe::wgpu::Backends; /// Left in the settings folder from a launch until its window has settled. const MARKER: &str = "launch-pending"; +const LOCK: &str = "launch.lock"; + +/// Keep this file in place: unlinking a locked file lets a second inode acquire a new lock. +fn acquire_guard(dir: &Path) -> Option { + let file = match std::fs::create_dir_all(dir) + .and_then(|()| std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK))) + { + Ok(file) => file, + Err(e) => { + log::warn!("launch guard unavailable: {e}"); + return None; + } + }; + match file.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => return None, + Err(e) => { + log::warn!("launch guard lock unavailable: {e}"); + return None; + } + } + // Older versions lock the marker itself. Probe only the lock: Windows also prevents + // reads through another handle while it is held. An unreadable marker is not a stale one. + match File::open(dir.join(MARKER)) { + Ok(marker) => match marker.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => return None, + Err(e) => { + log::warn!("launch marker lock unavailable: {e}"); + return None; + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + log::warn!("launch marker unavailable: {e}"); + return None; + } + } + Some(file) +} /// How long the window keeps drawing after its first frame before the launch counts as made. /// A driver that fails as the window is shown and its surface re-created does so within the @@ -37,7 +77,7 @@ pub struct Launch { /// to show. pub notice: Option, marker: Option, - /// Holds the marker's lock until the launch settles (or the process ends). + /// Holds the separate guard file's lock until the launch settles (or the process ends). lock: Option, /// When the window drew its first frame, until the launch settles. first_frame: Option, @@ -52,10 +92,13 @@ impl Launch { let prefs_path = dir.join(PREFS_FILE); let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default(); let marker = dir.join(MARKER); - // Locked by another launch that is still opening its window: not a crash. - let held = File::open(&marker).is_ok_and(|f| matches!(f.try_lock(), Err(std::fs::TryLockError::WouldBlock))); + let Some(lock) = acquire_guard(dir) else { + return Launch { backends: (prefs.startup.window_graphics == "gl").then_some(Backends::GL), ..Launch::default() }; + }; + // Read again while owning the guard: another launch may have just changed preferences. + prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default(); let mut notice = None; - if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) { + if marker.exists() && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) { prefs.startup.window_graphics = "gl".into(); match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) { Ok(()) => log::warn!("the last launch stopped as its window opened: switching Window Graphics to OpenGL"), @@ -69,15 +112,14 @@ impl Launch { ); } let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL); - let (marker, lock) = match std::fs::create_dir_all(dir).and_then(|()| File::create(&marker)) { - // Where locks aren't available the guard works as before. - Ok(f) => (Some(marker), f.try_lock().is_ok().then_some(f)), + let marker = match File::create(&marker) { + Ok(_) => Some(marker), Err(e) => { log::warn!("launch marker: {e}"); - (None, None) + None } }; - Launch { backends, notice, marker, lock, first_frame: None } + Launch { backends, notice, marker, lock: Some(lock), first_frame: None } } /// The window drew a frame. Until the launch has settled, returns how long that still takes @@ -104,18 +146,19 @@ impl Launch { /// The launch made it: release and remove its marker. fn settled(&mut self) { - self.lock = None; if let Some(m) = self.marker.take() && let Err(e) = std::fs::remove_file(&m) { log::warn!("launch marker: {e}"); } + self.lock = None; } } /// The window couldn't open with OpenGL (an error, not a crash): back to Automatic, and no marker. pub fn opengl_failed(dir: Option<&Path>) { let Some(dir) = dir else { return }; + let Some(_lock) = acquire_guard(dir) else { return }; let prefs_path = dir.join(PREFS_FILE); let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default(); prefs.startup.window_graphics = "auto".into(); @@ -217,4 +260,219 @@ mod tests { } let _ = std::fs::remove_dir_all(&dir); } + + /// A fresh settings folder for one test. + fn fresh_dir(case: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ec-launch-guard-{case}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Window Graphics saved as OpenGL. + fn save_opengl(dir: &Path) { + let mut prefs = Prefs::default(); + prefs.startup.window_graphics = "gl".into(); + std::fs::write(dir.join(PREFS_FILE), prefs.to_json()).unwrap(); + } + + /// A second copy that quits normally while the first is still opening doesn't remove the first + /// one's marker (#477). + #[test] + fn a_second_copy_quitting_leaves_the_running_launchs_marker() { + let dir = fresh_dir("second-quits"); + let mut first = Launch::begin(Some(&dir), false); + let mut second = Launch::begin(Some(&dir), false); + second.exited(); + let kept = dir.join(MARKER).exists(); + first.exited(); + let _ = std::fs::remove_dir_all(&dir); + assert!(kept, "the second copy removed the running launch's marker"); + } + + /// A second copy whose OpenGL window fails doesn't remove a running launch's marker. + #[test] + fn failed_opengl_in_a_second_copy_leaves_the_running_launchs_marker() { + let dir = fresh_dir("second-gl-fails"); + let mut first = Launch::begin(Some(&dir), false); + let second = Launch::begin(Some(&dir), false); + opengl_failed(Some(&dir)); + let kept = dir.join(MARKER).exists(); + drop(second); + first.exited(); + let _ = std::fs::remove_dir_all(&dir); + assert!(kept, "OpenGL cleanup removed another running launch's marker"); + } + + /// With `WGPU_BACKEND` set, a stale marker and a saved OpenGL choice are left as they are. + #[test] + fn wgpu_backend_leaves_a_stale_marker_and_saved_opengl_alone() { + let dir = fresh_dir("env-override"); + save_opengl(&dir); + std::fs::write(dir.join(MARKER), b"stale-marker").unwrap(); + let before = std::fs::read(dir.join(PREFS_FILE)).unwrap(); + let mut l = Launch::begin(Some(&dir), true); + assert_eq!(l.backends, None); + assert!(l.notice.is_none()); + l.exited(); + assert!(dir.join(MARKER).exists()); + assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before); + let _ = std::fs::remove_dir_all(&dir); + } + + /// After OpenGL fails to open a window, the next launch is back on Automatic, without a notice. + #[test] + fn failed_opengl_puts_the_next_launch_back_on_automatic() { + let dir = fresh_dir("gl-recovers"); + save_opengl(&dir); + let l = Launch::begin(Some(&dir), false); + assert_eq!(l.backends, Some(Backends::GL)); + drop(l); + opengl_failed(Some(&dir)); + assert!(!dir.join(MARKER).exists()); + assert_eq!(Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap()).startup.window_graphics, "auto"); + let mut l = Launch::begin(Some(&dir), false); + assert_eq!(l.backends, None); + assert!(l.notice.is_none()); + l.exited(); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The guard file is never removed or replaced: unlinking a locked file would let the next + /// launch lock a new one while the old lock is still held. + #[cfg(unix)] + #[test] + fn the_guard_file_stays_the_same_file_across_launches() { + use std::os::unix::fs::MetadataExt; + let dir = fresh_dir("guard-identity"); + let mut a = Launch::begin(Some(&dir), false); + let before = std::fs::metadata(dir.join(LOCK)).unwrap(); + let t0 = Instant::now(); + assert_eq!(a.frame_drawn_at(t0), Some(SETTLE)); + assert_eq!(a.frame_drawn_at(t0 + SETTLE), None); + assert!(!dir.join(MARKER).exists()); + let after = std::fs::metadata(dir.join(LOCK)).unwrap(); + assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino())); + let mut b = Launch::begin(Some(&dir), false); + assert!(b.lock.is_some()); + let reused = std::fs::metadata(dir.join(LOCK)).unwrap(); + assert_eq!((before.dev(), before.ino()), (reused.dev(), reused.ino())); + b.exited(); + let _ = std::fs::remove_dir_all(dir); + } + + /// A second copy's frames and quit neither settle nor unlock the running launch. + #[test] + fn a_second_copys_frames_dont_settle_the_running_launch() { + let dir = fresh_dir("second-frames"); + let mut a = Launch::begin(Some(&dir), false); + std::fs::write(dir.join(MARKER), b"owner-marker").unwrap(); + let mut b = Launch::begin(Some(&dir), false); + assert!(b.lock.is_none()); + assert!(b.marker.is_none()); + let t0 = Instant::now(); + assert_eq!(b.frame_drawn_at(t0), None); + assert_eq!(b.frame_drawn_at(t0 + SETTLE), None); + b.exited(); + assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"owner-marker"); + let probe = File::open(dir.join(LOCK)).unwrap(); + assert!(matches!(probe.try_lock(), Err(std::fs::TryLockError::WouldBlock))); + a.exited(); + let _ = std::fs::remove_dir_all(dir); + } + + /// Older versions lock the marker itself: while one runs, a new launch changes nothing, and + /// once it has stopped without settling the next launch still switches to OpenGL. + #[test] + fn a_running_older_version_holding_the_marker_is_left_alone() { + let dir = fresh_dir("older-version"); + std::fs::write(dir.join(MARKER), b"old-owner").unwrap(); + std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap(); + let old = File::open(dir.join(MARKER)).unwrap(); + old.try_lock().unwrap(); + let before = std::fs::read(dir.join(PREFS_FILE)).unwrap(); + let mut b = Launch::begin(Some(&dir), false); + assert!(b.lock.is_none()); + assert!(b.marker.is_none()); + assert!(b.backends.is_none()); + assert!(b.notice.is_none()); + b.exited(); + opengl_failed(Some(&dir)); + assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before); + // Windows locks forbid reading through another handle. Inspect the contents only after + // releasing the old owner's lock, but before recovery can truncate the marker. + drop(old); + assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"old-owner"); + let mut recovered = Launch::begin(Some(&dir), false); + if cfg!(not(target_os = "macos")) { + assert_eq!(recovered.backends, Some(Backends::GL)); + } + recovered.exited(); + let _ = std::fs::remove_dir_all(dir); + } + + /// A Windows sharing violation is not evidence that a marker is stale or absent. + #[cfg(windows)] + #[test] + fn an_unopenable_marker_leaves_settings_and_marker_alone() { + use std::os::windows::fs::OpenOptionsExt; + + let dir = fresh_dir("marker-unopenable"); + std::fs::write(dir.join(MARKER), b"old-owner").unwrap(); + std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap(); + let old = std::fs::OpenOptions::new().read(true).write(true).share_mode(0).open(dir.join(MARKER)).unwrap(); + let before = std::fs::read(dir.join(PREFS_FILE)).unwrap(); + let mut launch = Launch::begin(Some(&dir), false); + assert!(launch.lock.is_none()); + assert!(launch.marker.is_none()); + assert!(launch.backends.is_none()); + assert!(launch.notice.is_none()); + launch.exited(); + opengl_failed(Some(&dir)); + assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before); + drop(old); + assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"old-owner"); + let _ = std::fs::remove_dir_all(dir); + } + + /// A guard file that can't be opened leaves Window Graphics and the marker as they are. + #[test] + fn an_unusable_guard_file_leaves_settings_and_marker_alone() { + let dir = fresh_dir("guard-unusable"); + std::fs::create_dir(dir.join(LOCK)).unwrap(); + std::fs::write(dir.join(MARKER), b"stale-marker").unwrap(); + std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap(); + let before = std::fs::read(dir.join(PREFS_FILE)).unwrap(); + let mut l = Launch::begin(Some(&dir), false); + assert!(l.backends.is_none()); + assert!(l.notice.is_none()); + assert!(l.lock.is_none()); + assert!(l.marker.is_none()); + l.exited(); + opengl_failed(Some(&dir)); + assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before); + assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"stale-marker"); + let _ = std::fs::remove_dir_all(dir); + } + + /// OpenGL cleanup from elsewhere waits until the running launch has let go; then it resets + /// Window Graphics to Automatic and removes the marker. + #[test] + fn failed_opengl_cleanup_waits_for_the_running_launch() { + let dir = fresh_dir("gl-cleanup-waits"); + save_opengl(&dir); + let launch = Launch::begin(Some(&dir), false); + assert_eq!(launch.backends, Some(Backends::GL)); + assert!(launch.lock.is_some()); + let before = std::fs::read(dir.join(PREFS_FILE)).unwrap(); + opengl_failed(Some(&dir)); + assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before); + assert!(dir.join(MARKER).exists(), "cleanup elsewhere can't remove a running launch's marker"); + drop(launch); + opengl_failed(Some(&dir)); + let after = Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap()).startup.window_graphics; + assert_eq!(after, "auto", "once the launch has let go, failed OpenGL resets Window Graphics"); + assert!(!dir.join(MARKER).exists()); + let _ = std::fs::remove_dir_all(dir); + } } From 3f3031d94181e6ad1f5891ee824c0329290da1a1 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 17:54:46 +0530 Subject: [PATCH 032/227] Timeline: Shift-dragging a layer bar snaps to layer ends, the current time, markers and keys (#1828) Moving a layer bar or dragging its In / Out end with Shift held snaps the moved edge, within 8 px of the pointer, to the current time, comp and layer markers, visible keys, the work area, the comp's start and end, and other layers' In / Out points, as in After Effects. The edited layers' own ends are no targets (nor, when moving, their markers and keys). Targets are gathered once per drag; snapping is measured from the pointer, so dragging on lets go. Without Shift a drag moves by the whole frames the pointer covered, as before. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 103 +++++++++++++++----- crates/ui-egui/src/panels/timeline/tests.rs | 17 ++++ crates/ui-egui/tests/it/ui_timeline_trim.rs | 32 +++++- 3 files changed, 123 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index be0ef05d5..be33e0e5a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -588,15 +588,27 @@ pub fn snap_time(candidates: &[f64], t: f64, tolerance: f64) -> f64 { /// Times the current-time indicator snaps to (comp seconds). fn snap_candidates(comp: &Comp, rows: &[Row]) -> Vec { + snap_targets(comp, rows, &[], false) +} + +/// [`snap_candidates`] without the `edited` layers' In / Out points; when `moving` them, without +/// their markers and keys either (those move along). +fn snap_targets(comp: &Comp, rows: &[Row], edited: &[u64], moving: bool) -> Vec { + let skip = |l: LayerId, contents: bool| edited.contains(&l.0) && (moving || !contents); let mut v = vec![0.0, comp.work_area.0.seconds(), comp.work_area.1.seconds(), comp.duration.seconds()]; v.extend(comp.markers.iter().map(|m| m.time.seconds())); for l in &comp.layers { - v.push(l.in_point.seconds()); - v.push(l.out_point.seconds()); - v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds())); + if !skip(l.id, false) { + v.push(l.in_point.seconds()); + v.push(l.out_point.seconds()); + } + if !skip(l.id, true) { + v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds())); + } } for r in rows { if let RowKind::Prop { uid } = r.kind + && !skip(r.layer, true) && let Some(l) = comp.layer(r.layer) && let Some(p) = l.props.find(uid) { @@ -3473,44 +3485,81 @@ fn layer_quick_offset_begin(ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>, body: &e ctx.data(|d| d.get_temp::(quick_bar_id())).is_some_and(|q| q.layer == layer.id) } -/// Moving and trimming a layer bar by whole frames. +/// One layer bar drag: the layers it edits, the pointer's travel (px), the grabbed layer's moving +/// ends and the snap targets when it started (comp seconds), and the whole frames sent so far. +#[derive(Clone)] +struct BarDrag { + edited: Vec, + px: f64, + ends: Vec, + targets: Arc<[f64]>, + sent: i64, +} + +/// The whole frames a bar drag of `px` moves its ends by: truncated as the pointer goes, or with +/// `shift`, the move that puts one of `ends` on the nearest target within 8 px of the pointer +/// (#1828). Measured from the pointer, never the snapped result, so dragging on lets go. +fn bar_drag_frames(px: f64, pps: f64, fd: f64, ends: &[f64], targets: &[f64], shift: bool) -> i64 { + let secs = px / pps; + let snapped = shift + .then(|| { + ends.iter() + .flat_map(|e| targets.iter().map(move |t| t - e)) + .filter(|d| ((d - secs) * pps).abs() <= 8.0) + .min_by(|a, b| (a - secs).abs().total_cmp(&(b - secs).abs())) + }) + .flatten(); + // `as` saturates (NaN → 0), so a zero frame duration or zoom can't panic. + snapped.map_or((secs / fd).trunc() as i64, |d| (d / fd).round() as i64) +} + +/// Moving and trimming a layer bar by whole frames; Shift snaps its ends to the current time, +/// markers, keys, the work area and other layers' In / Out points, as in After Effects (#1828). fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) { let BarResps { body, lin, lout } = bars; - let pps = g.tl.pps; - let fd = g.tl.fd; let ctx = g.tl.ctx.clone(); - let actions = &mut g.tl.actions; - let selected = &g.selected; - let ui_actions = &mut g.ui_actions; let layer = rc.layer; let drag_key = format!("bar-{}", layer.id.0); for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { if kind == "move" && quick { continue; } + let id = egui::Id::unique(("bar-drag", layer.id.0, kind)); if resp.dragged() { - let acc_id = egui::Id::unique(("bar-acc", layer.id.0, kind)); - let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0)); - acc += resp.drag_delta().x; - let frames = ((acc as f64 / pps) / fd).trunc() as i64; - if frames != 0 { - acc -= (frames as f64 * fd * pps) as f32; - let d = frames as f64 * fd; - // Moving or trimming a selected layer acts on the whole - // selection; each layer trims from its own in/out point. - let targets = json!(edited_layers(layer.id, selected)); - let params = match kind { - "move" => json!({"layers": targets, "delta": d, "merge": drag_key}), - "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}), - _ => json!({"layers": targets, "outDelta": d, "merge": drag_key}), + // Moving or trimming a selected layer acts on the whole selection as it was when the + // drag started (a Shift-drag adds the layer to it); each layer trims from its own + // in/out point. + let start = || { + let edited = edited_layers(layer.id, &g.selected); + let (i, o) = (layer.in_point.seconds(), layer.out_point.seconds()); + let ends = match kind { + "move" => vec![i, o], + "in" => vec![i], + _ => vec![o], }; - actions.push(("layer.timing".into(), params)); + let mut targets = snap_targets(&g.tl.comp, &g.tl.rows, &edited, kind == "move"); + targets.push(g.tl.time.seconds()); + BarDrag { edited, px: 0.0, ends, targets: targets.into(), sent: 0 } + }; + let mut drag = ctx.data(|d| d.get_temp::(id)).filter(|_| !resp.drag_started()).unwrap_or_else(start); + drag.px += f64::from(resp.drag_delta().x); + let shift = ctx.input(|i| i.modifiers.shift); + let frames = bar_drag_frames(drag.px, g.tl.pps, g.tl.fd, &drag.ends, &drag.targets, shift); + if frames != drag.sent { + let d = frames.saturating_sub(drag.sent) as f64 * g.tl.fd; + drag.sent = frames; + let params = match kind { + "move" => json!({"layers": drag.edited, "delta": d, "merge": drag_key}), + "in" => json!({"layers": drag.edited, "inDelta": d, "merge": drag_key}), + _ => json!({"layers": drag.edited, "outDelta": d, "merge": drag_key}), + }; + g.tl.actions.push(("layer.timing".into(), params)); } - ctx.data_mut(|d| d.insert_temp(acc_id, acc)); + ctx.data_mut(|d| d.insert_temp(id, drag)); } if resp.drag_stopped() { - ctx.data_mut(|d| d.remove::(egui::Id::unique(("bar-acc", layer.id.0, kind)))); - ui_actions.push(UiAct::EndMerge); + ctx.data_mut(|d| d.remove::(id)); + g.ui_actions.push(UiAct::EndMerge); } } } diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index e42923e4a..9185c614b 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -432,6 +432,23 @@ fn cti_snaps_to_the_nearest_candidate() { let comp = app.session.active_comp().unwrap().clone(); let c = snap_candidates(&comp, &build_rows(&app, &comp)); assert!(c.contains(&comp.duration.seconds()) && c.contains(&0.0)); + // A dragged layer's own ends are no targets; moved, neither are its markers and keys (#1828). + let rows = build_rows(&app, &comp); + let l = comp.layers.first().unwrap().id.0; + assert_eq!(snap_targets(&comp, &rows, &[l], false).len(), c.len() - 2); + assert!(snap_targets(&comp, &rows, &[l], true).len() <= c.len() - 2); +} + +/// Shift snaps a bar drag's nearest end to a target within 8 px of the pointer; farther away, or +/// without Shift, the bar moves by the whole frames the pointer covered (#1828). +#[test] +fn bar_drags_snap_their_ends_with_shift() { + let (pps, fd, ends) = (100.0, 0.1, [2.0, 6.0]); + assert_eq!(bar_drag_frames(95.0, pps, fd, &ends, &[3.0], true), 10, "5 px short of 2 → 3 s snaps"); + assert_eq!(bar_drag_frames(95.0, pps, fd, &ends, &[3.0], false), 9, "no snap without Shift"); + assert_eq!(bar_drag_frames(88.0, pps, fd, &ends, &[3.0], true), 8, "12 px away lets go"); + assert_eq!(bar_drag_frames(104.0, pps, fd, &ends, &[3.0, 7.06], true), 11, "the target nearest the pointer wins"); + assert_eq!(bar_drag_frames(5.0, 0.0, 0.0, &ends, &[0.0], true), i64::MAX, "zero zoom saturates, no panic"); } /// Every string drawn by one frame of `draw`. diff --git a/crates/ui-egui/tests/it/ui_timeline_trim.rs b/crates/ui-egui/tests/it/ui_timeline_trim.rs index 6f95f744f..0ba1c73a6 100644 --- a/crates/ui-egui/tests/it/ui_timeline_trim.rs +++ b/crates/ui-egui/tests/it/ui_timeline_trim.rs @@ -45,18 +45,46 @@ fn timing(h: &Harness<'_, EffectcraftApp>, layer: u64) -> (f64, f64, f64) { } fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + drag_with(h, from, to, Modifiers::NONE); +} + +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { h.event(Event::PointerMoved(from)); + h.event(Event::ModifiersChanged(modifiers)); h.step(); - h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }); h.step(); for k in 1..=10 { h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))); h.step(); } - h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); h.run_steps(3); } +/// Shift-dragging a bar end or the bar snaps it to another layer's In / Out point a few pixels +/// away; without Shift it stops on the frame the pointer reached (#1828). +#[test] +fn shift_dragging_a_bar_snaps_to_other_layers() { + for shift in [false, true] { + let (mut h, solid, pre) = harness(); + let mods = if shift { Modifiers::SHIFT } else { Modifiers::NONE }; + let (b, p) = (bar(&h, solid), bar(&h, pre)); + let y = b.center().y; + // The solid's Out end (6 s), grabbed 2 px inside, to 6 px short of the precomp's Out (7 s). + drag_with(&mut h, pos2(b.max.x - 2.0, y), pos2(p.max.x - 8.0, y), mods); + let (i, o, _) = timing(&h, solid); + assert!((i - 2.0).abs() < 1e-6, "shift {shift}: in stays at 2 s, got {i}"); + assert_eq!((o - 7.0).abs() < 1e-6, shift, "shift {shift}: out {o} snaps to 7 s only with Shift"); + // The whole bar, so its In (2 s) lands 5 px short of the precomp's In (3 s). + let b = bar(&h, solid); + drag_with(&mut h, pos2(b.center().x, y), pos2(b.center().x + p.min.x - b.min.x - 5.0, y), mods); + let (i, _, _) = timing(&h, solid); + assert_eq!((i - 3.0).abs() < 1e-6, shift, "shift {shift}: in {i} snaps to 3 s only with Shift"); + } +} + /// Both ends of every bar show the trim cursor, just inside and just outside the end; the middle /// doesn't. The same holds for a selected layer, a twirled-open layer and a short bar. fn assert_trim_cursor_at_ends(h: &mut Harness<'_, EffectcraftApp>, layer: u64, case: &str) { From d047d89b9aa2f50ff8fd99fd1cafb0601e9ba385 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:36:17 +0100 Subject: [PATCH 033/227] #2104: document audio comparison and regression contracts --- crates/ui-egui/src/audio.rs | 1 + crates/ui-egui/src/audio/live_tests.rs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index a8cb54d6d..960aa71e2 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -344,6 +344,7 @@ fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { /// Each shared precomp is compared once per depth. A depth limit alone still visits an /// exponentially large number of paths in a small graph of reused precomps, blocking the UI. fn audio_changed_inner(old: &Project, new: &Project, comp: ItemId, seen: &mut HashSet<(ItemId, usize)>) -> bool { + /// Compare one comp and its reachable audio sources, without revisiting a shared path depth. fn comps_differ(old: &Project, new: &Project, comp: ItemId, depth: usize, seen: &mut HashSet<(ItemId, usize)>) -> bool { if depth >= 16 || !seen.insert((comp, depth)) { return false; diff --git a/crates/ui-egui/src/audio/live_tests.rs b/crates/ui-egui/src/audio/live_tests.rs index 28ea3be3e..6ca45caac 100644 --- a/crates/ui-egui/src/audio/live_tests.rs +++ b/crates/ui-egui/src/audio/live_tests.rs @@ -34,6 +34,7 @@ impl FootageSource for Ramp { } } +/// Construct a paused feeder whose short nonzero span makes every source sample observable. fn playback(footage: Arc) -> AudioPlayback { let mut project = Project::default(); let f = Footage { path: "ramp.wav".into(), kind: FootageKind::Audio, has_audio: true, ..Default::default() }; @@ -64,16 +65,19 @@ fn playback(footage: Arc) -> AudioPlayback { } } +/// Run exactly one production feeder block, without a background refill racing assertions. fn mix(p: &AudioPlayback) { feed_block(&p.state, p.comp, &p.feed, &p.span, p.rate, p.mix); } +/// Copy the active project and apply a layer edit through the same snapshot boundary as the UI. fn edit(p: &AudioPlayback, update: impl FnOnce(&mut Layer)) -> Arc { let mut project = p.project().unwrap(); update(&mut Arc::make_mut(&mut project).comp_mut(p.comp).unwrap().layers[0]); project } +/// Levels edits retain sample position across the loop boundary and the exclusive non-looping end. #[test] fn project_replacement_resumes_at_the_first_unconsumed_sample() { for looping in [false, true] { @@ -122,6 +126,7 @@ impl FootageSource for Gated { } } +/// A slow mix must not block edits or publish samples from the superseded project generation. #[test] fn a_block_in_flight_cannot_publish_after_a_mute() { let (entered, waiting) = mpsc::channel(); @@ -151,6 +156,7 @@ impl ExprHost for OpacityLevels { } } +/// Video properties are irrelevant to a plain mix, but may be dependencies of audio expressions. #[test] fn video_edits_keep_the_queue_unless_audio_expressions_can_read_them() { for expression in [false, true] { @@ -180,6 +186,7 @@ fn video_edits_keep_the_queue_unless_audio_expressions_can_read_them() { } } +/// Reused nested comps cost linear visits while edits to their descendants remain detectable. #[test] fn shared_nested_comps_are_compared_once_per_depth() { let mut project = Project::default(); From 8b9f9e694368e28cc2d29c52d208424b17cedd49 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:05:57 +0100 Subject: [PATCH 034/227] Capture observed process and GPU failure evidence (#3409) * #3357: retain process, memory and typed GPU failure evidence * #3357: tighten reporter protocol regression cases --- Cargo.lock | 160 ++++++++++-- apps/effectcraft/Cargo.toml | 3 + apps/effectcraft/src/crash_observation.rs | 264 +++++++++++++++++++ apps/effectcraft/src/crash_report.rs | 300 ++++++++++++++++++---- apps/effectcraft/src/diagnostic_state.rs | 147 +++++++++++ apps/effectcraft/src/launch_guard.rs | 61 ++++- apps/effectcraft/src/main.rs | 36 ++- crates/gpu/src/diagnostics.rs | 102 ++++++++ crates/gpu/src/lib.rs | 15 +- crates/ui-egui/src/app/gpu.rs | 6 + crates/ui-egui/src/gpu_failure.rs | 29 ++- docs/testing.md | 39 ++- 12 files changed, 1080 insertions(+), 82 deletions(-) create mode 100644 apps/effectcraft/src/crash_observation.rs create mode 100644 apps/effectcraft/src/diagnostic_state.rs create mode 100644 crates/gpu/src/diagnostics.rs diff --git a/Cargo.lock b/Cargo.lock index 03e789f08..37233ef58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1422,6 +1422,7 @@ dependencies = [ name = "effectcraft" version = "0.23.1" dependencies = [ + "chrono", "cpal", "craft-update", "effectcraft-engine", @@ -1439,6 +1440,7 @@ dependencies = [ "rfd", "serde", "serde_json", + "sysinfo", "winit", "winresource", "zbus", @@ -3068,7 +3070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.5", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3668,7 +3670,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.21", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3814,7 +3816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4120,6 +4122,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4434,6 +4445,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -4664,7 +4685,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -6071,6 +6092,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "tag_ptr" version = "0.1.0" @@ -7224,16 +7259,38 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", + "windows-collections 0.3.2", "windows-core 0.62.2", - "windows-future", - "windows-numerics", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -7255,6 +7312,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -7263,9 +7333,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.1", "windows-result 0.4.1", - "windows-strings", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] @@ -7275,8 +7356,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core 0.62.2", - "windows-link", - "windows-threading", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -7301,12 +7382,28 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -7314,7 +7411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ "windows-core 0.62.2", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7323,9 +7420,9 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows-result 0.4.1", - "windows-strings", + "windows-strings 0.5.1", ] [[package]] @@ -7337,13 +7434,31 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -7352,7 +7467,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7388,7 +7503,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7422,13 +7537,22 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 3030ee96d..21bd46755 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -25,6 +25,9 @@ rfd = { workspace = true } pollster = { workspace = true } cpal = { workspace = true } log = { workspace = true } +chrono = { workspace = true } +# MIT; safe process handles and target-only memory observation for the crash helper. +sysinfo = { version = "=0.37.2", default-features = false, features = ["system"] } [target.'cfg(target_os = "linux")'.dependencies] # Native GNOME/Wayland controls without a bundled title font. diff --git a/apps/effectcraft/src/crash_observation.rs b/apps/effectcraft/src/crash_observation.rs new file mode 100644 index 000000000..b67dc07fe --- /dev/null +++ b/apps/effectcraft/src/crash_observation.rs @@ -0,0 +1,264 @@ +//! Bounded observations made outside the failed GUI. Measurements are evidence, not a cause. +use std::io::{self, Write}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant, SystemTime}; + +use sysinfo::{MemoryRefreshKind, Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + +pub fn utc() -> String { + chrono::DateTime::::from(SystemTime::now()).to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +#[derive(Default)] +struct Memory { + samples: u64, + resident_peak: u64, + virtual_peak: u64, + available_min: Option, + total: u64, + last_at: Option, + last_uptime_secs: Option, +} + +impl Memory { + fn sample(&mut self, resident: u64, virtual_bytes: u64, available: u64, total: u64) { + self.samples = self.samples.saturating_add(1); + self.resident_peak = self.resident_peak.max(resident); + self.virtual_peak = self.virtual_peak.max(virtual_bytes); + // Zero total means the platform could not supply physical-memory information. + if total != 0 { + self.available_min = Some(self.available_min.map_or(available, |old| old.min(available))); + self.total = total; + } + self.last_at = Some(utc()); + } +} + +pub struct Observation { + pid: Pid, + system: System, + birth: Option, + started: Instant, + started_at: String, + detected_at: Option, + last_sample: Instant, + memory: Memory, + // Only Windows can retrieve an unrelated process's ExitStatus through a retained handle. + exit: Receiver>, + status: Option, + alive: Option, +} + +fn target_system(pid: Pid) -> System { + let mut system = System::new(); + system.refresh_processes_specifics(ProcessesToUpdate::Some(&[pid]), false, ProcessRefreshKind::nothing().with_memory()); + system +} + +impl Observation { + /// Acquire the Windows handle before acknowledging reporter readiness, so even an immediate + /// GUI abort leaves an observable process object. Never enumerate commands or environments. + pub fn start(pid: u32) -> Self { + let pid = Pid::from_u32(pid); + let system = target_system(pid); + let birth = system.process(pid).map(|p| p.start_time()); + let (send, exit) = mpsc::sync_channel(1); + #[cfg(windows)] + { + let waiting = target_system(pid); + let _ = std::thread::Builder::new().name("ec-exit-status".into()).spawn(move || { + let status = waiting.process(pid).and_then(|p| p.wait()); + let _ = send.send(status); + }); + } + #[cfg(not(windows))] + drop(send); + let now = Instant::now(); + let mut result = Self { + pid, + system, + birth, + started: now, + started_at: utc(), + detected_at: None, + last_sample: now, + memory: Memory::default(), + exit, + status: None, + alive: None, + }; + result.sample(); + result + } + + fn sample(&mut self) { + self.system.refresh_processes_specifics(ProcessesToUpdate::Some(&[self.pid]), false, ProcessRefreshKind::nothing().with_memory()); + self.system.refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram()); + let process = self.system.process(self.pid).filter(|p| Some(p.start_time()) == self.birth && p.exists()); + self.alive = self.birth.map(|_| process.is_some()); + if let Some(process) = process { + self.memory.last_uptime_secs = Some(process.run_time()); + self.memory.sample(process.memory(), process.virtual_memory(), self.system.available_memory(), self.system.total_memory()); + } + if let Ok(status) = self.exit.try_recv() { + self.status = status; + } + self.last_sample = Instant::now(); + } + + pub fn tick(&mut self) { + if self.last_sample.elapsed() >= Duration::from_secs(1) { + self.sample(); + } + } + + /// Pipe closure can precede the OS signalling process termination. Bound that grace period; + /// a broken pipe or early reporter drop must never be called a confirmed process exit. + pub fn finish(&mut self) { + self.detected_at = Some(utc()); + if self.status.is_none() + && let Ok(status) = self.exit.recv_timeout(Duration::from_millis(750)) + { + self.status = status; + } + self.sample(); + } + + pub fn confirmed_exit(&self) -> bool { + self.status.is_some() + } + + pub fn write(&self, report: &mut impl Write) -> io::Result<()> { + writeln!( + report, + "Detection UTC: {}\nObserver started UTC: {}\nObserved duration: {} ms", + self.detected_at.as_deref().unwrap_or("not captured"), + self.started_at, + self.started.elapsed().as_millis() + )?; + writeln!( + report, + "Process observation: {}", + if self.confirmed_exit() { + "process termination observed" + } else if self.alive == Some(true) { + "GUI still present when the control channel ended" + } else { + "target absent from refresh or process identity/status unavailable (not proof of termination)" + } + )?; + match &self.status { + Some(status) => { + writeln!(report, "OS exit status: {status}")?; + #[cfg(windows)] + if let Some(code) = status.code() { + write_windows_status(report, code as u32)?; + } + } + None => writeln!(report, "OS exit status: unavailable (not inferred from the control pipe)")?, + } + let memory = &self.memory; + writeln!(report, "\nMemory evidence (1 s sampling; sampled maxima, not OS peaks):\nTarget-present sampling attempts: {}", memory.samples)?; + if let Some(uptime) = memory.last_uptime_secs { + writeln!(report, "GUI uptime at last target-present observation: {uptime} s")?; + } + #[cfg(windows)] + let virtual_label = "Peak sampled private committed bytes"; + #[cfg(not(windows))] + let virtual_label = "Peak sampled virtual bytes"; + if memory.samples != 0 { + writeln!( + report, + "Last target sampling attempt UTC: {}\nPeak sampled resident bytes: {}\n{virtual_label}: {}", + memory.last_at.as_deref().unwrap_or("unavailable"), + memory.resident_peak, + memory.virtual_peak + )?; + } + if let Some(available) = memory.available_min { + writeln!(report, "Minimum sampled system available RAM bytes: {available}\nSystem total RAM bytes at last sample: {}", memory.total)?; + } + writeln!( + report, + "Samples can miss short spikes or retain stale/zero readings when an OS query fails; per-field success is not exposed by sysinfo. Windows private commit is not GPU VRAM; on other platforms virtual memory is not physical RAM. System commit limit/usage and GPU memory budgets were not collected. No memory-pressure threshold establishes the cause of this incident.\nThe memory observer does not collect process command lines, environments, documents, native dumps or system event logs. The existing session-log tail below may contain paths or other logged details; review it before sharing." + ) + } +} + +#[cfg(any(windows, test))] +fn write_windows_status(report: &mut impl Write, code: u32) -> io::Result<()> { + let meaning = match code { + 0xc0000005 => "STATUS_ACCESS_VIOLATION", + 0xc0000017 => "STATUS_NO_MEMORY", + 0xc000012d => "STATUS_COMMITMENT_LIMIT", + 0xc0000374 => "STATUS_HEAP_CORRUPTION", + 0xc0000409 => "STATUS_STACK_BUFFER_OVERRUN / fast fail", + 0x40000015 => "STATUS_FATAL_APP_EXIT", + _ => "unclassified", + }; + writeln!( + report, + "Windows status bits: 0x{code:08X} ({meaning})\nThese are process exit bits, not a verified exception record. Software can set an exit code explicitly; an access violation does not identify the faulting module or prove a driver crash." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_target_without_exit_status_does_not_confirm_termination() { + let (send, exit) = mpsc::sync_channel(1); + drop(send); + let now = Instant::now(); + let observation = Observation { + pid: Pid::from_u32(1), + system: System::new(), + birth: Some(1), + started: now, + started_at: utc(), + detected_at: Some(utc()), + last_sample: now, + memory: Memory::default(), + exit, + status: None, + alive: Some(false), + }; + assert!(!observation.confirmed_exit()); + let mut report = Vec::new(); + observation.write(&mut report).unwrap(); + let report = String::from_utf8(report).unwrap(); + assert!(report.contains("not proof of termination")); + assert!(!report.contains("process termination observed")); + } + + #[test] + fn memory_high_water_marks_are_bounded_and_not_a_causal_diagnosis() { + let mut memory = Memory::default(); + memory.sample(40, 80, 30, 100); + memory.sample(20, 90, 50, 100); + assert_eq!((memory.samples, memory.resident_peak, memory.virtual_peak, memory.available_min), (2, 40, 90, Some(30))); + memory.sample(0, 0, 0, 0); + assert_eq!(memory.available_min, Some(30)); + } + + #[test] + fn windows_status_bits_preserve_unsigned_codes_without_claiming_a_driver_crash() { + for (code, name) in + [(0xc0000005, "STATUS_ACCESS_VIOLATION"), (0xc0000017, "STATUS_NO_MEMORY"), (0xc000012d, "STATUS_COMMITMENT_LIMIT"), (42, "unclassified")] + { + let mut text = Vec::new(); + write_windows_status(&mut text, code).unwrap(); + let text = String::from_utf8(text).unwrap(); + assert!(text.contains(name)); + assert!(text.contains(&format!("0x{code:08X}"))); + assert!(text.contains("not a verified exception record")); + } + } + + #[test] + fn utc_timestamps_are_parseable_and_zero_offset() { + let time = chrono::DateTime::parse_from_rfc3339(&utc()).unwrap(); + assert_eq!(time.offset().local_minus_utc(), 0); + } +} diff --git a/apps/effectcraft/src/crash_report.rs b/apps/effectcraft/src/crash_report.rs index 32306aa5c..6ca42aab7 100644 --- a/apps/effectcraft/src/crash_report.rs +++ b/apps/effectcraft/src/crash_report.rs @@ -2,23 +2,31 @@ //! //! Allocation failure can abort without a panic hook. The GUI therefore owns a pipe to a //! helper which never loads a project, font database or GPU. Only an explicit marker after -//! normal teardown suppresses the report. EOF alone identifies an unexpected exit, not its -//! cause. System-wide exhaustion can still prevent the helper or its native dialog working. +//! normal teardown suppresses the report. EOF alone identifies a lost control channel, not +//! a process exit or its cause. System-wide exhaustion can still prevent the helper or its native dialog working. +use std::collections::VecDeque; use std::ffi::{OsStr, OsString}; use std::fs::{File, OpenOptions}; use std::io::{self, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, RecvTimeoutError}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +#[path = "crash_observation.rs"] +mod observation; + const FLAG: &str = "--internal-crash-reporter"; const CLEAN: &[u8] = b"EFFECTCRAFT_CLEAN_EXIT_V1\n"; const LOG_BYTES: usize = 64 * 1024; const HEADER_BYTES: usize = 128; +const READY: &[u8] = b"READY\n"; +const EVENT_BYTES: usize = 128; +const EVENT_COUNT: usize = 32; /// Owned only by the GUI, never handed to another child. Dropping it without `finish` closes -/// the pipe and reports an unexpected exit; there is intentionally no clean-exit Drop hook. +/// the pipe and reports the incident; there is intentionally no clean-exit Drop hook. pub struct Reporter { pipe: ChildStdin, child: Child, @@ -72,6 +80,20 @@ impl Shutdown { Ok(()) } + /// Keep only fixed-vocabulary, bounded lifecycle facts outside the GUI. Callers emit each + /// category once; their combined payload fits well below the smallest OS pipe buffer. + pub fn record(&self, event: &'static str) -> io::Result<()> { + if event.len() > 100 || event.contains(['\n', '\r']) { + return Err(io::Error::other("invalid diagnostic observation")); + } + let mut slot = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?; + if let Some(reporter) = slot.as_mut() { + writeln!(reporter.pipe, "EVENT {event}")?; + reporter.pipe.flush()?; + } + Ok(()) + } + /// Consume the reporter exactly once, after the applicable platform teardown boundary. pub fn finish(&self) -> io::Result<()> { let reporter = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?.take(); @@ -105,12 +127,18 @@ pub fn start(id: &str, log: Option<&Path>, reports: &Path, interactive: bool) -> log.map_or_else(OsString::new, |p| p.as_os_str().to_owned()), reports.as_os_str().to_owned(), OsString::from(if interactive { "1" } else { "0" }), + OsString::from(std::process::id().to_string()), ]; spawn(&exe, &args) } fn spawn(exe: &Path, args: &[OsString]) -> io::Result { - let mut child = Command::new(exe).args(args).stdin(Stdio::piped()).stdout(Stdio::null()).spawn()?; + let mut child = Command::new(exe).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()?; + if let Err(error) = ready(&mut child) { + let _ = child.kill(); + let _ = finish_child(&mut child, Duration::from_millis(100)); + return Err(error); + } match child.stdin.take() { Some(pipe) => Ok(Reporter { pipe, child }), None => { @@ -121,6 +149,36 @@ fn spawn(exe: &Path, args: &[OsString]) -> io::Result { } } +/// A fixed acknowledgement means the helper holds the process observation before heavy GUI +/// work begins. A suspended helper cannot hold startup indefinitely. +fn ready(child: &mut Child) -> io::Result<()> { + let mut output = child.stdout.take().ok_or_else(|| io::Error::other("crash reporter has no readiness pipe"))?; + let (send, receive) = mpsc::sync_channel(1); + std::thread::Builder::new().name("ec-reporter-ready".into()).spawn(move || { + let mut bytes = [0; READY.len()]; + #[cfg(not(test))] + let result = + output.read_exact(&mut bytes).and_then(|()| if bytes == READY { Ok(()) } else { Err(io::Error::other("invalid reporter acknowledgement")) }); + // libtest prints its bounded banner before invoking the helper role. + #[cfg(test)] + let result = (|| { + for _ in 0..1024 { + bytes.rotate_left(1); + output.read_exact(&mut bytes[READY.len() - 1..])?; + if bytes == READY { + return Ok(()); + } + } + Err(io::Error::other("reporter acknowledgement missing")) + })(); + let _ = send.send(result); + // Keep the pipe open for libtest's trailing result (and future helper output), without + // storing it. Dropping it at READY would turn a clean helper exit into BrokenPipe. + let _ = io::copy(&mut output, &mut io::sink()); + })?; + receive.recv_timeout(Duration::from_secs(3)).map_err(|_| io::Error::other("crash reporter readiness timed out"))? +} + /// Handle the internal mode before logging, argument parsing, updates or GUI initialization. /// The callback is a native dialog on this helper's main thread, not on the failed GUI's. pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool { @@ -128,7 +186,7 @@ pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool { return false; } let parsed = match args { - [_, id, log, reports, interactive] => id + [_, id, log, reports, interactive, pid] => id .to_str() .filter(|id| valid_id(id)) .zip(match interactive.to_str() { @@ -136,27 +194,61 @@ pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool { Some("1") => Some(true), _ => None, }) - .map(|(id, interactive)| (id, (!log.is_empty()).then(|| Path::new(log)), Path::new(reports), interactive)), + .zip(pid.to_str().and_then(|p| p.parse::().ok()).filter(|pid| *pid != 0)) + .map(|((id, interactive), pid)| (id, (!log.is_empty()).then(|| Path::new(log)), Path::new(reports), interactive, pid)), _ => None, }; - let Some((id, log, reports, interactive)) = parsed else { + let Some((id, log, reports, interactive, pid)) = parsed else { eprintln!("EffectCraft crash reporter: invalid internal arguments"); return true; }; // Keep the exact file open while the GUI is alive: a later rotation must not substitute // another instance's log. If rotation won the startup race, the session marker rejects it. let mut log = SessionLog::open(log, id); - match clean_exit(&mut io::stdin().lock()) { - Ok(true) => {} - result => { - let failure = result.err().map(|e| e.to_string()); - let saved = write_report(reports, id, &mut log, failure.as_deref()); - let description = description(&saved); - eprintln!("{description}"); - if interactive { - show(&description); + let mut observation = observation::Observation::start(pid); + // A GUI that dies during helper startup can already have closed this read end. Keep + // watching its control pipe and save the report rather than losing that early failure. + let _ = io::stdout().write_all(READY).and_then(|()| io::stdout().flush()); + // The reader has one bounded queue; the monitor never waits on a live pipe, so it can + // sample the target even when no log or lifecycle message is emitted. + let (send, receive) = mpsc::sync_channel(1); + let reader = std::thread::Builder::new().name("ec-reporter-pipe".into()).spawn(move || { + let mut input = io::stdin().lock(); + loop { + let message = read_message(&mut input); + let terminal = !matches!(message, Ok(Message::Event(_))); + if send.send(message).is_err() || terminal { + break; } } + }); + let mut events = VecDeque::with_capacity(EVENT_COUNT); + let failure = if reader.is_err() { + Some("control reader could not start".to_owned()) + } else { + loop { + observation.tick(); + match receive.recv_timeout(Duration::from_millis(250)) { + Ok(Ok(Message::Clean)) => return true, + Ok(Ok(Message::Event(event))) => { + if events.len() == EVENT_COUNT { + events.pop_front(); + } + events.push_back(format!("{} {event}", observation::utc())); + } + Ok(Ok(Message::Ended)) => break None, + Ok(Err(error)) => break Some(error.to_string()), + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => break Some("control reader disconnected".to_owned()), + } + } + }; + observation.finish(); + let saved = write_report(reports, id, &mut log, failure.as_deref(), &observation, &events); + let description = description(&saved, observation.confirmed_exit()); + eprintln!("{description}"); + if interactive { + show(&description); } true } @@ -165,20 +257,43 @@ fn valid_id(id: &str) -> bool { !id.is_empty() && id.len() <= 80 && id.bytes().all(|c| c.is_ascii_digit() || c == b'-') } -/// Fixed-size comparison: arbitrary/truncated input never grows a buffer or counts as clean. -fn clean_exit(input: &mut impl Read) -> io::Result { - let mut marker = [0u8; CLEAN.len()]; - let mut filled = 0; - while filled < marker.len() { - let remaining = marker.get_mut(filled..).ok_or_else(|| io::Error::other("invalid control marker offset"))?; - match input.read(remaining) { - Ok(0) => return Ok(false), - Ok(n) => filled += n, - Err(e) if e.kind() == io::ErrorKind::Interrupted => {} - Err(e) => return Err(e), +enum Message { + Clean, + Event(String), + Ended, +} + +/// Every line and the retained event ring are bounded, including malformed control input. +fn read_message(input: &mut impl Read) -> io::Result { + let mut line = [0u8; EVENT_BYTES]; + for i in 0..line.len() { + let byte = line.get_mut(i).ok_or_else(|| io::Error::other("invalid control offset"))?; + loop { + match input.read(std::slice::from_mut(byte)) { + Ok(0) => return Ok(Message::Ended), + Ok(_) => break, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } + if *byte == b'\n' { + let text = line.get(..=i).ok_or_else(|| io::Error::other("invalid control line"))?; + if text == CLEAN { + return Ok(Message::Clean); + } + if let Some(event) = text.strip_prefix(b"EVENT ") { + let event = std::str::from_utf8(event).map_err(|_| io::Error::other("non-UTF8 diagnostic observation"))?; + return Ok(Message::Event(event.trim_end().to_owned())); + } + return Ok(Message::Ended); } } - Ok(marker == CLEAN) + Err(io::Error::other("control line exceeded its fixed limit")) +} + +#[cfg(test)] +fn clean_exit(input: &mut impl Read) -> io::Result { + Ok(matches!(read_message(input)?, Message::Clean)) } enum SessionLog { @@ -228,7 +343,14 @@ impl SessionLog { } } -fn write_report(reports: &Path, id: &str, log: &mut SessionLog, pipe_error: Option<&str>) -> io::Result { +fn write_report( + reports: &Path, + id: &str, + log: &mut SessionLog, + pipe_error: Option<&str>, + observation: &observation::Observation, + events: &VecDeque, +) -> io::Result { if !valid_id(id) { return Err(io::Error::other("invalid report identity")); } @@ -244,8 +366,13 @@ fn write_report(reports: &Path, id: &str, log: &mut SessionLog, pipe_error: Opti )?; writeln!( report, - "The GUI's control pipe ended without its clean-exit marker.\nThe exit status and exact cause are not available to this reporter.\nMemory exhaustion, a native crash or external termination are possible.\nThis report was saved locally; it was not sent anywhere." + "The GUI control channel ended without its clean-exit marker.\nThe exact cause is not established. GPU fallback, memory pressure and process exit are separate observations, not a proven causal chain.\nThis report was saved locally; it was not sent anywhere." )?; + observation.write(&mut report)?; + writeln!(report, "\nRetained lifecycle observations (last {EVENT_COUNT}; UTC is helper receipt time):")?; + for event in events { + writeln!(report, "{event}")?; + } if let Some(error) = pipe_error { writeln!(report, "Control pipe error: {error}")?; } @@ -256,13 +383,15 @@ fn write_report(reports: &Path, id: &str, log: &mut SessionLog, pipe_error: Opti Ok(path) } -fn description(saved: &io::Result) -> String { +fn description(saved: &io::Result, confirmed_exit: bool) -> String { let diagnostic = match saved { Ok(path) => format!("A diagnostic report was saved to:\n{}", path.display()), Err(error) => format!("A diagnostic report could not be saved: {error}"), }; + let incident = + if confirmed_exit { "EffectCraft stopped unexpectedly." } else { "EffectCraft lost its diagnostic control channel; process exit is not confirmed." }; format!( - "EffectCraft stopped unexpectedly.\n\n{diagnostic}\n\nThe exact cause is unknown. Reopen EffectCraft to check for an existing auto-save. Changes since the last auto-save may be lost.\n\nNo report has been sent anywhere." + "{incident}\n\n{diagnostic}\n\nThe exact cause is unknown. If EffectCraft has closed, reopen it to check for an existing auto-save. Changes since the last auto-save may be lost.\n\nNo report has been sent anywhere." ) } @@ -276,6 +405,10 @@ mod tests { static NEXT: AtomicUsize = AtomicUsize::new(0); const ROLE: &str = "EFFECTCRAFT_CRASH_REPORT_TEST_ROLE"; + fn test_report(dir: &Path, id: &str, log: &mut SessionLog) -> io::Result { + write_report(dir, id, log, None, &observation::Observation::start(std::process::id()), &VecDeque::new()) + } + fn folder() -> PathBuf { let p = std::env::temp_dir().join(format!("ec-crash-report-{}-{}", run_id(), NEXT.fetch_add(1, Ordering::Relaxed))); std::fs::create_dir_all(&p).unwrap(); @@ -283,7 +416,7 @@ mod tests { } fn test_name(name: &str) -> String { - // Also usable as a standalone std-only rustc test while the app's build cache is busy. + // Also usable from an isolated harness with the reporter dependencies. let prefix = module_path!().split_once("::").map_or("", |(_, rest)| rest); format!("{prefix}::{name}") } @@ -299,10 +432,12 @@ mod tests { .env("EC_REPORT_DIR", dir) .env("EC_REPORT_ID", id) .env("EC_REPORT_INTERACTIVE", if interactive { "1" } else { "0" }) + .env("EC_REPORT_PID", std::process::id().to_string()) .stdin(Stdio::piped()) - .stdout(Stdio::null()) + .stdout(Stdio::piped()) .spawn() .unwrap(); + ready(&mut child).unwrap(); Reporter { pipe: child.stdin.take().unwrap(), child } } @@ -321,7 +456,7 @@ mod tests { for mut input in [b"".as_slice(), &CLEAN[..CLEAN.len() - 1], b"clean", b"EFFECTCRAFT_CLEAN_EXIT_V0\n"] { assert!(!clean_exit(&mut input).unwrap()); } - assert!(!clean_exit(&mut &vec![b'x'; 100_000][..]).unwrap()); + assert!(clean_exit(&mut &vec![b'x'; 100_000][..]).is_err()); } #[test] @@ -336,9 +471,9 @@ mod tests { let mut old = OpenOptions::new().append(true).open(dir.join("effectcraft.1.log")).unwrap(); old.write_all(&vec![b'a'; LOG_BYTES * 2]).unwrap(); old.write_all(b"\nGPU context retired\n").unwrap(); - let report = write_report(&dir, id, &mut log, None).unwrap(); + let report = test_report(&dir, id, &mut log).unwrap(); let text = std::fs::read_to_string(report).unwrap(); - assert!(text.len() < LOG_BYTES + 2048); + assert!(text.len() < LOG_BYTES + 4096); assert!(text.contains("GPU context retired")); assert!(!text.contains("OTHER SESSION")); std::fs::remove_dir_all(dir).unwrap(); @@ -350,7 +485,7 @@ mod tests { let path = dir.join("log"); std::fs::write(&path, "private other session").unwrap(); for (id, source) in [("1-1", Some(path.as_path())), ("1-2", None)] { - let report = write_report(&dir, id, &mut SessionLog::open(source, id), None).unwrap(); + let report = test_report(&dir, id, &mut SessionLog::open(source, id)).unwrap(); let text = std::fs::read_to_string(report).unwrap(); assert!(text.contains("Recent log unavailable")); assert!(!text.contains("private other session")); @@ -365,7 +500,7 @@ mod tests { std::fs::write(&path, "crash-report session 1-2\noriginal session\n").unwrap(); let mut log = SessionLog::open(Some(&path), "1-2"); std::fs::write(&path, "crash-report session 3-4\nprivate other session\n").unwrap(); - let report = write_report(&dir, "1-2", &mut log, None).unwrap(); + let report = test_report(&dir, "1-2", &mut log).unwrap(); let text = std::fs::read_to_string(report).unwrap(); assert!(text.contains("Recent log unavailable")); assert!(!text.contains("private other session")); @@ -388,13 +523,13 @@ mod tests { fn report_write_failure_is_described_and_existing_report_is_not_overwritten() { let dir = folder(); let mut log = SessionLog::Unavailable("no log".into()); - let first = write_report(&dir, "2-1", &mut log, None).unwrap(); + let first = test_report(&dir, "2-1", &mut log).unwrap(); let before = std::fs::read(&first).unwrap(); - let result = write_report(&dir, "2-1", &mut log, None); + let result = test_report(&dir, "2-1", &mut log); assert!(result.is_err()); - assert!(description(&result).contains("could not be saved")); + assert!(description(&result, false).contains("could not be saved")); assert_eq!(std::fs::read(first).unwrap(), before); - assert!(write_report(&dir, "../escape", &mut log, None).is_err()); + assert!(test_report(&dir, "../escape", &mut log).is_err()); std::fs::remove_dir_all(dir).unwrap(); } @@ -470,7 +605,10 @@ mod tests { assert!(!status.success()); wait_file(&dir.join("ready-4-1")); let text = std::fs::read_to_string(dir.join("crash-4-1.txt")).unwrap(); - assert!(text.contains("exact cause are not available")); + assert!(text.contains("exact cause is not established")); + assert!(text.contains("Detection UTC:")); + #[cfg(windows)] + assert!(text.contains("Windows status bits:")); std::fs::remove_dir_all(dir).unwrap(); } @@ -531,15 +669,70 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn lost_pipe_keeps_bounded_lifecycle_evidence_without_claiming_process_exit() { + let dir = folder(); + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "9-1")).unwrap(); + for _ in 0..40 { + reporter.record("GPU observation: wgpu validation error").unwrap(); + } + drop(reporter); + wait_file(&dir.join("ready-9-1")); + let text = std::fs::read_to_string(dir.join("crash-9-1.txt")).unwrap(); + assert!(text.contains("GUI still present")); + assert!(!text.contains("process termination observed")); + assert_eq!(text.matches("GPU observation: wgpu validation error").count(), EVENT_COUNT); + assert!(text.contains("Target-present sampling attempts:")); + assert!(text.len() < 8192); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn clean_exit_after_lifecycle_events_does_not_write_a_report() { + let dir = folder(); + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "9-2")).unwrap(); + reporter.record("Backend selection source: default").unwrap(); + reporter.finish().unwrap(); + wait_file(&dir.join("ready-9-2")); + assert!(!dir.join("crash-9-2.txt").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + #[cfg(windows)] + fn windows_report_preserves_real_unsigned_exit_bits() { + let dir = folder(); + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"]) + .env(ROLE, "windows-status") + .env("EC_REPORT_DIR", &dir) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert_eq!(status.code().map(|code| code as u32), Some(0xc0000017)); + wait_file(&dir.join("ready-9-3")); + let text = std::fs::read_to_string(dir.join("crash-9-3.txt")).unwrap(); + assert!(text.contains("Windows status bits: 0xC0000017 (STATUS_NO_MEMORY)")); + assert!(text.contains("not a verified exception record")); + assert!(text.contains("process termination observed")); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn internal_mode_rejects_invalid_arguments_without_launching_the_app() { assert!(!maybe_run(&[], |_| panic!("no dialog"))); assert!(!maybe_run(&[OsString::from("--version")], |_| panic!("no dialog"))); assert!(maybe_run(&[OsString::from(FLAG)], |_| panic!("no dialog"))); - let args = [FLAG, "../escape", "", "", "1"].map(OsString::from); + let args = [FLAG, "../escape", "", "", "1", "1"].map(OsString::from); assert!(maybe_run(&args, |_| panic!("no dialog"))); - let args = [FLAG, "1-1", "", "", "invalid"].map(OsString::from); + let args = [FLAG, "1-1", "", "", "invalid", "1"].map(OsString::from); assert!(maybe_run(&args, |_| panic!("no dialog"))); + for pid in ["0", "not-a-pid", "4294967296"] { + let args = [FLAG, "1-1", "", "", "0", pid].map(OsString::from); + assert!(maybe_run(&args, |_| panic!("no dialog"))); + } } #[test] @@ -556,7 +749,14 @@ mod tests { let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap()); let id = std::env::var("EC_REPORT_ID").unwrap(); let interactive = std::env::var("EC_REPORT_INTERACTIVE").unwrap(); - let args = [OsString::from(FLAG), OsString::from(&id), OsString::new(), dir.as_os_str().to_owned(), OsString::from(&interactive)]; + let args = [ + OsString::from(FLAG), + OsString::from(&id), + OsString::new(), + dir.as_os_str().to_owned(), + OsString::from(&interactive), + std::env::var_os("EC_REPORT_PID").unwrap(), + ]; assert!(maybe_run(&args, |description| { assert_eq!(interactive, "1", "non-interactive reporter must not show a dialog"); assert!(dir.join(format!("crash-{id}.txt")).exists()); @@ -577,6 +777,12 @@ mod tests { after_teardown(Some(&reporter.clone()), || {}).unwrap(); std::process::exit(0); // Models macOS not returning from its native event loop. } + if role == "windows-status" { + let _reporter = helper(&dir, "9-3"); + // The real OS status is intentionally set, not actual memory exhaustion. The + // report must preserve this distinction rather than claiming a verified exception. + std::process::exit(0xc0000017u32 as i32); + } assert_eq!(role, "abort"); let _reporter = helper(&dir, "4-1"); std::process::abort(); diff --git a/apps/effectcraft/src/diagnostic_state.rs b/apps/effectcraft/src/diagnostic_state.rs new file mode 100644 index 000000000..042e6e88c --- /dev/null +++ b/apps/effectcraft/src/diagnostic_state.rs @@ -0,0 +1,147 @@ +//! Fixed-vocabulary observations retained by the crash helper independently of the log tail. +//! Driver messages and environment text can contain private data; never forward either here. + +use std::ffi::OsStr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; + +use crate::crash_report::Shutdown; + +pub fn record(reporter: &Shutdown, event: &'static str) { + log::info!("diagnostic observation: {event}"); + if let Err(error) = reporter.record(event) { + log::warn!("could not retain diagnostic observation: {error}"); + } +} + +/// Mirror only wgpu's documented names/aliases, without calling its parser: that parser +/// logs unknown tokens verbatim. Unknown, non-Unicode and oversized values remain private. +pub fn backend_override_events(value: &OsStr) -> Vec<&'static str> { + let Some(value) = value.to_str().filter(|value| value.len() <= 4096) else { + return vec!["WGPU_BACKEND: non-Unicode or oversized value omitted"]; + }; + let mut selected = [false; 6]; + let mut unknown = false; + for token in value.split(',').map(str::trim) { + let aliases: &[&[&str]] = &[&["vulkan", "vk"], &["dx12", "d3d12"], &["metal", "mtl"], &["opengl", "gles", "gl"], &["webgpu"], &["noop"]]; + if let Some(index) = aliases.iter().position(|names| names.iter().any(|name| token.eq_ignore_ascii_case(name))) { + if let Some(flag) = selected.get_mut(index) { + *flag = true; + } + } else { + unknown = true; + } + } + let events = [ + "WGPU_BACKEND requests: vulkan", + "WGPU_BACKEND requests: dx12", + "WGPU_BACKEND requests: metal", + "WGPU_BACKEND requests: gl", + "WGPU_BACKEND requests: webgpu", + "WGPU_BACKEND requests: noop", + ]; + let mut result: Vec<_> = selected.into_iter().zip(events).filter_map(|(present, event)| present.then_some(event)).collect(); + if unknown { + result.push("WGPU_BACKEND: unrecognized or empty tokens omitted"); + } + result +} + +/// At most eight events per device, even when a driver floods its callbacks. A wgpu OOM +/// observation is not proof of system RAM exhaustion; device loss is not a driver-crash verdict. +#[derive(Clone)] +pub struct GpuObservations { + seen: Arc, + reporter: Shutdown, +} + +impl GpuObservations { + pub fn new(reporter: &Shutdown) -> Self { + Self { seen: Arc::new(AtomicU8::new(0)), reporter: reporter.clone() } + } + + pub fn uncaptured(&self, error: &eframe::wgpu::Error) { + let (bit, event) = match error { + eframe::wgpu::Error::OutOfMemory { .. } => (1, "GPU observation: wgpu out-of-memory error (not proof of system RAM exhaustion)"), + eframe::wgpu::Error::Validation { .. } => (2, "GPU observation: wgpu validation error"), + eframe::wgpu::Error::Internal { .. } => (4, "GPU observation: wgpu internal error"), + }; + self.once(bit, event); + } + + pub fn scoped(&self, error: effectcraft_ui_egui::gpu_failure::ScopedError) { + use effectcraft_ui_egui::gpu_failure::ScopedError; + let (bit, event) = match error { + ScopedError::OutOfMemory => (32, "GPU scoped observation: out-of-memory (not proof of system RAM exhaustion)"), + ScopedError::Internal => (64, "GPU scoped observation: internal error (not confirmed out-of-memory)"), + ScopedError::Validation => (128, "GPU scoped observation: validation error"), + }; + self.once(bit, event); + } + + pub fn device_lost(&self, reason: eframe::wgpu::DeviceLostReason) { + match reason { + eframe::wgpu::DeviceLostReason::Unknown => self.once(8, "GPU observation: device lost, reason unknown (not proof of a driver crash)"), + eframe::wgpu::DeviceLostReason::Destroyed => self.once(16, "GPU observation: device-lost callback, explicit device destruction"), + }; + } + + fn once(&self, bit: u8, event: &'static str) -> bool { + if self.seen.fetch_or(bit, Ordering::Relaxed) & bit != 0 { + return false; + } + record(&self.reporter, event); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn override_names_are_normalized_deduplicated_and_private() { + let events = backend_override_events(OsStr::new(" Vk, VULKAN, d3d12, MTL, gles, webgpu, noop, private-token")); + assert_eq!(events.len(), 7); + assert_eq!(events.first(), Some(&"WGPU_BACKEND requests: vulkan")); + assert!(!events.join(" ").contains("private-token")); + assert!(events.iter().all(|event| event.len() <= 100)); + assert_eq!(backend_override_events(OsStr::new("")).len(), 1); + assert_eq!(backend_override_events(OsStr::new(&"x".repeat(4097))), ["WGPU_BACKEND: non-Unicode or oversized value omitted"]); + } + + #[test] + fn gpu_categories_are_latched_independently() { + let observations = GpuObservations::new(&Shutdown::default()); + observations.uncaptured(&eframe::wgpu::Error::OutOfMemory { source: Box::new(std::io::Error::other("private driver detail")) }); + observations.device_lost(eframe::wgpu::DeviceLostReason::Unknown); + observations.device_lost(eframe::wgpu::DeviceLostReason::Unknown); + assert_eq!(observations.seen.load(Ordering::Relaxed), 9); + observations.device_lost(eframe::wgpu::DeviceLostReason::Destroyed); + observations.uncaptured(&eframe::wgpu::Error::Validation { + source: Box::new(std::io::Error::other("private driver detail")), + description: "private validation detail".into(), + }); + observations.uncaptured(&eframe::wgpu::Error::Internal { + source: Box::new(std::io::Error::other("private driver detail")), + description: "private internal detail".into(), + }); + assert_eq!(observations.seen.load(Ordering::Relaxed), 31); + use effectcraft_ui_egui::gpu_failure::ScopedError; + observations.scoped(ScopedError::Internal); + assert_eq!(observations.seen.load(Ordering::Relaxed), 95, "internal scope must not set scoped OOM bit"); + observations.scoped(ScopedError::OutOfMemory); + observations.scoped(ScopedError::Validation); + observations.scoped(ScopedError::OutOfMemory); + assert_eq!(observations.seen.load(Ordering::Relaxed), 255); + assert!(!observations.once(1, "GPU observation: duplicate")); + assert!(!observations.once(8, "GPU observation: duplicate")); + } + + #[test] + #[cfg(unix)] + fn non_unicode_override_is_not_copied() { + use std::os::unix::ffi::OsStrExt; + assert_eq!(backend_override_events(OsStr::from_bytes(b"private\xff")), ["WGPU_BACKEND: non-Unicode or oversized value omitted"]); + } +} diff --git a/apps/effectcraft/src/launch_guard.rs b/apps/effectcraft/src/launch_guard.rs index a99981cd7..2656179ac 100644 --- a/apps/effectcraft/src/launch_guard.rs +++ b/apps/effectcraft/src/launch_guard.rs @@ -68,11 +68,34 @@ fn acquire_guard(dir: &Path) -> Option { /// switch back in Settings. pub const SETTLE: Duration = Duration::from_secs(3); +/// Why this launch requested its window graphics APIs. An interrupted startup is an +/// observation, not proof that a driver caused the previous exit. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum BackendSource { + #[default] + Default, + SavedPreference, + InterruptedStartup, + EnvironmentOverride, +} + +impl BackendSource { + pub fn event(self) -> &'static str { + match self { + Self::Default => "backend source: default", + Self::SavedPreference => "backend source: saved OpenGL preference", + Self::InterruptedStartup => "backend source: previous interrupted startup; OpenGL fallback", + Self::EnvironmentOverride => "backend source: WGPU_BACKEND present", + } + } +} + /// This launch's window graphics. #[derive(Debug, Default)] pub struct Launch { /// The graphics APIs the window may use (`None`: eframe's default). pub backends: Option, + pub source: BackendSource, /// Switched to OpenGL because the previous launch stopped as its window opened: the message /// to show. pub notice: Option, @@ -88,12 +111,17 @@ impl Launch { /// launch stopped as its window opened (where OpenGL exists: not macOS), and leave this /// launch's marker. `env_override`: `WGPU_BACKEND` is set. pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch { - let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() }; + if env_override { + return Launch { source: BackendSource::EnvironmentOverride, ..Launch::default() }; + } + let Some(dir) = dir else { return Launch::default() }; let prefs_path = dir.join(PREFS_FILE); let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default(); let marker = dir.join(MARKER); let Some(lock) = acquire_guard(dir) else { - return Launch { backends: (prefs.startup.window_graphics == "gl").then_some(Backends::GL), ..Launch::default() }; + let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL); + let source = if backends.is_some() { BackendSource::SavedPreference } else { BackendSource::Default }; + return Launch { backends, source, ..Launch::default() }; }; // Read again while owning the guard: another launch may have just changed preferences. prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default(); @@ -112,6 +140,13 @@ impl Launch { ); } let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL); + let source = if notice.is_some() { + BackendSource::InterruptedStartup + } else if backends.is_some() { + BackendSource::SavedPreference + } else { + BackendSource::Default + }; let marker = match File::create(&marker) { Ok(_) => Some(marker), Err(e) => { @@ -119,7 +154,7 @@ impl Launch { None } }; - Launch { backends, notice, marker, lock: Some(lock), first_frame: None } + Launch { backends, source, notice, marker, lock: Some(lock), first_frame: None } } /// The window drew a frame. Until the launch has settled, returns how long that still takes @@ -185,6 +220,7 @@ mod tests { // may still crash as the window is shown); frames for `SETTLE` do. let mut l = Launch::begin(Some(&dir), false); assert_eq!((l.backends, l.notice.is_some()), (None, false)); + assert_eq!(l.source, BackendSource::Default); assert!(dir.join(MARKER).exists()); let t0 = Instant::now(); assert_eq!(l.frame_drawn_at(t0), Some(SETTLE)); @@ -202,14 +238,18 @@ mod tests { } else { assert_eq!(l.backends, Some(Backends::GL)); assert!(l.notice.is_some()); + assert_eq!(l.source, BackendSource::InterruptedStartup); assert_eq!(window_graphics(&dir), "gl", "saved in Settings"); l.exited(); let l = Launch::begin(Some(&dir), false); assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice"); + assert_eq!(l.source, BackendSource::SavedPreference); } // `WGPU_BACKEND` wins, and leaves no marker. let _ = std::fs::remove_file(dir.join(MARKER)); - assert_eq!(Launch::begin(Some(&dir), true).backends, None); + let overridden = Launch::begin(Some(&dir), true); + assert_eq!(overridden.backends, None); + assert_eq!(overridden.source, BackendSource::EnvironmentOverride); assert!(!dir.join(MARKER).exists()); let _ = std::fs::remove_dir_all(&dir); } @@ -276,6 +316,19 @@ mod tests { std::fs::write(dir.join(PREFS_FILE), prefs.to_json()).unwrap(); } + #[test] + fn a_second_copy_reports_the_saved_backend_without_owning_the_guard() { + let dir = fresh_dir("second-source"); + save_opengl(&dir); + let mut first = Launch::begin(Some(&dir), false); + let mut second = Launch::begin(Some(&dir), false); + assert!(second.lock.is_none()); + assert_eq!((second.backends, second.source), (Some(Backends::GL), BackendSource::SavedPreference)); + second.exited(); + first.exited(); + let _ = std::fs::remove_dir_all(dir); + } + /// A second copy that quits normally while the first is still opening doesn't remove the first /// one's marker (#477). #[test] diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 3d9455c81..5a602adcb 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -23,6 +23,7 @@ mod apple_events; mod audio_out; mod control_server; mod crash_report; +mod diagnostic_state; mod launch_guard; #[cfg(target_os = "macos")] mod native_menu; @@ -183,7 +184,7 @@ fn start_helpers_if_opted_in(session: &mut effectcraft_engine::Session) { fn main() -> eframe::Result { let args: Vec<_> = std::env::args_os().skip(1).collect(); if crash_report::maybe_run(&args, |description| { - rfd::MessageDialog::new().set_level(rfd::MessageLevel::Error).set_title("EffectCraft stopped unexpectedly").set_description(description).show(); + rfd::MessageDialog::new().set_level(rfd::MessageLevel::Error).set_title("EffectCraft diagnostic report").set_description(description).show(); }) { return Ok(()); } @@ -246,8 +247,15 @@ fn run_desktop(reporter: &crash_report::Shutdown) -> eframe::Result { #[cfg(target_os = "linux")] appimage::integrate_from_env(ICON_PNG); // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch - // stopped as its window opened (a crashing graphics driver, #243). - let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some()); + // stopped as its window opened (the marker alone does not establish a cause). + let backend_override = std::env::var_os("WGPU_BACKEND"); + let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), backend_override.is_some()); + diagnostic_state::record(reporter, launch.source.event()); + if let Some(value) = backend_override { + for event in diagnostic_state::backend_override_events(&value) { + diagnostic_state::record(reporter, event); + } + } let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL); let notice = launch.notice.take(); let options = eframe::NativeOptions { @@ -281,16 +289,33 @@ fn run_desktop(reporter: &crash_report::Shutdown) -> eframe::Result { Box::new(move |cc| { EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx); match &cc.wgpu_render_state { - Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()), - None => log::info!("graphics: no wgpu device"), + Some(rs) => { + let info = rs.adapter.get_info(); + log::info!("graphics: {info:?}"); + let event = match info.backend { + eframe::wgpu::Backend::Noop => "actual GPU backend: noop", + eframe::wgpu::Backend::Vulkan => "actual GPU backend: vulkan", + eframe::wgpu::Backend::Metal => "actual GPU backend: metal", + eframe::wgpu::Backend::Dx12 => "actual GPU backend: dx12", + eframe::wgpu::Backend::Gl => "actual GPU backend: gl", + eframe::wgpu::Backend::BrowserWebGpu => "actual GPU backend: webgpu", + }; + diagnostic_state::record(&desktop_reporter, event); + } + None => diagnostic_state::record(&desktop_reporter, "actual GPU backend: no wgpu device"), } // This executable owns the shared device's handlers. Install them before // EffectCraft's compositor pipelines are built, without changing handlers from // inside Gpu::new. eframe has already constructed its presentation renderer here. let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx); + let observations = diagnostic_state::GpuObservations::new(&desktop_reporter); + let scoped_observed = observations.clone(); + gpu_failures.set_scoped_error_observer(move |error| scoped_observed.scoped(error)); if let Some(rs) = &cc.wgpu_render_state { + let errors_observed = observations.clone(); let errors = gpu_failures.clone(); rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| { + errors_observed.uncaptured(&error); let message = format!("uncaptured GPU error: {error}"); if errors.report(&message, false) { log::error!("{message}"); @@ -298,6 +323,7 @@ fn run_desktop(reporter: &crash_report::Shutdown) -> eframe::Result { })); let lost = gpu_failures.clone(); rs.device.set_device_lost_callback(move |reason, message| { + observations.device_lost(reason); lost.report(&format!("GPU device lost ({reason:?}): {message}"), true); }); } diff --git a/crates/gpu/src/diagnostics.rs b/crates/gpu/src/diagnostics.rs new file mode 100644 index 000000000..a37adff22 --- /dev/null +++ b/crates/gpu/src/diagnostics.rs @@ -0,0 +1,102 @@ +//! Per-compositor, fixed-category observations from error scopes. No driver strings escape. + +#[cfg(any(test, not(target_arch = "wasm32")))] +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, OnceLock}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScopedError { + OutOfMemory, + Internal, + Validation, +} + +pub type ScopedErrorObserver = Arc; + +#[derive(Default)] +pub(crate) struct ScopedObservations { + observer: OnceLock, + #[cfg(any(test, not(target_arch = "wasm32")))] + seen: AtomicU8, +} + +impl ScopedObservations { + /// Register before rendering. Shared by all clones of the compositor, never global. + pub(crate) fn install(&self, observer: ScopedErrorObserver) -> bool { + self.observer.set(observer).is_ok() + } + + #[cfg(any(test, not(target_arch = "wasm32")))] + pub(crate) fn report_scopes(&self, oom: bool, internal: bool, validation: bool) { + for (present, category) in [(oom, ScopedError::OutOfMemory), (internal, ScopedError::Internal), (validation, ScopedError::Validation)] { + if present { + self.report(category); + } + } + } + + #[cfg(any(test, not(target_arch = "wasm32")))] + fn report(&self, error: ScopedError) { + let Some(observer) = self.observer.get() else { return }; + let bit = match error { + ScopedError::OutOfMemory => 1, + ScopedError::Internal => 2, + ScopedError::Validation => 4, + }; + if self.seen.fetch_or(bit, Ordering::Relaxed) & bit != 0 { + return; + } + // Diagnostics must not turn a recoverable GPU frame error into an application panic. + #[cfg(not(target_arch = "wasm32"))] + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| observer(error))).is_err() { + log::warn!("GPU scoped-error observer panicked"); + } + #[cfg(target_arch = "wasm32")] + observer(error); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn categories_are_independent_bounded_and_not_global() { + let observations = ScopedObservations::default(); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let copy = seen.clone(); + assert!(observations.install(Arc::new(move |error| copy.lock().unwrap().push(error)))); + assert!(!observations.install(Arc::new(|_| panic!("must not replace observer")))); + for _ in 0..100 { + observations.report(ScopedError::Internal); + observations.report(ScopedError::Validation); + observations.report(ScopedError::OutOfMemory); + } + ScopedObservations::default().report(ScopedError::OutOfMemory); + assert_eq!(*seen.lock().unwrap(), [ScopedError::Internal, ScopedError::Validation, ScopedError::OutOfMemory]); + } + + #[test] + fn internal_and_validation_scopes_do_not_claim_out_of_memory() { + let observations = ScopedObservations::default(); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let copy = seen.clone(); + observations.install(Arc::new(move |error| copy.lock().unwrap().push(error))); + observations.report_scopes(false, false, false); + assert!(seen.lock().unwrap().is_empty()); + observations.report_scopes(false, true, true); + assert_eq!(*seen.lock().unwrap(), [ScopedError::Internal, ScopedError::Validation]); + observations.report_scopes(true, true, true); + assert_eq!(*seen.lock().unwrap(), [ScopedError::Internal, ScopedError::Validation, ScopedError::OutOfMemory]); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn observer_panic_does_not_escape_frame_recovery() { + let observations = ScopedObservations::default(); + observations.install(Arc::new(|_| panic!("synthetic observer panic"))); + observations.report(ScopedError::OutOfMemory); + observations.report(ScopedError::Internal); + assert_eq!(observations.seen.load(Ordering::Relaxed), 3); + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index ddbc8d54a..86a498c6e 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -58,6 +58,7 @@ mod bokeh; mod classic3d; mod context; pub mod deferred; +mod diagnostics; mod effects; mod fx_color; mod fx_depth; @@ -91,6 +92,7 @@ mod yuv; use std::sync::Arc; pub use context::{GpuContext, GpuImage, TransferStats}; +pub use diagnostics::{ScopedError, ScopedErrorObserver}; use effectcraft_effects::Buf; use effectcraft_project::ItemId; use effectcraft_raster::Image; @@ -126,6 +128,7 @@ impl std::error::Error for FrameError {} /// The GPU compositor (cheap to clone; one device shared by all clones). #[derive(Clone)] pub struct Gpu { + scoped_observations: Arc, ctx: Arc, /// Backend::Auto's per-comp CPU / GPU timings. auto: Arc, @@ -162,7 +165,7 @@ impl Gpu { } pub fn from_context(ctx: GpuContext) -> Gpu { - Gpu { ctx: Arc::new(ctx), auto: Default::default(), declined: Default::default() } + Gpu { ctx: Arc::new(ctx), auto: Default::default(), declined: Default::default(), scoped_observations: Default::default() } } /// A device of its own without blocking, with deferred readbacks (a browser worker, see @@ -173,6 +176,13 @@ impl Gpu { Ok(Gpu::from_context(ctx)) } + /// Install a host's fixed-category observer before rendering. It is called at most once + /// per category across all clones, outside device/renderer locks. Returns false if already + /// registered. The observer must remain brief and must not perform GPU work. + pub fn set_scoped_error_observer(&self, observer: ScopedErrorObserver) -> bool { + self.scoped_observations.install(observer) + } + /// [`Gpu::request_deferred`] blocking (tests of the browser worker's path, natively). #[cfg(not(target_arch = "wasm32"))] pub fn headless_deferred() -> Option { @@ -316,6 +326,9 @@ impl Gpu { let validation = pollster::block_on(validation.pop()); let internal = pollster::block_on(internal.pop()); let oom = pollster::block_on(oom.pop()); + // Preserve the typed scope observations before FrameError::Memory combines OOM + // and internal errors. An internal error alone is not evidence of actual OOM. + self.scoped_observations.report_scopes(oom.is_some(), internal.is_some(), validation.is_some()); let memory = oom.is_some() || internal.is_some(); // The allocation failure first: a validation error is often its consequence. let errors: Vec = [oom, internal, validation].into_iter().flatten().map(|e| e.to_string()).collect(); diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs index c9849e921..92ea7665c 100644 --- a/crates/ui-egui/src/app/gpu.rs +++ b/crates/ui-egui/src/app/gpu.rs @@ -44,6 +44,9 @@ impl EffectcraftApp { pub fn set_gpu_failure_bridge(&mut self, bridge: gpu_failure::GpuFailureBridge) { if let Some(gpu) = &self.gpu { bridge.forward_to(gpu.context().device_failure_notifier()); + if let Some(observer) = bridge.scoped_error_observer() { + gpu.set_scoped_error_observer(observer); + } } self.gpu_failures = Some(bridge); } @@ -257,6 +260,9 @@ impl EffectcraftApp { Ok(Ok(g)) => { if let Some(bridge) = &self.gpu_failures { bridge.forward_to(g.context().device_failure_notifier()); + if let Some(observer) = bridge.scoped_error_observer() { + g.set_scoped_error_observer(observer); + } if bridge.failure().is_some() { return; } diff --git a/crates/ui-egui/src/gpu_failure.rs b/crates/ui-egui/src/gpu_failure.rs index 275112912..a0840b790 100644 --- a/crates/ui-egui/src/gpu_failure.rs +++ b/crates/ui-egui/src/gpu_failure.rs @@ -2,7 +2,9 @@ //! embedding host, never to a library borrowing its device. This bridge does no device work, //! document mutation, file I/O or renderer locking inside a notification. -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; + +pub use effectcraft_gpu::{ScopedError, ScopedErrorObserver}; type Notifier = Arc; @@ -25,11 +27,22 @@ struct State { pub struct GpuFailureBridge { state: Arc>, ctx: egui::Context, + scoped_observer: Arc>, } impl GpuFailureBridge { pub fn new(ctx: &egui::Context) -> Self { - Self { state: Arc::default(), ctx: ctx.clone() } + Self { state: Arc::default(), ctx: ctx.clone(), scoped_observer: Arc::default() } + } + + /// Register the host observer before handing this bridge to the app. Scoped errors do + /// not retire the device: they remain recoverable per-frame observations. + pub fn set_scoped_error_observer(&self, observer: impl Fn(ScopedError) + Send + Sync + 'static) -> bool { + self.scoped_observer.set(Arc::new(observer)).is_ok() + } + + pub(crate) fn scoped_error_observer(&self) -> Option { + self.scoped_observer.get().cloned() } pub fn failure(&self) -> Option { @@ -90,6 +103,18 @@ mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn scoped_observer_is_shared_without_retiring_the_device() { + let bridge = GpuFailureBridge::new(&egui::Context::default()); + let seen = Arc::new(Mutex::new(Vec::new())); + let copy = seen.clone(); + assert!(bridge.set_scoped_error_observer(move |error| copy.lock().unwrap().push(error))); + assert!(!bridge.set_scoped_error_observer(|_| panic!("must not replace observer"))); + bridge.clone().scoped_error_observer().unwrap()(ScopedError::Internal); + assert_eq!(*seen.lock().unwrap(), [ScopedError::Internal]); + assert!(bridge.failure().is_none()); + } + #[test] fn a_construction_failure_is_replayed_after_notifier_registration() { let bridge = GpuFailureBridge::new(&egui::Context::default()); diff --git a/docs/testing.md b/docs/testing.md index e6cd8df68..4a7610d16 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -123,16 +123,45 @@ A teardown panic leaves reporting armed. An abort or other unexpected process te closes the pipe without that marker. The helper saves a local report under the session log folder's `Crash Reports` directory and shows a native error dialog. Control-server launches save the report without a modal dialog. -Reports contain version/platform and at most the last 64 KiB of the matching session's log. -Logging filters and the session file size cap can omit later events from that tail. +Reports contain version/platform, UTC detection and observer-start times, observed duration, +GUI uptime at the last target-present sample, and at most the last 64 KiB of the matching +session's log. A separate ring retains the last 32 timestamped, fixed-vocabulary backend and +GPU observations even if startup messages have fallen out of the log tail. `WGPU_BACKEND` +values are normalized to known backend names; arbitrary environment text is not copied. +Logging filters and the session file size cap can omit later events from the log tail. They are never uploaded automatically. Review logs for private project paths before sharing. +On Windows the helper acquires a process handle before acknowledging readiness and retains +it until the GUI exits. It records the real OS exit status in unsigned hexadecimal, with a +small lookup of known status names. These bits are **not a verified exception record**: +software can explicitly set the same code, and an access violation does not identify the +faulting module or prove a driver crash. Other platforms report exit status as unavailable. +A lost pipe while the GUI is still present, or a failed/absent process refresh, is not reported +as confirmed termination. + +The helper samples only the target process and system RAM at roughly one-second intervals, +retaining a count, last-attempt time, sampled resident-memory maximum, Windows private-commit +maximum (virtual memory on other platforms), and system available-RAM minimum. This is a +fixed-size summary, not a memory dump. OS query failure can leave stale or zero values, and +sampling misses short spikes. System commit limits/usage and GPU VRAM budgets are not captured. +No threshold is treated as proof of memory exhaustion. Typed wgpu OOM, internal/validation +errors and device-loss notifications are observations; device destruction is distinguished +from unknown loss, and neither establishes why the process later exited. + Run `cargo test -p effectcraft crash_report::tests` for bounded protocol, normal shutdown, actual subprocess abort, unrelated child handles, concurrent sessions, missing log/helper, -log rotation and report-write failure coverage. The three ignored tests are subprocess entry +log rotation, bounded retained events, live-GUI pipe loss, and report-write failure coverage. +Windows also tests a real subprocess exit with explicit `0xC0000017` bits, without exhausting +memory or pretending that the exit was an actual OOM exception. The three ignored tests are subprocess entry points launched by these tests, not manual tests. -This is diagnosis, not memory recovery: a pipe closing does not prove an out-of-memory failure -or provide the process's exit code. System-wide exhaustion, killing the whole process tree, +These are observations, not memory recovery or a causal diagnosis: a pipe closing does not +prove an out-of-memory failure. System-wide exhaustion, killing the whole process tree, or an unavailable native dialog service can also prevent the helper from reporting. Existing auto-save recovery remains responsible for restoring previously saved work. + +Native exception records, faulting-module attribution and driver/TDR confirmation require +separately collected OS event evidence or a native dump. They are deliberately not gathered +here: dumps can contain document contents and credentials. Any future dump workflow must be +explicitly opt-in, bounded, local-first and separately reviewed before sharing. This reporter +must remain useful when those sources are unavailable. From 3c6abe3606c868990ad5ba7bec2e259431b222aa Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 18:44:24 +0530 Subject: [PATCH 035/227] Timeline: a Shift-drag on an unselected bar moves the whole selection (#1828) A Shift-press on an unselected bar adds it to the selection, so the drag now moves that selection and its layers are no snap targets. Tests cover the selection drag and that a moved layer's own marker is no target. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 13 ++++++++----- crates/ui-egui/src/panels/timeline/tests.rs | 19 +++++++++++++++---- crates/ui-egui/tests/it/ui_timeline_trim.rs | 13 +++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index be33e0e5a..f9804f995 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3526,11 +3526,15 @@ fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) { } let id = egui::Id::unique(("bar-drag", layer.id.0, kind)); if resp.dragged() { - // Moving or trimming a selected layer acts on the whole selection as it was when the - // drag started (a Shift-drag adds the layer to it); each layer trims from its own - // in/out point. + let shift = ctx.input(|i| i.modifiers.shift); + // Moving or trimming a selected layer acts on the whole selection as it is when the + // drag starts; each layer trims from its own in/out point. A Shift-drag on an + // unselected bar adds it to the selection (`layer_bar_hits`) and moves them all. let start = || { - let edited = edited_layers(layer.id, &g.selected); + let mut edited = edited_layers(layer.id, &g.selected); + if kind == "move" && shift && !g.selected.contains(&layer.id) { + edited.extend(g.selected.iter().map(|l| l.0)); + } let (i, o) = (layer.in_point.seconds(), layer.out_point.seconds()); let ends = match kind { "move" => vec![i, o], @@ -3543,7 +3547,6 @@ fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) { }; let mut drag = ctx.data(|d| d.get_temp::(id)).filter(|_| !resp.drag_started()).unwrap_or_else(start); drag.px += f64::from(resp.drag_delta().x); - let shift = ctx.input(|i| i.modifiers.shift); let frames = bar_drag_frames(drag.px, g.tl.pps, g.tl.fd, &drag.ends, &drag.targets, shift); if frames != drag.sent { let d = frames.saturating_sub(drag.sent) as f64 * g.tl.fd; diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 9185c614b..1cd5e3423 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -432,11 +432,22 @@ fn cti_snaps_to_the_nearest_candidate() { let comp = app.session.active_comp().unwrap().clone(); let c = snap_candidates(&comp, &build_rows(&app, &comp)); assert!(c.contains(&comp.duration.seconds()) && c.contains(&0.0)); - // A dragged layer's own ends are no targets; moved, neither are its markers and keys (#1828). +} + +/// A dragged layer's own In / Out points are no snap targets; moved, neither are its markers +/// (#1828). +#[test] +fn bar_drags_dont_snap_to_their_own_layer() { + let mut app = app(); + let bx = app.session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0; + app.session.execute("layer.addMarker", json!({"layers": [bx], "time": 1.0})).unwrap(); + let comp = app.session.active_comp().unwrap().clone(); let rows = build_rows(&app, &comp); - let l = comp.layers.first().unwrap().id.0; - assert_eq!(snap_targets(&comp, &rows, &[l], false).len(), c.len() - 2); - assert!(snap_targets(&comp, &rows, &[l], true).len() <= c.len() - 2); + let all = snap_targets(&comp, &rows, &[], false); + let trimmed = snap_targets(&comp, &rows, &[bx], false); + let moved = snap_targets(&comp, &rows, &[bx], true); + assert_eq!(trimmed.len(), all.len() - 2, "trimming drops only the layer's In and Out"); + assert!(trimmed.contains(&1.0) && !moved.contains(&1.0), "moving drops the layer's marker: {moved:?}"); } /// Shift snaps a bar drag's nearest end to a target within 8 px of the pointer; farther away, or diff --git a/crates/ui-egui/tests/it/ui_timeline_trim.rs b/crates/ui-egui/tests/it/ui_timeline_trim.rs index 0ba1c73a6..9aaf1f500 100644 --- a/crates/ui-egui/tests/it/ui_timeline_trim.rs +++ b/crates/ui-egui/tests/it/ui_timeline_trim.rs @@ -148,6 +148,19 @@ fn dragging_bar_ends_trims_in_and_out() { assert!((i - 3.0).abs() < 1e-6 && (o - 8.0).abs() < 1.5 / 30.0 && s.abs() < 1e-6, "out extended to ~8 s, not slipped: in {i} out {o} start {s}"); } +/// A Shift-drag on an unselected bar adds it to the selection and moves the whole selection +/// together, so the other selected layers keep their offset (#1828). +#[test] +fn shift_dragging_an_unselected_bar_moves_the_selection() { + let (mut h, solid, pre) = harness(); + h.state_mut().session.execute("layer.select", json!({"layers": [pre]})).unwrap(); + h.run_steps(2); + let b = bar(&h, solid); + drag_with(&mut h, b.center(), b.center() + vec2(b.width() / 4.0 * 2.5, 0.0), Modifiers::SHIFT); + let ((si, ..), (pi, ..)) = (timing(&h, solid), timing(&h, pre)); + assert!(si > 4.0 && (pi - si - 1.0).abs() < 1e-6, "both moved by the same amount: solid in {si}, precomp in {pi}"); +} + /// With Frames and Frame Count Start at 1, In moves to 00061 but the Duration column still counts /// the layer's 120 frames (#1807). #[test] From d0eb89904cff29d8216052d13f7fd5111b85af44 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 19:26:51 +0530 Subject: [PATCH 036/227] Timeline: note that a snapped bar edge off the frame grid lands within half a frame (#1828) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index f9804f995..4de3233e8 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3498,7 +3498,8 @@ struct BarDrag { /// The whole frames a bar drag of `px` moves its ends by: truncated as the pointer goes, or with /// `shift`, the move that puts one of `ends` on the nearest target within 8 px of the pointer -/// (#1828). Measured from the pointer, never the snapped result, so dragging on lets go. +/// (#1828). Measured from the pointer, never the snapped result, so dragging on lets go. The +/// move stays whole frames, so an end off the frame grid lands within half a frame of its target. fn bar_drag_frames(px: f64, pps: f64, fd: f64, ends: &[f64], targets: &[f64], shift: bool) -> i64 { let secs = px / pps; let snapped = shift From 13470ca1618ffbe426a0f05a649a1c7bb4dceda7 Mon Sep 17 00:00:00 2001 From: eliasx45 Date: Tue, 10 Nov 2026 14:57:32 +0100 Subject: [PATCH 037/227] Add affected CI and replayable crash repros (#1224) * AG4: xtask ci --affected cargo xtask ci --affected [--base REV] [--dry-run] runs fmt, clippy, tests (nextest + doctests when available), layers, assets (only on asset changes) and wasm (only for affected L0-L4/web crates) for the packages a change can affect: changed files map to the owning package and to packages whose sources read them via ../ paths, expanded to reverse dependents. Workspace-wide inputs (root Cargo.toml, Cargo.lock, .cargo/, xtask/, toolchain, clippy.toml) fall back to full CI. Documented in docs/testing.md, AGENTS.md and CLAUDE.md. (cherry picked from commit f3ca84fc2ee3948582cbc94e4c99ee510e829540) (cherry picked from commit 8a2cdcadf447eafa57ab14016beae4420f002af0) (cherry picked from commit d8a4301bc10c7bbcc2c4e5c70980f4b774d6efe3) (cherry picked from commit 36402c61ac2f7e29ecd76a6cfc89365cc2572e0a) * AO1: fast CI profile, looser timing tests - [profile.ci] (release without LTO, 16 codegen units); `xtask ci` runs clippy and tests with --profile ci instead of --release. - Wall-clock asserts in engine memory_tick, gpu OpenGL decline and script socket tests keep their intent with bounds that survive a loaded machine. - docs/testing.md and CONTRIBUTING.md mention the profile. (cherry picked from commit b45c8a379351ebdfda0d7076ad4b740baf6f2087) (cherry picked from commit fa1ea6a107c4c1936cde89b518a05adc73f2b4b0) (cherry picked from commit e5d5ed80e9ead43c2ce62b75ec1902340826a8e1) (cherry picked from commit d2c67660c70764f9c56880a642ad7f0d004a7f92) * AG6: journal replay repros Bug reports become replay scripts and crash regression tests become data files: - engine::replay: a JSON-lines repro format (start line, command steps with expect_error, expect queries with JSON pointer + equals, expect_no_error), a never-panicking runner that reports per-step ok/error and the first failure, and journal export (from the last project open; saves left out; size-capped). - journal.export {path?} and help.exportRepro {path?} (not journaled). - Crash capture: when the last-resort guard catches a panic in the outermost command, the journal plus that command is saved to Repros/crash-*.jsonl in the settings store (newest 20 kept) and the error names the file. - Commands run by a journaled command (script steps) are no longer journaled twice. - effectcraft-cli replay SCRIPT.jsonl [--json] [--keep-going]: exits 1 on a failed step, unmet expectation or panic. - crates/host/tests/repros.rs replays every tests/repros/*.jsonl; three samples from past crash fixes (zero frame rate, Layer Settings with no selection, New Comp from Selection with hostile overlap). - Docs: docs/agents.md "Repro scripts"; AGENTS.md: crash fix regression test = a .jsonl in tests/repros. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit c38509d7dafe507856c48c78db65e00b94b0cebc) (cherry picked from commit e55b56dbf76716adfcbfe4cc773df19a74edc419) (cherry picked from commit 96a63855a98b4a05c24f9095b949aebf29d8665a) (cherry picked from commit c59351ab3bd697f0868e41230dd883960d94a540) * xtask: ci and ci --affected run cargo test and nextest with --no-fail-fast One run now lists every failing crate instead of stopping at the first. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019qXL3GPNnY9BSFF2XoU2Yu (cherry picked from commit e46c7debe738eca62b35bf7392ab77637f6a33ae) * Agents: confinement write-checks journal.export, help.exportRepro and file.exportTimeline Review on #859 (split into this PR): their output paths were only read-checked (relative ones not at all). Test: confinement_covers_every_command_that_writes_a_file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014d65YdNxQmjRkbXRtP2NwH * #1224: handle bin-only CI targets and BOM repros without weakening timing tests * #1224: run the full gate when nextest policy changes --------- Co-authored-by: Elias Co-authored-by: Claude Co-authored-by: FredApps Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- CONTRIBUTING.md | 2 +- apps/effectcraft-cli/src/main.rs | 61 +- apps/effectcraft-cli/tests/it/cli.rs | 36 + crates/automation/src/confine.rs | 7 + crates/automation/src/tests.rs | 25 + crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/journal.rs | 53 ++ crates/engine/src/lib.rs | 1 + crates/engine/src/replay.rs | 748 +++++++++++++++++ crates/engine/src/session/dispatch.rs | 16 +- crates/engine/src/system/guard.rs | 14 + crates/host/tests/it/main.rs | 2 + crates/host/tests/it/repros.rs | 31 + docs/agents.md | 69 ++ docs/testing.md | 42 + tests/repros/comp-zero-frame-rate.jsonl | 13 + .../layer-settings-empty-selection.jsonl | 8 + ...-comp-from-selection-hostile-overlap.jsonl | 17 + xtask/src/affected.rs | 764 ++++++++++++++++++ xtask/src/main.rs | 83 +- 20 files changed, 1970 insertions(+), 24 deletions(-) create mode 100644 crates/engine/src/cmd/journal.rs create mode 100644 crates/engine/src/replay.rs create mode 100644 crates/host/tests/it/repros.rs create mode 100644 tests/repros/comp-zero-frame-rate.jsonl create mode 100644 tests/repros/layer-settings-empty-selection.jsonl create mode 100644 tests/repros/new-comp-from-selection-hostile-overlap.jsonl create mode 100644 xtask/src/affected.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29521ad73..8ce4b85aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ that variable set. ```sh cargo run -p effectcraft -- --demo # the desktop app, with the demo project cargo test --workspace -cargo xtask ci # what every commit must pass +cargo xtask ci # what every commit must pass (dev-profile checks and tests) ``` The default `dev` profile keeps debug info to line tables: builds are fast and panics/backtraces diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index b8634178e..f52401a00 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -31,6 +31,7 @@ //! effectcraft-cli bench --roto [--frames N] [--sam2 model.safetensors] [--mobilesam mobile_sam.pt] [--fixes] [--clip FRAMES --truth MASKS] [--json] Roto Brush trackers on synthetic clips with known mattes //! (classical engine, MobileSAM and SAM 2.1 (any installed size) when installed; IoU, flicker, re-appearance, seconds per frame) //! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script +//! effectcraft-cli replay SCRIPT.jsonl [--keep-going] replay a repro script (docs/agents.md) //! effectcraft-cli mcp [--autosave | --bridge PORT] MCP server on stdio //! //! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty) @@ -97,7 +98,7 @@ fn stdout_broken() -> bool { STDOUT_FAILED.get().is_some_and(|k| *k != std::io::ErrorKind::BrokenPipe) } -const USAGE: &str = "usage: effectcraft-cli [args] [--json] +const USAGE: &str = "usage: effectcraft-cli [args] [--json] info project + engine summary commands [--filter TEXT] [--enabled] list engine commands exec [--params JSON] run one engine command (exec --list: list them, as `commands`) @@ -138,6 +139,9 @@ const USAGE: &str = "usage: effectcraft-cli Result<(), Failure> { Ok(()) } +/// `replay SCRIPT.jsonl`: run a repro script against a headless session (its start line picks +/// the project) and report every step. Exits 1 when the replay fails. +fn replay_cmd(args: &Args, json_out: bool) -> Result<(), Failure> { + use effectcraft_engine::replay; + let Some(file) = args.pos.first() else { return usage_err("replay needs a .jsonl script") }; + if args.opt("--bridge").is_some() || args.project.is_some() || args.flag("--demo") || args.flag("--empty") { + return usage_err("replay runs headless from the script's start line ({\"start\": \"new\" | \"demo\" | {\"project\": path}})"); + } + use std::io::Read; + let path = std::path::Path::new(file); + let input = std::fs::File::open(path).map_err(|e| Failure::Error(format!("cannot read {file}: {e}")))?; + // Bound the read itself: metadata can be stale or describe a pipe rather than its contents. + let mut script = String::new(); + input.take(replay::MAX_SCRIPT_BYTES as u64 + 1).read_to_string(&mut script).map_err(|e| Failure::Error(format!("cannot read {file}: {e}")))?; + if script.len() > replay::MAX_SCRIPT_BYTES { + return Err(Failure::Error(format!("{file} is larger than {} MB", replay::MAX_SCRIPT_BYTES >> 20))); + } + let mut s = session(args)?; + let opts = replay::Options { keep_going: args.flag("--keep-going"), base_dir: path.parent().map(std::path::Path::to_path_buf) }; + let report = replay::run(&mut s, &script, &opts); + if json_out { + emit(&serde_json::to_value(&report).unwrap_or_else(|e| json!({"error": e.to_string()})), true); + } else { + for st in &report.steps { + let what = st.command.as_deref().unwrap_or(st.kind); + match (&st.error, st.ok) { + (None, _) => say!("line {:>4} ok {what}", st.line), + (Some(e), true) => say!("line {:>4} ok {what} (tolerated error: {e})", st.line), + (Some(e), false) => say!("line {:>4} FAIL {what}: {e}", st.line), + } + } + } + let Some(first) = &report.first_failure else { + if !json_out { + say!("replay ok: {} steps", report.steps.len()); + } + return Ok(()); + }; + let msg = format!( + "failed at line {} ({}){}: {}{}", + first.line, + first.command.as_deref().unwrap_or(first.kind), + if report.panicked { ", a command panicked" } else { "" }, + first.error.as_deref().unwrap_or(""), + if report.skipped > 0 { format!(" ({} later steps not run)", report.skipped) } else { String::new() } + ); + if json_out { + note!("effectcraft-cli replay: {msg}"); + std::process::exit(1); + } + Err(Failure::Error(msg)) +} + /// Print a result: compact JSON with `--json`, else pretty JSON. fn emit(v: &Value, json_out: bool) { if json_out { @@ -614,6 +672,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { "commands" => list_commands_cmd(args, json_out)?, "exec" if args.flag("--list") => list_commands_cmd(args, json_out)?, "script" => script_cmd(args, json_out)?, + "replay" => replay_cmd(args, json_out)?, "exec" => { let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") }; let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) { diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs index 363eb8e52..4510f16c9 100644 --- a/apps/effectcraft-cli/tests/it/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -1223,3 +1223,39 @@ fn render_exr_compression_and_depth() { assert!(reply["error"].as_str().unwrap().contains("exrBitDepth: half|float"), "{reply}"); let _ = std::fs::remove_dir_all(&root); } + +#[test] +fn replay_reports_steps_and_the_first_failure() { + let repro = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/repros/comp-zero-frame-rate.jsonl"); + let v = ok_json(&["replay", repro.to_str().unwrap()]); + assert_eq!(v["ok"], true, "{v}"); + assert!(v["steps"].as_array().unwrap().len() > 5); + let bad = tmp("bad-repro.jsonl"); + std::fs::write( + &bad, + "{\"start\": \"new\"}\n{\"command\": \"comp.new\"}\n{\"expect\": {\"query\": \"comp.info\", \"pointer\": \"/width\", \"equals\": 1}}\nnot json\n", + ) + .unwrap(); + let (code, v) = run_json(&["replay", bad.to_str().unwrap()]); + assert_eq!(code, 1); + assert_eq!(v["ok"], false); + assert_eq!(v["first_failure"]["line"], 3, "{v}"); + assert_eq!(v["skipped"], 1); + let out = bin().args(["replay", bad.to_str().unwrap(), "--keep-going"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("line 2 ok comp.new") && text.contains("line 4 FAIL invalid: not JSON"), "{text}"); + assert!(String::from_utf8_lossy(&out.stderr).contains("failed at line 3 (comp.info)")); + // The start line picks the project. + assert_eq!(bin().args(["replay", bad.to_str().unwrap(), "--demo"]).output().unwrap().status.code(), Some(2)); +} + +/// Text editors can prefix an exported JSON-lines repro with a UTF-8 BOM. +#[test] +fn replay_accepts_a_utf8_bom() { + let path = tmp("bom-repro.jsonl"); + std::fs::write(&path, "\u{feff}{\"command\":\"comp.new\"}\n").unwrap(); + let (code, report) = run_json(&["replay", path.to_str().unwrap()]); + assert_eq!(code, 0, "{report}"); + assert_eq!(report["ok"], true); +} diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 570726932..a9fc4665f 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -143,6 +143,8 @@ const WRITES: &[(&str, &[&str])] = &[ ("shortcuts.export", &["path"]), ("renderQueue.add", &["output", "path"]), ("file.exportTimeline", &["path"]), + ("journal.export", &["path"]), + ("help.exportRepro", &["path"]), // `path` / `output` may also be a file-name template, as for render.preRender. ("file.createProxy", &["path", "output"]), ]; @@ -280,6 +282,11 @@ impl Confinement { if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given { return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined"))); } + // The default repro destination is the settings store, not an automation write root. + // Require a single explicit path so missing, null or array parameters cannot bypass it. + if id == "help.exportRepro" && params.get("path").and_then(Value::as_str).is_none() { + return Err(Error::BadArgs("`help.exportRepro`: give `path` inside an automation write root while file access is confined".into())); + } let mut lookups = vec![]; // An output the agent did not name is derived from where the project sits: the Default // Output Folder preference when that is set, beside the project file otherwise — and the diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 92b62da2f..8a1839ede 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1865,3 +1865,28 @@ fn automation_roots_check_relinked_footage_again() { assert!(!(px[0] > 200 && px[3] > 200), "the link pointed outside afterwards was read: {px:?}"); let _ = std::fs::remove_dir_all(&base); } + +/// Exporting a repro must not write to the settings store outside the automation roots. +#[test] +fn confined_repro_export_requires_an_explicit_writable_path() { + use crate::confine::Confinement; + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + use std::sync::Arc; + + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(MemoryConfig::default()); + let session = Session { config: Some(store.clone()), ..Default::default() }; + let mut s = McpServer::new(Backend::headless(session)); + s.backend().confine(Confinement::new(&[], &[dir.path().to_path_buf()]).unwrap()); + for params in [json!({}), json!({"path": null}), json!({"path": []}), json!({"path": [dir.path().join("repro.jsonl")]})] { + let (content, error) = call(&mut s, "execute_command", json!({"command": "help.exportRepro", "params": params})); + assert!(error, "{content:?}"); + assert!(store.list("Repros").is_empty(), "implicit settings-store writes must be refused"); + } + let path = dir.path().join("repro.jsonl"); + call_json(&mut s, "execute_command", json!({"command": "help.exportRepro", "params": {"path": path}})); + assert!(path.is_file()); + let journal = call_json(&mut s, "execute_command", json!({"command": "journal.export"})); + assert!(journal["script"].is_string(), "in-memory exports need no write root"); + assert!(store.list("Repros").is_empty()); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 40603e8cd..09caa7cf4 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -31,6 +31,7 @@ pub(crate) mod frame_export; mod frontend; mod help; mod import_project; +mod journal; mod key_labels; mod key_transform; mod keys_more; @@ -202,6 +203,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(nav_more::specs()); v.extend(render_queue::specs()); v.extend(help::specs()); + v.extend(journal::specs()); v.extend(query::specs()); v.extend(batch::specs()); v.extend(layer_menu::specs()); diff --git a/crates/engine/src/cmd/journal.rs b/crates/engine/src/cmd/journal.rs new file mode 100644 index 000000000..533cc498c --- /dev/null +++ b/crates/engine/src/cmd/journal.rs @@ -0,0 +1,53 @@ +//! The command journal as a replay script (see [`crate::replay`]): `journal.export` for agents +//! and `help.exportRepro` for bug reports. Neither is journaled. + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, str_p}; +use crate::{Result, Session, replay}; + +/// `journal.export {path?}`: the script itself, or written to `path`. +fn export(s: &mut Session, p: &Value) -> Result { + if let Some(path) = str_p(p, "path") { + let (path, steps) = replay::save_repro(s, Some(path))?; + return Ok(json!({"path": path, "steps": steps})); + } + let script = replay::export(s, None); + Ok(json!({"steps": script.lines().count().saturating_sub(1), "script": script})) +} + +/// `help.exportRepro {path?}`: save the steps so far for a bug report (default: the settings +/// folder's `Repros`). +fn export_repro(s: &mut Session, p: &Value) -> Result { + let (path, steps) = replay::save_repro(s, str_p(p, "path"))?; + Ok(json!({"path": path, "steps": steps})) +} + +const fn spec(id: &'static str, label: &'static str, params: &'static str, run: super::Run) -> CommandSpec { + CommandSpec { id, label, menu: &[], shortcut: None, alt_shortcuts: &[], params, enabled: always, run, journal: false } +} + +pub fn specs() -> Vec { + #[allow(unused_mut)] + let mut v = vec![ + spec( + "journal.export", + "Export Command Journal", + "{path? (write the script there)} → {script, steps} or {path, steps}: the commands run since the project was last opened, as a replay script (docs/agents.md, Repro scripts)", + export, + ), + spec( + "help.exportRepro", + "Save Bug Repro...", + "{path? (default: Repros in the settings folder)} → {path, steps}: the steps since the project was opened, for a bug report (commands and parameters only)", + export_repro, + ), + ]; + // A command with a bug, for the crash-repro tests. + #[cfg(test)] + v.push(spec("test.panic", "Panic (test)", "{why?}", |_, p| { + let why = str_p(p, "why").unwrap_or("test").to_string(); + std::panic::panic_any(why) + })); + v +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 599aba1b7..f8edfbe08 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -21,6 +21,7 @@ pub mod menus; mod output_guard; pub mod preview; pub mod render_queue; +pub mod replay; pub mod scriptui; mod settings; mod system; diff --git a/crates/engine/src/replay.rs b/crates/engine/src/replay.rs new file mode 100644 index 000000000..5cca36918 --- /dev/null +++ b/crates/engine/src/replay.rs @@ -0,0 +1,748 @@ +//! Replay scripts: a bug or crash as a list of commands an agent (or a test) can run again. +//! +//! The session's command journal ([`Session::journal`]) becomes a JSON-lines script +//! (`journal.export`, `help.exportRepro`, and automatically when the last-resort guard catches a +//! panic, see [`after_command`]); `effectcraft-cli replay script.jsonl` runs one headless and +//! `crates/host/tests/it/repros.rs` runs every `tests/repros/*.jsonl` as a regression test. +//! +//! Format (one JSON object per line; blank lines and lines starting with `//` or `#` are +//! comments): +//! +//! ```text +//! {"start": "new" | "demo" | {"project": "path.ecproj"}, "allow_errors"?: bool} optional, first +//! {"command": "comp.new", "params": {...}, "expect_error"?: true | "substring"} +//! {"expect": {"query": "prop.get", "params": {...}, "pointer"?: "/value", "equals"?: ...}} +//! {"expect_no_error": true} +//! ``` +//! +//! A command must succeed unless it says `expect_error` (then it must fail, with that text in +//! the message when it is a string), or the start line says `allow_errors` (errors are reported +//! but tolerated; crash repros say so, since after the fix the crashing step may fail cleanly). +//! A panic always fails. `expect` runs a command (normally a query) and compares the value at +//! the JSON `pointer` of its result with `equals` (numbers within 1e-6); `expect_no_error` +//! checks that the previous command succeeded. Other keys (`version`, `app`, `note`, `crash`…) +//! are information for people. Relative `project` paths are relative to the script. +//! +//! Privacy: a repro holds command ids and their parameters only, never project or media file +//! contents. Parameters can include file paths, layer names and text typed into text layers. + +use std::cell::Cell; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde_json::{Map, Value, json}; + +use crate::{EngineError, Result, Session, guard}; + +/// The format version written into exported headers. +pub const VERSION: u64 = 1; +/// Largest script [`run`] accepts (bytes). +pub const MAX_SCRIPT_BYTES: usize = 64 << 20; +/// Most lines [`run`] reads. +pub const MAX_LINES: usize = 100_000; +/// Exported scripts stay below this size: the oldest steps are dropped (and counted). +const MAX_EXPORT_BYTES: usize = 32 << 20; +/// Folder of saved repros in the settings store (next to `Logs`). +pub const REPRO_DIR: &str = "Repros"; +/// Automatic crash repros kept (the oldest are removed). +const KEEP_CRASH_REPROS: usize = 20; +/// Commands that replace the whole project: an export starts at the last one. +const PROJECT_STARTS: &[&str] = &["file.newProject", "file.open", "file.openDemoProject"]; +/// Error and value texts in reports are cut to this many characters. +const MAX_TEXT: usize = 600; + +// ---------- nesting and crash capture (Session::execute) ---------- + +thread_local! { + /// Commands running on this thread: (all, journaled ones). + static DEPTH: Cell<(u32, u32)> = const { Cell::new((0, 0)) }; + /// A replay is running on this thread (its panics are reported, not saved as repros). + static REPLAYING: Cell = const { Cell::new(false) }; +} + +/// One running command (see [`Session::execute`]): commands it runs itself (a script's steps) +/// aren't journaled again, and only the outermost command saves a crash repro. +pub struct Nesting { + outer: (u32, u32), +} + +impl Nesting { + /// Record nesting so commands dispatched by a script are not exported twice. + pub fn enter(journaled: bool) -> Nesting { + let outer = DEPTH.with(Cell::get); + DEPTH.with(|d| d.set((outer.0.saturating_add(1), outer.1.saturating_add(u32::from(journaled))))); + Nesting { outer } + } + /// Run by a journaled command (which replays it). + pub fn inside_journaled(&self) -> bool { + self.outer.1 > 0 + } + /// Whether this command is responsible for capturing a nested panic. + pub fn outermost(&self) -> bool { + self.outer.0 == 0 + } +} + +impl Drop for Nesting { + fn drop(&mut self) { + DEPTH.with(|d| d.set(self.outer)); + } +} + +/// After the outermost command: when the guard caught a panic while it ran (`caught` is +/// [`guard::caught`] from before), save the journal plus this command as a crash repro in the +/// settings store and name it in the error. +pub fn after_command(s: &Session, id: &str, params: &Value, nesting: &Nesting, caught: u64, r: Result) -> Result { + if !nesting.outermost() || guard::caught() == caught || REPLAYING.with(Cell::get) { + return r; + } + let error = match &r { + Err(e) => e.to_string(), + Ok(_) => "a step it ran failed with an internal error".to_string(), + }; + let saved = save_crash_repro(s, id, params, &error); + match (r, saved) { + (Err(EngineError::Other(m)), Some(path)) => Err(EngineError::Other(format!("{m} (the steps that led here were saved to {path})"))), + (r, _) => r, + } +} + +/// Wall-clock seconds used only to label exported repros. +fn unix_secs() -> u64 { + web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +/// `Repros/--.jsonl`. +fn repro_name(prefix: &str, id: &str) -> String { + let id: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '.' { c } else { '_' }).take(60).collect(); + format!("{REPRO_DIR}/{prefix}-{:012}-{id}.jsonl", unix_secs()) +} + +/// Where a store entry is on disk (or its name in stores without a folder). +fn store_path(store: &dyn crate::config::ConfigStore, name: &str) -> String { + store.dir().map(|d| d.join(name).display().to_string()).unwrap_or_else(|| name.to_string()) +} + +/// Persist the failing command with its preceding journal and prune older crash repros. +fn save_crash_repro(s: &Session, id: &str, params: &Value, error: &str) -> Option { + let store = s.config.clone()?; + let mut text = export(s, Some(json!({"command": id, "error": cut(error)}))); + text.push_str(&command_line(id, params)); + text.push('\n'); + let name = repro_name("crash", id); + if let Err(e) = store.write(&name, &text) { + log::error!("cannot save the crash repro {name}: {e}"); + return None; + } + // Keep the newest few (names sort by time). + let mut old: Vec = store.list(REPRO_DIR).into_iter().filter(|n| n.starts_with("crash-")).collect(); + old.sort(); + let extra = old.len().saturating_sub(KEEP_CRASH_REPROS); + for n in old.into_iter().take(extra) { + let _ = store.remove(&format!("{REPRO_DIR}/{n}")); + } + let path = store_path(store.as_ref(), &name); + log::error!("crash repro saved to {path}"); + Some(path) +} + +/// Save the journal as a repro: at `path` (through the session's file services), else in the +/// settings store's `Repros` folder. Returns where it went and how many steps it holds. +pub fn save_repro(s: &Session, path: Option<&str>) -> Result<(String, usize)> { + let text = export(s, None); + let steps = text.lines().count().saturating_sub(1); + if let Some(path) = path { + s.services.write_file(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + return Ok((path.to_string(), steps)); + } + let store = s.config.clone().ok_or_else(|| EngineError::Other("there is no settings folder to save the repro in; pass `path`".into()))?; + let name = repro_name("repro", "session"); + store.write(&name, &text).map_err(|e| EngineError::Other(format!("cannot save {name}: {e}")))?; + Ok((store_path(store.as_ref(), &name), steps)) +} + +// ---------- export ---------- + +/// Serialize one command without including an empty parameter object. +fn command_line(id: &str, params: &Value) -> String { + let mut o = Map::new(); + o.insert("command".into(), json!(id)); + if !(params.is_null() || params.as_object().is_some_and(Map::is_empty)) { + o.insert("params".into(), params.clone()); + } + Value::Object(o).to_string() +} + +/// Saving writes the user's files and doesn't change the project: left out of repros. +fn skipped_in_export(id: &str) -> bool { + id.starts_with("file.save") || id.starts_with("file.increment") +} + +/// The journal as a replay script: a start line, then the steps since the project was last +/// replaced (`file.newProject` / `file.open` / `file.openDemoProject`; successful Revert records a resolved `file.open`). `crash` (the failing +/// command and its error) marks a crash repro, which tolerates errors on replay. +pub fn export(s: &Session, crash: Option) -> String { + let from = s.journal.iter().rposition(|(id, _)| PROJECT_STARTS.contains(&id.as_str())).unwrap_or(0); + let mut lines: Vec = + s.journal.get(from..).unwrap_or_default().iter().filter(|(id, _)| !skipped_in_export(id)).map(|(id, p)| command_line(id, p)).collect(); + let mut size: usize = lines.iter().map(|l| l.len().saturating_add(1)).sum(); + let mut dropped = 0usize; + while size > MAX_EXPORT_BYTES && !lines.is_empty() { + size = size.saturating_sub(lines.remove(0).len().saturating_add(1)); + dropped += 1; + } + let mut head = Map::new(); + head.insert("start".into(), json!("new")); + head.insert("version".into(), json!(VERSION)); + head.insert("app".into(), json!(format!("EffectCraft {}", env!("CARGO_PKG_VERSION")))); + head.insert("os".into(), json!(std::env::consts::OS)); + head.insert("exported".into(), json!(unix_secs())); + if let Some(c) = crash { + head.insert("allow_errors".into(), json!(true)); + head.insert("crash".into(), c); + } + if dropped > 0 { + head.insert("dropped".into(), json!(dropped)); + } + head.insert("note".into(), json!("commands and parameters only (no file contents); parameters can include file paths and text")); + let mut out = Value::Object(head).to_string(); + out.push('\n'); + for l in lines { + out.push_str(&l); + out.push('\n'); + } + out +} + +// ---------- parsing ---------- + +#[derive(Clone, Debug, PartialEq)] +enum Start { + New, + Demo, + Project(String), +} + +#[derive(Clone, Debug)] +enum Step { + Start(Start), + Command { + id: String, + params: Value, + expect_error: Option>, + }, + Expect { + query: String, + params: Value, + pointer: Option, + equals: Option, + }, + ExpectNoError, + /// A line that isn't a valid step (reported as a failure). + Invalid(String), +} + +fn params_of(v: Option<&Value>) -> std::result::Result { + match v { + None | Some(Value::Null) => Ok(json!({})), + Some(p @ Value::Object(_)) => Ok(p.clone()), + Some(_) => Err("`params` must be an object".into()), + } +} + +/// Decode the supported initial project states. +fn parse_start(v: &Value) -> std::result::Result { + match v { + Value::String(s) if s == "new" => Ok(Start::New), + Value::String(s) if s == "demo" => Ok(Start::Demo), + Value::Object(o) => match o.get("project").and_then(Value::as_str) { + Some(p) if !p.trim().is_empty() => Ok(Start::Project(p.to_string())), + _ => Err("`start` object needs a `project` path".into()), + }, + _ => Err("`start` must be \"new\", \"demo\" or {\"project\": path}".into()), + } +} + +/// One line → a step (`None`: a blank or comment line). +fn parse_line(text: &str, first: bool, allow_errors: &mut bool) -> Option { + let t = text.trim(); + if t.is_empty() || t.starts_with("//") || t.starts_with('#') { + return None; + } + let v: Value = match serde_json::from_str(t) { + Ok(v) => v, + Err(e) => return Some(Step::Invalid(format!("not JSON: {e}"))), + }; + let Some(o) = v.as_object() else { return Some(Step::Invalid("a line must be a JSON object".into())) }; + let step = if let Some(st) = o.get("start") { + if !first { + return Some(Step::Invalid("the `start` line must come first".into())); + } + *allow_errors = o.get("allow_errors").and_then(Value::as_bool).unwrap_or(false); + parse_start(st).map(Step::Start) + } else if let Some(c) = o.get("command") { + parse_command(c, o) + } else if let Some(e) = o.get("expect") { + parse_expect(e) + } else if o.get("expect_no_error").and_then(Value::as_bool) == Some(true) { + Ok(Step::ExpectNoError) + } else if o.len() == 1 && o.contains_key("comment") { + return None; + } else { + Err("expected a `start`, `command`, `expect` or `expect_no_error` line".to_string()) + }; + Some(step.unwrap_or_else(Step::Invalid)) +} + +/// Parse one command and its optional expected error without executing it. +fn parse_command(c: &Value, o: &Map) -> std::result::Result { + let id = c.as_str().filter(|s| !s.is_empty()).ok_or("`command` must be a command id")?; + let expect_error = match o.get("expect_error") { + None | Some(Value::Null | Value::Bool(false)) => None, + Some(Value::Bool(true)) => Some(None), + Some(Value::String(s)) => Some(Some(s.clone())), + Some(_) => return Err("`expect_error` must be true or a text the error contains".into()), + }; + Ok(Step::Command { id: id.to_string(), params: params_of(o.get("params"))?, expect_error }) +} + +/// Parse a query assertion, retaining the distinction between no expected value and JSON null. +fn parse_expect(e: &Value) -> std::result::Result { + let query = e.get("query").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or("`expect` needs a `query` command id")?; + let pointer = e.get("pointer").map(|p| p.as_str().map(str::to_string).ok_or("`pointer` must be a JSON pointer such as /value")).transpose()?; + Ok(Step::Expect { query: query.to_string(), params: params_of(e.get("params"))?, pointer, equals: e.get("equals").cloned() }) +} + +// ---------- running ---------- + +/// How to run a script. +#[derive(Clone, Debug, Default)] +pub struct Options { + /// Keep running after a failed step (default: stop at the first failure). + pub keep_going: bool, + /// Folder relative `start.project` paths are resolved against (the script's). + pub base_dir: Option, +} + +/// What happened to one line. +#[derive(Clone, Debug, Serialize)] +pub struct StepReport { + /// 1-based line number (0: the implicit start). + pub line: usize, + /// `start`, `command`, `expect`, `expect_no_error` or `invalid`. + pub kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub panicked: bool, +} + +/// The outcome of [`run`]. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Report { + pub ok: bool, + /// A step panicked (caught by the last-resort guard): always a failure. + pub panicked: bool, + pub steps: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_failure: Option, + /// Steps not run after the first failure. + pub skipped: usize, +} + +/// Cut a message to [`MAX_TEXT`] characters. +fn cut(s: &str) -> String { + match s.char_indices().nth(MAX_TEXT) { + Some((i, _)) => format!("{}…", s.get(..i).unwrap_or(s)), + None => s.to_string(), + } +} + +/// Run one command the way the app does, noting whether it panicked. +fn exec(s: &mut Session, id: &str, params: Value) -> (Result, bool) { + let before = guard::caught(); + let r = guard::guarded(&format!("replay of `{id}`"), || s.execute(id, params)); + s.events.clear(); + (r, guard::caught() != before) +} + +/// Numbers within 1e-6 (relative above 1), everything else exactly. +fn approx_eq(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0), + _ => x == y, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| approx_eq(a, b)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, a)| y.get(k).is_some_and(|b| approx_eq(a, b))), + _ => a == b, + } +} + +/// Marks this thread as replaying while alive. +struct Replaying(bool); + +impl Replaying { + /// Suppress automatic crash exports for the duration of a replay. + fn enter() -> Replaying { + Replaying(REPLAYING.with(|r| r.replace(true))) + } +} + +impl Drop for Replaying { + fn drop(&mut self) { + REPLAYING.with(|r| r.set(self.0)); + } +} + +/// Create or open the initial project and retain whether its command panicked. +fn start_step(s: &mut Session, start: &Start, base: Option<&Path>) -> (String, Result, bool) { + match start { + Start::New => { + let (r, p) = exec(s, "file.newProject", json!({})); + ("file.newProject".into(), r, p) + } + Start::Demo => { + let (r, p) = exec(s, "file.openDemoProject", json!({})); + ("file.openDemoProject".into(), r, p) + } + Start::Project(path) => { + let full = match base { + Some(b) if Path::new(path).is_relative() => b.join(path).display().to_string(), + _ => path.clone(), + }; + let (r, p) = exec(s, "file.open", json!({"path": full})); + ("file.open".into(), r, p) + } + } +} + +/// Compare a query result and produce a bounded explanation when it fails. +fn expectation_error(r: Result, panicked: bool, pointer: Option<&str>, equals: Option<&Value>) -> Option { + let value = match r { + Ok(v) => v, + Err(e) => return Some(cut(&format!("{}{e}", if panicked { "panicked: " } else { "" }))), + }; + let got = match pointer { + Some(p) => value.pointer(p), + None => Some(&value), + }; + match (got, equals) { + (None, _) => Some(cut(&format!("no value at {} in {value}", pointer.unwrap_or("")))), + (Some(got), Some(want)) if !approx_eq(got, want) => Some(cut(&format!("expected {want}, got {got}"))), + _ => None, + } +} + +/// Execute one parsed line and remember whether the last command failed for `expect_no_error`. +fn run_step(s: &mut Session, line: usize, step: Step, opts: &Options, allow_errors: bool, last_command_failed: &mut Option) -> StepReport { + let mut sr = StepReport { line, kind: "command", command: None, ok: true, error: None, panicked: false }; + match step { + Step::Invalid(m) => { + sr.kind = "invalid"; + sr.ok = false; + sr.error = Some(m); + } + Step::Start(start) => { + sr.kind = "start"; + let (id, r, panicked) = start_step(s, &start, opts.base_dir.as_deref()); + sr.command = Some(id); + sr.panicked = panicked; + sr.ok = !panicked; + if let Err(e) = r { + sr.ok = false; + sr.error = Some(cut(&e.to_string())); + } + } + Step::Command { id, params, expect_error } => { + let (r, panicked) = exec(s, &id, params); + sr.command = Some(id); + sr.panicked = panicked; + *last_command_failed = Some(r.is_err()); + let err = r.err().map(|e| e.to_string()); + let (ok, error) = match (err, expect_error) { + (e, _) if panicked => (false, Some(format!("panicked: {}", e.unwrap_or_else(|| "a step failed with an internal error".into())))), + (None, None) => (true, None), + (None, Some(_)) => (false, Some("expected an error, but the command succeeded".into())), + (Some(e), Some(Some(want))) if !e.contains(want.as_str()) => (false, Some(format!("expected an error containing {want:?}, got: {e}"))), + (Some(_), Some(_)) => (true, None), + (Some(e), None) => (allow_errors, Some(e)), + }; + sr.ok = ok; + sr.error = error.as_deref().map(cut); + } + Step::Expect { query, params, pointer, equals } => { + sr.kind = "expect"; + let (r, panicked) = exec(s, &query, params); + sr.command = Some(query); + sr.panicked = panicked; + sr.error = expectation_error(r, panicked, pointer.as_deref(), equals.as_ref()); + sr.ok = sr.error.is_none() && !panicked; + } + Step::ExpectNoError => { + sr.kind = "expect_no_error"; + match last_command_failed { + Some(false) => {} + Some(true) => { + sr.ok = false; + sr.error = Some("the previous command failed".into()); + } + None => { + sr.ok = false; + sr.error = Some("no command before this line".into()); + } + } + } + } + sr +} + +/// Run a replay script against `s`. Never panics: malformed lines are failed steps. +pub fn run(s: &mut Session, script: &str, opts: &Options) -> Report { + let _replaying = Replaying::enter(); + let mut report = Report { ok: true, ..Default::default() }; + let fail = |report: &mut Report, sr: StepReport| { + report.ok = false; + report.panicked |= sr.panicked; + if report.first_failure.is_none() { + report.first_failure = Some(sr.clone()); + } + report.steps.push(sr); + }; + if script.len() > MAX_SCRIPT_BYTES { + let msg = format!("the script is larger than {} MB", MAX_SCRIPT_BYTES >> 20); + fail(&mut report, StepReport { line: 0, kind: "invalid", command: None, ok: false, error: Some(msg), panicked: false }); + return report; + } + // Hand-edited UTF-8 scripts may start with a BOM, especially on Windows. + let script = script.strip_prefix('\u{feff}').unwrap_or(script); + let mut allow_errors = false; + let mut steps: Vec<(usize, Step)> = vec![]; + for (i, line) in script.lines().enumerate().take(MAX_LINES) { + if let Some(step) = parse_line(line, steps.is_empty(), &mut allow_errors) { + steps.push((i + 1, step)); + } + } + if script.lines().nth(MAX_LINES).is_some() { + steps.push((MAX_LINES + 1, Step::Invalid(format!("the script has more than {MAX_LINES} lines; the rest was not read")))); + } + if !matches!(steps.first(), Some((_, Step::Start(_)) | (_, Step::Invalid(_)))) { + steps.insert(0, (0, Step::Start(Start::New))); + } + let mut last_command_failed: Option = None; + let mut stopped = false; + for (line, step) in steps { + if stopped { + report.skipped += 1; + continue; + } + let sr = run_step(s, line, step, opts, allow_errors, &mut last_command_failed); + if sr.ok { + report.steps.push(sr); + } else { + fail(&mut report, sr); + stopped = !opts.keep_going; + } + } + report +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use super::{Options, run}; + use crate::Session; + use crate::config::{ConfigStore, MemoryConfig}; + + fn replay(script: &str) -> super::Report { + run(&mut Session::new(), script, &Options::default()) + } + + #[test] + fn steps_expectations_and_failures_are_reported() { + let r = replay( + r#"{"start": "new"} +// a comment +{"command": "comp.new", "params": {"name": "A", "width": 320, "height": 180, "frameRate": 30, "duration": 2}} +{"expect_no_error": true} +{"command": "comp.new", "params": {"frameRate": 0}, "expect_error": "frame"} +{"expect": {"query": "comp.info", "pointer": "/width", "equals": 320}} +"#, + ); + assert!(r.ok, "{r:?}"); + assert_eq!(r.steps.len(), 5); + let r = replay( + "{\"command\": \"comp.new\"}\n{\"expect\": {\"query\": \"comp.info\", \"pointer\": \"/width\", \"equals\": 1}}\n{\"command\": \"comp.new\"}", + ); + assert!(!r.ok && !r.panicked && r.skipped == 1, "{r:?}"); + assert_eq!(r.first_failure.as_ref().map(|f| f.line), Some(2)); + // An unexpected success, an unknown command. + assert!(!replay(r#"{"command": "comp.new", "expect_error": true}"#).ok); + assert!(!replay(r#"{"command": "no.such.command"}"#).ok); + // Tolerated errors. + let r = replay("{\"start\": \"new\", \"allow_errors\": true}\n{\"command\": \"no.such.command\"}\n{\"expect_no_error\": true}"); + assert!(!r.ok && r.first_failure.as_ref().map(|f| f.kind) == Some("expect_no_error"), "{r:?}"); + } + + /// A UTF-8 BOM belongs to the text file, not the first JSON step. + #[test] + fn utf8_bom_preserves_explicit_and_implicit_start_lines() { + for body in ["{\"start\":\"new\"}\n{\"command\":\"comp.new\"}", "{\"command\":\"comp.new\"}"] { + let script = format!("\u{feff}{body}"); + let report = replay(&script); + assert!(report.ok, "{report:?}"); + assert_eq!(report.steps.last().unwrap().line, body.lines().count()); + } + } + + #[test] + fn malformed_lines_are_failures_not_crashes() { + for bad in [ + "not json", + "[1, 2]", + "{\"command\": 5}", + "{\"command\": \"comp.new\", \"params\": [1]}", + "{\"command\": \"comp.new\", \"expect_error\": 3}", + "{\"expect\": {}}", + "{\"expect\": {\"query\": \"comp.info\", \"pointer\": 4}}", + "{\"expect\": {\"query\": \"comp.info\", \"pointer\": \"/nope\"}}", + "{\"start\": 7}", + "{\"start\": {\"project\": \"\"}}", + "{\"command\": \"comp.new\"}\n{\"start\": \"new\"}", + "{\"what\": 1}", + "{\"expect_no_error\": true}", + "{\"start\": {\"project\": \"/no/such/file.ecproj\"}}", + ] { + let r = replay(bad); + assert!(!r.ok && !r.panicked, "{bad}: {r:?}"); + } + let r = replay(&format!("{{\"command\": \"{}\"}}", "\u{1F600}".repeat(1000))); + assert!(!r.ok && r.first_failure.and_then(|f| f.error).is_some_and(|e| e.ends_with('…'))); + assert!(replay("").ok, "an empty script is a new project"); + } + + #[test] + fn the_journal_exports_and_replays() { + let mut s = Session::new(); + s.execute("comp.new", json!({"name": "Old"})).unwrap(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000", "name": "Red"})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#00ff00", "name": "Green"})).unwrap(); + // A query isn't journaled; neither is saving. + s.execute("comp.info", json!({})).unwrap(); + let text = super::export(&s, None); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 4, "{text}"); + assert!(lines[0].contains("\"start\":\"new\"") && lines[1].contains("file.openDemoProject"), "starts at the last project open: {text}"); + let mut again = Session::new(); + let r = run(&mut again, &text, &Options::default()); + assert!(r.ok, "{r:?}"); + let layers = |s: &Session| s.active_comp().map(|c| c.layers.len()); + assert_eq!(layers(&again), layers(&s)); + // journal.export returns it; with `path` it writes the file. + let v = s.execute("journal.export", json!({})).unwrap(); + assert_eq!(v["steps"], 3); + assert_eq!(v["script"].as_str().unwrap().lines().skip(1).collect::>(), lines[1..]); + let dir = std::env::temp_dir().join(format!("ec-replay-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("j.jsonl"); + s.execute("journal.export", json!({"path": path.to_str().unwrap()})).unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains("layer.newSolid")); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(s.journal.iter().filter(|(id, _)| id.starts_with("journal.")).count(), 0); + } + + #[test] + fn help_export_repro_saves_into_the_settings_store() { + let mut s = Session::new(); + assert!(s.execute("help.exportRepro", json!({})).is_err(), "no settings folder and no path"); + let store = Arc::new(MemoryConfig::default()); + s.config = Some(store.clone()); + s.execute("comp.new", json!({})).unwrap(); + let v = s.execute("help.exportRepro", json!({})).unwrap(); + assert_eq!(v["steps"], 1); + let saved = store.list(super::REPRO_DIR); + assert_eq!(saved.len(), 1, "{saved:?}"); + assert!(store.read(v["path"].as_str().unwrap()).unwrap().contains("comp.new")); + } + + #[test] + fn a_panicking_command_saves_a_crash_repro_and_replays_as_a_failure() { + let mut s = Session::new(); + let store = Arc::new(MemoryConfig::default()); + s.config = Some(store.clone()); + s.execute("comp.new", json!({"name": "C"})).unwrap(); + // `test.panic` stands in for a command with a bug (see `commands::journal`). + let e = s.execute("test.panic", json!({"why": "boom"})).unwrap_err().to_string(); + assert!(e.contains("boom") && e.contains("Repros/crash-"), "{e}"); + let names = store.list(super::REPRO_DIR); + assert_eq!(names.len(), 1); + let text = store.read(&format!("{}/{}", super::REPRO_DIR, names[0])).unwrap(); + assert!(text.lines().next().unwrap().contains("\"crash\""), "{text}"); + assert!(text.lines().last().unwrap().contains("test.panic"), "{text}"); + // The replay fails on the panic (and doesn't save another repro). + let r = run(&mut Session::new(), &text, &Options::default()); + assert!(!r.ok && r.panicked, "{r:?}"); + assert_eq!(r.first_failure.as_ref().and_then(|f| f.command.as_deref()), Some("test.panic")); + // A failing replay inside a session with a store saves nothing more. + let mut t = Session::new(); + t.config = Some(store.clone()); + assert!(!run(&mut t, &text, &Options::default()).ok); + assert_eq!(store.list(super::REPRO_DIR).len(), 1); + // Only the newest crash repros are kept. + for i in 0..30 { + store.write(&format!("{}/crash-{i:012}-x.jsonl", super::REPRO_DIR), "{}").unwrap(); + } + let _ = s.execute("test.panic", json!({})); + assert_eq!(store.list(super::REPRO_DIR).len(), super::KEEP_CRASH_REPROS); + } + + #[test] + fn commands_run_by_a_journaled_command_are_not_journaled_twice() { + let mut s = Session::new(); + s.execute("comp.new", json!({})).unwrap(); + // engine.batch isn't journaled: its steps are. + s.execute("engine.batch", json!({"steps": [{"command": "layer.newNull"}, {"command": "layer.newNull"}]})).unwrap(); + assert_eq!(s.journal.iter().filter(|(id, _)| id == "layer.newNull").count(), 2); + // A journaled command's own steps replay with it. + s.execute("file.runScript", json!({"steps": [{"command": "layer.newNull"}]})).unwrap(); + let ids: Vec<&str> = s.journal.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["comp.new", "layer.newNull", "layer.newNull", "file.runScript"]); + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod revert_tests { + use super::{Options, export, run}; + use crate::Session; + use serde_json::json; + + /// Saving is omitted, so a successful Revert must keep the file it read as an open boundary. + #[test] + fn save_as_then_revert_exports_a_replayable_open() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("saved.ecproj"); + let mut s = Session::new(); + s.execute("comp.new", json!({"name": "Saved"})).unwrap(); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + s.execute("layer.newNull", json!({})).unwrap(); + s.execute("file.revert", json!({})).unwrap(); + s.execute("layer.newNull", json!({"name": "After Revert"})).unwrap(); + let script = export(&s, None); + assert!(!script.contains("file.save") && !script.contains("file.revert"), "{script}"); + assert!(script.lines().nth(1).unwrap().contains("file.open"), "{script}"); + let saved = std::fs::read(&path).unwrap(); + let mut again = Session::new(); + let report = run(&mut again, &script, &Options::default()); + assert!(report.ok, "{report:?}"); + assert_eq!(again.path, s.path); + assert_eq!(again.active_comp().unwrap().layers.len(), s.active_comp().unwrap().layers.len()); + assert_eq!(saved, std::fs::read(path).unwrap(), "replay must not rewrite the saved project"); + } +} diff --git a/crates/engine/src/session/dispatch.rs b/crates/engine/src/session/dispatch.rs index aa83ac09f..a3f3f05d5 100644 --- a/crates/engine/src/session/dispatch.rs +++ b/crates/engine/src/session/dispatch.rs @@ -22,10 +22,20 @@ impl Session { } } // Last-resort guard: a panicking command reports an error instead of crashing the app. - let r = guard::guarded(&format!("command `{id}`"), || (spec.run)(self, ¶ms))?; + let nesting = crate::replay::Nesting::enter(spec.journal); + let caught = guard::caught(); + let r = guard::guarded(&format!("command `{id}`"), || (spec.run)(self, ¶ms)); + let r = crate::replay::after_command(self, id, ¶ms, &nesting, caught, r)?; self.learn_observe(id, ¶ms); - if spec.journal { - self.journal.push((id.to_string(), params)); + if spec.journal && !nesting.inside_journaled() { + // Revert and Open Recent resolve a file the parameters may not name. Store + // the successful read as an explicit open boundary while its resolved path is known. + let entry = if matches!(id, "file.revert" | "file.openRecent") { + self.path.as_ref().map(|path| ("file.open".to_string(), serde_json::json!({"path": path}))) + } else { + None + }; + self.journal.push(entry.unwrap_or_else(|| (id.to_string(), params))); if self.journal.len() > 10_000 { self.journal.drain(..1000); } diff --git a/crates/engine/src/system/guard.rs b/crates/engine/src/system/guard.rs index 699e6474d..ec32e2545 100644 --- a/crates/engine/src/system/guard.rs +++ b/crates/engine/src/system/guard.rs @@ -4,14 +4,28 @@ //! panics leaves the project as it was. This is a safety net, not a substitute for returning //! errors (AGENTS.md, "Never crash"). +use std::cell::Cell; + use crate::{EngineError, Result}; +thread_local! { + /// Panics [`guarded`] caught on this thread (see [`caught`]). + static CAUGHT: Cell = const { Cell::new(0) }; +} + +/// How many panics [`guarded`] has caught on this thread so far: compare two readings to learn +/// whether a call panicked (crash repros, `effectcraft-cli replay`). +pub fn caught() -> u64 { + CAUGHT.with(Cell::get) +} + /// Run `f`; a panic inside it becomes an error naming `what`. The panic itself is logged by /// the panic hook ([`crate::logging::install_panic_hook`]). pub fn guarded(what: &str, f: impl FnOnce() -> Result) -> Result { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { Ok(r) => r, Err(payload) => { + CAUGHT.with(|c| c.set(c.get().saturating_add(1))); let msg = payload .downcast_ref::<&str>() .map(|s| s.to_string()) diff --git a/crates/host/tests/it/main.rs b/crates/host/tests/it/main.rs index 168ec127a..ecc1abb0f 100644 --- a/crates/host/tests/it/main.rs +++ b/crates/host/tests/it/main.rs @@ -15,3 +15,5 @@ mod timeline_interop; mod vector_import; mod wav_markers; mod webm_metadata; + +mod repros; diff --git a/crates/host/tests/it/repros.rs b/crates/host/tests/it/repros.rs new file mode 100644 index 000000000..1b343603c --- /dev/null +++ b/crates/host/tests/it/repros.rs @@ -0,0 +1,31 @@ +//! Regression tests as data: every `tests/repros/*.jsonl` (repository root) is a replay script +//! (`effectcraft_engine::replay`, docs/agents.md "Repro scripts") run against a fully wired +//! headless session, like `effectcraft-cli replay`. A crash fix's regression test is a `.jsonl` +//! dropped there: it must replay without a panic and meet its expectations. + +use std::path::{Path, PathBuf}; + +use effectcraft_engine::replay; + +fn repros_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/repros") +} + +#[test] +fn every_repro_replays_cleanly() { + let mut files: Vec = + std::fs::read_dir(repros_dir()).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|x| x == "jsonl")).collect(); + files.sort(); + assert!(!files.is_empty(), "no repros in {}", repros_dir().display()); + let mut failures = vec![]; + for f in &files { + let script = std::fs::read_to_string(f).unwrap(); + let mut s = effectcraft_host::session(); + let opts = replay::Options { keep_going: false, base_dir: f.parent().map(Path::to_path_buf) }; + let r = replay::run(&mut s, &script, &opts); + if !r.ok { + failures.push(format!("{}: {}", f.display(), serde_json::to_string(&r.first_failure).unwrap())); + } + } + assert!(failures.is_empty(), "{} of {} repros failed:\n{}", failures.len(), files.len(), failures.join("\n")); +} diff --git a/docs/agents.md b/docs/agents.md index ae9b1a6f6..06f9bedae 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -865,6 +865,75 @@ not read or written. `` is an id or name, or `-` for the active comp. `` is JSON (`50`, `[960,540]`, `"#ff0000"`) or a bare string. +## Repro scripts + +Every journaled command the app runs (menus, panels, shortcuts, agents, scripts) goes into the session's +command journal. A **repro script** is that journal as JSON lines, so a bug or crash becomes +something an agent can replay and a regression test becomes a data file. + +```text +// comments: lines starting with // or #; blank lines are skipped +{"start": "new" | "demo" | {"project": "rel/or/abs.ecproj"}, "allow_errors": false} optional, first line +{"command": "comp.new", "params": {"name": "Main", "frameRate": 30}} +{"command": "comp.settings", "params": {"frameRate": 0}, "expect_error": "frameRate"} +{"expect": {"query": "comp.info", "pointer": "/frameRate", "equals": 30}} +{"expect_no_error": true} +``` + +- `start`: the project to begin with (default `new`). A relative `project` path is relative to the + script. Other header keys (`version`, `app`, `os`, `crash`, `note`) are for people. +- `command`: run like the app runs it. It must succeed, unless it has `expect_error` (`true`, or a + text the error must contain) or the header says `allow_errors` (errors are reported but + tolerated). A panic always fails the replay. +- `expect`: run a command, normally a query (`prop.get`, `comp.info`, `layer.tree`, `project.summary`), + and compare the value at the JSON `pointer` of its result with `equals` (numbers within 1e-6). + Without `equals` it only checks that the query succeeds. +- `expect_no_error`: the previous command succeeded. + +Where scripts come from: + +- `journal.export {path?}` returns `{script, steps}` (or writes `path`): the commands since the + project was last replaced (`file.newProject`, `file.open`, `file.openDemoProject`). Queries and + saves are not in it; commands a script or `file.runScript` ran are covered by that one step. + A successful Revert starts a new replay boundary at the saved file it reopened. +- `help.exportRepro {path?}` saves the same for a bug report, by default to `Repros/` in the + settings folder (macOS `~/Library/Application Support/EffectCraft/Repros`, next to `Logs/`). + Confined automation must give an explicit path inside a write root; `journal.export {}` + remains available because it only returns the script in memory. +- **Crash repros**: when the last-resort guard catches a panic in a command, the app writes + `Repros/crash--.jsonl` there automatically (the newest 20 are kept): the + journal, then the command that panicked, with `"allow_errors": true` and the error in the header. + The error the user or agent sees names the file. Headless sessions without a settings folder + (the CLI, tests) don't write one. + +Privacy: a repro holds command ids and parameters only, never project, image or media file +contents. Parameters can include file paths, layer and item names and text typed into text +layers, so read a repro before sharing it. + +Replay one headless (a fully wired session, like the CLI's other subcommands): + +```sh +effectcraft-cli replay crash.jsonl # per-step ok/FAIL lines, then the first failure +effectcraft-cli replay crash.jsonl --json # {ok, panicked, steps, first_failure, skipped} +effectcraft-cli replay crash.jsonl --keep-going +``` + +Replaying a repro runs its commands with your privileges and file access, including commands +that write files or run scripts. Read repros from untrusted sources before running them, or use +a disposable environment. + +It exits with status 1 when a step fails, an expectation is not met or a command panics, and +stops at the first failure unless `--keep-going`. Steps that work on the user's files (`file.open` +of their project, imports) need those files; replace them with a `start` project or generated +content when you turn a report into a test. A repro only sees commands: UI state changed +without one (a hover, a half-finished drag) is not in it. + +**Regression tests are data files.** A crash fix's regression test is the repro, trimmed to the +steps that matter, with an `expect_error` or `expect` line where the fix changed the outcome, +dropped into [`tests/repros/`](../tests/repros). `crates/host/tests/it/repros.rs` replays every +`*.jsonl` there and fails on any panic or unmet expectation +(`cargo nextest run -p effectcraft-host --test it -E 'test(/^repros::/)'`). + ## Seeing the UI To work on the UI, start the app with `cargo run -p effectcraft -- --control 9877` (add `--demo` to diff --git a/docs/testing.md b/docs/testing.md index 4a7610d16..4c5bfddcb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3,6 +3,7 @@ ```sh cargo test --workspace # everything cargo xtask ci # fmt, clippy -D warnings, tests, pdf-deps, lock-patches, layers, assets, wasm +cargo xtask ci --affected # the same gates, scoped to crates your change can affect ``` The After Effects test projects of `crates/aep` are not in the repository; without them the @@ -32,6 +33,47 @@ Each crate with several integration-test files has one test binary, `tests/it/ma modules are the files in `tests/it/`. Run one file with `cargo test -p --test it ::` or `cargo nextest run -p -E 'binary(it) and test(/^::/)'`. +## Affected-only CI + +Use `cargo xtask ci --affected` to run the gates for the crates a change can affect. The full +`cargo xtask ci` remains the required check before a release and when affected mode falls back +because a workspace-wide input changed. + +```sh +cargo xtask ci --affected # base: the merge-base of HEAD with main +cargo xtask ci --affected --base HEAD~3 # any git revision +cargo xtask ci --affected --dry-run # print the plan only +``` + +It collects files that differ from the base (committed, staged, unstaged and untracked), preserving +Unicode and whitespace in their names. Each file maps to the workspace package whose directory +holds it and every package whose sources point at it with a `../` literal, such as +`include_bytes!("../../../assets/fonts/Inter-Regular.ttf")` or +`concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/preferences.md")`. Deleted and renamed inputs +still select their readers, including when the input's entire directory no longer exists. + +The affected set is those packages plus everything that depends on them (transitively through +normal and build dependencies) and every package that uses one of them as a dev-dependency. +Clippy and tests use the same dev profile as full CI. Both modes use the same test runner: +nextest plus doctests when installed, otherwise cargo test plus doctests. GPU tests run in one +libtest process, and the translation report reruns with visible output when ui-egui is affected. +The audio-backend probe runs before building affected crates. + +Formatting, PDF dependency policy, lock-patch and layer checks always run. Assets are checked +when an asset, an `.attribution` sidecar or `ATTRIBUTION.md` changes. The wasm check selects +affected L0–L4 and web crates. Every step runs even when an earlier step fails; the final result +names failed steps and includes timings. + +Changes to the root `Cargo.toml`, `Cargo.lock`, `.cargo/`, `xtask/`, `rust-toolchain(.toml)`, +`clippy.toml` or `.config/nextest.toml` can change every crate's build or test policy, so they run +the full CI instead and say why. Changes no crate reads (most of `docs/`, `README.md`, `.github/`, +`packaging/`) only get the cheap checks. + +Limits: a crate that reads a repo file at run time through a path that is not a `../` literal in its +sources is not detected, and building a subset of packages can unify features differently from the +whole workspace. Non-UTF-8 Git paths stop the command rather than silently omitting checks. +Run the full `cargo xtask ci` before a release. + ## What the tests cover - **Engine commands** (`crates/engine/src/tests*.rs`): every command family, including undo and diff --git a/tests/repros/comp-zero-frame-rate.jsonl b/tests/repros/comp-zero-frame-rate.jsonl new file mode 100644 index 000000000..5ce9dd873 --- /dev/null +++ b/tests/repros/comp-zero-frame-rate.jsonl @@ -0,0 +1,13 @@ +// comp.new / comp.settings took a frameRate of 0 (or below 0.0005) as a 0/1 rate: the next +// frame-duration computation divided by zero and killed the process with the unsaved project +// (fixed in 780419c, "Compositions: reject zero and negative frame rates instead of panicking"). +{"start": "new"} +{"command": "comp.new", "params": {"name": "Zero", "frameRate": 0}, "expect_error": "frameRate"} +{"command": "comp.new", "params": {"name": "Tiny", "frameRate": 0.0001}, "expect_error": "frameRate"} +{"command": "comp.new", "params": {"name": "Negative", "frameRate": -24}, "expect_error": "frameRate"} +{"command": "comp.new", "params": {"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 1}} +{"command": "comp.settings", "params": {"frameRate": 0}, "expect_error": "frameRate"} +{"expect": {"query": "comp.info", "pointer": "/frameRate", "equals": 30}} +{"command": "layer.newSolid", "params": {"color": "#ff0000"}} +{"command": "time.set", "params": {"time": 0.5}} +{"expect_no_error": true} diff --git a/tests/repros/layer-settings-empty-selection.jsonl b/tests/repros/layer-settings-empty-selection.jsonl new file mode 100644 index 000000000..71a0216ad --- /dev/null +++ b/tests/repros/layer-settings-empty-selection.jsonl @@ -0,0 +1,8 @@ +// Layer Settings' enablement read the first selected layer with `selected_layers[0]`: with layers +// in the comp but none selected it indexed an empty list and panicked (fixed in b21e04b, M4.12). +{"start": "demo"} +{"command": "edit.deselectAll"} +{"command": "layer.settings", "expect_error": "select a layer"} +{"command": "layer.newSolid", "params": {"color": "#3366ff", "name": "Blue"}} +{"command": "layer.settings", "params": {"name": "Renamed"}} +{"expect_no_error": true} diff --git a/tests/repros/new-comp-from-selection-hostile-overlap.jsonl b/tests/repros/new-comp-from-selection-hostile-overlap.jsonl new file mode 100644 index 000000000..07e96afc6 --- /dev/null +++ b/tests/repros/new-comp-from-selection-hostile-overlap.jsonl @@ -0,0 +1,17 @@ +// File > New Comp from Selection with Sequence Layers: a huge `overlapDuration` times the layer +// count overflowed the duration arithmetic (a panic with overflow checks, a wrapped, wrong comp +// length without them), and `dimensionsFrom` past the selection indexed out of bounds +// (fixed in b21e04b, M4.12). +{"start": "new"} +{"command": "comp.new", "params": {"name": "Holder", "width": 64, "height": 64, "frameRate": 30, "duration": 2}} +{"command": "layer.newSolid", "params": {"color": "#ff0000", "name": "a"}} +{"command": "layer.newSolid", "params": {"color": "#00ff00", "name": "b"}} +{"command": "layer.newSolid", "params": {"color": "#0000ff", "name": "c"}} +{"command": "project.select", "params": {"items": ["a", "b", "c"]}} +{"command": "file.newCompFromSelection", "params": {"single": true, "sequence": true, "overlap": true, "overlapDuration": 1e30, "duration": 2}} +{"expect": {"query": "comp.info", "pointer": "/width", "equals": 64}} +{"command": "project.select", "params": {"items": ["a", "b", "c"]}} +{"command": "file.newCompFromSelection", "params": {"single": true, "sequence": true, "overlap": true, "overlapDuration": -5, "duration": 2}} +{"expect": {"query": "comp.info", "pointer": "/duration", "equals": 4}} +{"command": "project.select", "params": {"items": ["a", "b"]}} +{"command": "file.newCompFromSelection", "params": {"single": true, "dimensionsFrom": 99}, "expect_error": "dimensionsFrom"} diff --git a/xtask/src/affected.rs b/xtask/src/affected.rs new file mode 100644 index 000000000..2ce407d27 --- /dev/null +++ b/xtask/src/affected.rs @@ -0,0 +1,764 @@ +//! `cargo xtask ci --affected [--base REV] [--dry-run]`: the CI gates, run only for the crates a +//! change can affect (docs/testing.md). +//! +//! 1. Changed files: everything that differs from the base (default: the merge-base with `main`), +//! committed, staged, unstaged or untracked. +//! 2. Files map to the workspace package whose directory holds them, and to every package whose +//! sources point at them with a `../` path (`include_bytes!("../../../assets/fonts/…")`, +//! `env!("CARGO_MANIFEST_DIR")` + `"/../../docs/effects.md"`, …). +//! 3. Affected = those packages, every package that depends on them (transitively, through +//! normal and build dependencies) and every package that has one of them as a dev-dependency. +//! 4. Workspace-wide inputs (root `Cargo.toml`, `Cargo.lock`, `.cargo/`, `xtask/`, the toolchain +//! file, `clippy.toml`, `.config/nextest.toml`) fall back to the full `cargo xtask ci`. + +use std::collections::BTreeSet; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +/// A workspace package, as far as change mapping cares. +#[derive(Debug, Clone, Default)] +pub struct Pkg { + pub name: String, + /// Package directory relative to the workspace root, `/`-separated, no trailing slash. + pub dir: String, + /// Workspace (path) dependencies: (package name, is a dev-dependency). + pub deps: Vec<(String, bool)>, + /// Has a library target (`--lib` is valid even when doctests are disabled). + pub has_lib: bool, + /// Has a library target with doctests (`cargo test --doc` runs for it). + pub has_doctests: bool, + /// Repo paths outside `dir` that the sources read (files or directories). + pub inputs: Vec, +} + +/// Changes to these force the full CI: they can change every crate's build or test policy. +pub fn full_ci_reason(path: &str) -> Option<&'static str> { + match path { + "Cargo.toml" => Some("workspace Cargo.toml changed"), + "Cargo.lock" => Some("Cargo.lock changed"), + "rust-toolchain" | "rust-toolchain.toml" => Some("toolchain changed"), + "clippy.toml" => Some("clippy.toml changed"), + // Test selection and required runner versions apply to the whole workspace. + ".config/nextest.toml" => Some("nextest configuration changed"), + _ if path.starts_with(".cargo/") => Some(".cargo/ changed"), + _ if path.starts_with("xtask/") => Some("xtask changed"), + _ => None, + } +} + +/// Files the `assets` check looks at (see `assets()` in main.rs). +pub fn is_asset_related(path: &str) -> bool { + path == "ATTRIBUTION.md" || path.ends_with(".attribution") || crate::is_asset(path) +} + +fn under(path: &str, dir: &str) -> bool { + dir.is_empty() || path == dir || path.strip_prefix(dir).is_some_and(|r| r.starts_with('/')) +} + +/// The package whose directory holds `path` (the innermost one). +pub fn owner<'a>(path: &str, pkgs: &'a [Pkg]) -> Option<&'a Pkg> { + pkgs.iter().filter(|p| !p.dir.is_empty() && under(path, &p.dir)).max_by_key(|p| p.dir.len()) +} + +/// Packages whose sources read `path` from outside their own directory. +pub fn readers<'a>(path: &'a str, pkgs: &'a [Pkg]) -> impl Iterator + 'a { + pkgs.iter().filter(move |p| p.inputs.iter().any(|i| under(path, i))) +} + +/// `direct` plus everything that depends on it: transitively through normal/build edges, then +/// one hop through dev-dependency edges (a dev-dependent's tests rerun, its dependents don't). +pub fn expand(direct: &BTreeSet, pkgs: &[Pkg]) -> BTreeSet { + let mut out = direct.clone(); + let mut stack: Vec = direct.iter().cloned().collect(); + while let Some(n) = stack.pop() { + for p in pkgs { + if !out.contains(&p.name) && p.deps.iter().any(|(d, dev)| !dev && *d == n) { + out.insert(p.name.clone()); + stack.push(p.name.clone()); + } + } + } + let dev: Vec = pkgs.iter().filter(|p| p.deps.iter().any(|(d, dev)| *dev && out.contains(d))).map(|p| p.name.clone()).collect(); + out.extend(dev); + out +} + +/// What `ci --affected` will do for a set of changed files. +#[derive(Debug, Default, PartialEq)] +pub struct Plan { + /// Set when the full CI must run instead. + pub full: Option, + /// Packages that hold or read a changed file. + pub direct: BTreeSet, + /// `direct` and its dependents: clippy and tests run for these. + pub affected: BTreeSet, + /// Whether the assets check runs. + pub assets: bool, + /// Changed files no package reads (docs, CI configs, packaging…). + pub unmapped: Vec, +} + +pub fn plan(files: &[String], pkgs: &[Pkg]) -> Plan { + let mut plan = Plan::default(); + for f in files { + if let Some(why) = full_ci_reason(f) { + plan.full = Some(format!("{why} ({f})")); + return plan; + } + plan.assets |= is_asset_related(f); + let mut hit = false; + for p in owner(f, pkgs).into_iter().chain(readers(f, pkgs)) { + plan.direct.insert(p.name.clone()); + hit = true; + } + if !hit { + plan.unmapped.push(f.clone()); + } + } + plan.affected = expand(&plan.direct, pkgs); + plan +} + +/// Normalise `base/rel` (both `/`-separated, relative to the repo root). None if it leaves the repo. +pub fn join_rel(base: &str, rel: &str) -> Option { + let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty() && *s != ".").collect(); + for c in rel.split('/') { + match c { + "" | "." => {} + ".." => { + parts.pop()?; + } + c => parts.push(c), + } + } + Some(parts.join("/")) +} + +/// The `../…` paths in a line of Rust: the part of each string literal from its first `../`, +/// cut back to a directory when a format placeholder follows (`"{}/../model/fixtures/{n}"`). +pub fn rel_paths(line: &str) -> Vec { + let mut out = vec![]; + for lit in line.split('"').skip(1).step_by(2) { + let Some(i) = lit.find("../") else { continue }; + let Some(rest) = lit.get(i..) else { continue }; + let p = match rest.find('{') { + Some(j) => rest.get(..j).and_then(|s| s.rfind('/').and_then(|k| s.get(..k))).unwrap_or(""), + None => rest, + }; + let p = p.trim_end_matches('/'); + if !p.is_empty() { + out.push(p.to_string()); + } + } + out +} + +/// Repo paths outside `pkg_dir` that a source file at `file` (both repo-relative) points at. +/// A literal may be relative to the file (`include_bytes!`) or to the manifest dir +/// (`CARGO_MANIFEST_DIR`), so both are tried. Keep existing paths and paths containing a change, +/// including deleted files and directories that no longer exist in the working tree. +pub fn inputs_of(file: &str, pkg_dir: &str, text: &str, known_input: &dyn Fn(&str) -> bool) -> BTreeSet { + let file_dir = file.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); + let mut out = BTreeSet::new(); + for line in text.lines().filter(|l| l.contains("../")) { + let t = line.trim_start(); + if t.starts_with("//") && !t.starts_with("///") && !t.starts_with("//!") { + continue; + } + for rel in rel_paths(line) { + for base in [file_dir, pkg_dir] { + let Some(p) = join_rel(base, &rel) else { continue }; + if p.is_empty() || p == "target" || p.starts_with("target/") || under(&p, pkg_dir) || !known_input(&p) { + continue; + } + out.insert(p); + } + } + } + out +} + +fn rs_files(dir: &Path, out: &mut Vec, depth: u32) { + if depth > 32 { + return; + } + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let p = e.path(); + let name = e.file_name(); + if p.is_dir() { + if name != "target" && name != ".git" { + rs_files(&p, out, depth + 1); + } + } else if p.extension().is_some_and(|x| x == "rs") { + out.push(p); + } + } +} + +fn rel_to(root: &Path, p: &Path) -> String { + p.strip_prefix(root).unwrap_or(p).components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::>().join("/") +} + +/// Workspace packages (xtask excluded) with their path dependencies and outside inputs. +pub fn packages(root: &Path, md: &Value, changed: &[String]) -> Vec { + let ws = md["workspace_root"].as_str().map(Path::new).unwrap_or(root); + let names: BTreeSet<&str> = md["packages"].as_array().into_iter().flatten().filter_map(|p| p["name"].as_str()).collect(); + let mut pkgs = vec![]; + for p in md["packages"].as_array().into_iter().flatten() { + let Some(name) = p["name"].as_str().filter(|n| *n != "xtask") else { continue }; + let Some(dir) = p["manifest_path"].as_str().and_then(|m| Path::new(m).parent()) else { continue }; + let deps = p["dependencies"] + .as_array() + .into_iter() + .flatten() + .filter(|d| !d["path"].is_null()) + .filter_map(|d| d["name"].as_str().filter(|n| names.contains(n)).map(|n| (n.to_string(), d["kind"] == "dev"))) + .collect(); + let lib_kind = |k: &Value| ["lib", "rlib", "dylib", "cdylib", "staticlib", "proc-macro"].iter().any(|l| k == *l); + let libs: Vec<&Value> = p["targets"].as_array().into_iter().flatten().filter(|t| t["kind"].as_array().into_iter().flatten().any(lib_kind)).collect(); + let has_lib = !libs.is_empty(); + let has_doctests = libs.iter().any(|t| t["doctest"] != false); + let rel = rel_to(ws, dir); + let mut files = vec![]; + rs_files(dir, &mut files, 0); + let known_input = |p: &str| ws.join(p).exists() || changed.iter().any(|f| under(f, p)); + let mut inputs = BTreeSet::new(); + for f in files { + if let Ok(text) = std::fs::read_to_string(&f) { + inputs.extend(inputs_of(&rel_to(ws, &f), &rel, &text, &known_input)); + } + } + pkgs.push(Pkg { name: name.to_string(), dir: rel, deps, has_lib, has_doctests, inputs: inputs.into_iter().collect() }); + } + pkgs +} + +fn git(root: &Path, args: &[&str]) -> Result { + let out = Command::new("git").arg("-C").arg(root).args(args).output().map_err(|e| format!("git: {e}"))?; + if !out.status.success() { + return Err(format!("git {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim())); + } + String::from_utf8(out.stdout).map_err(|e| format!("git {}: output is not UTF-8; cannot safely map changed paths: {e}", args.join(" "))) +} + +/// The base commit: `--base REV` or the merge-base of HEAD with `main` (or `origin/main`). +fn base(root: &Path, rev: Option<&str>) -> Result<(String, String), String> { + if let Some(r) = rev { + let sha = git(root, &["rev-parse", "--verify", &format!("{r}^{{commit}}")])?; + return Ok((sha.trim().to_string(), r.to_string())); + } + for m in ["main", "origin/main"] { + if let Ok(sha) = git(root, &["merge-base", "HEAD", m]) { + return Ok((sha.trim().to_string(), format!("merge-base with {m}"))); + } + } + Err("no merge-base with main or origin/main; pass --base REV".into()) +} + +/// Committed since `base`, staged, unstaged and untracked files (repo-relative, sorted). +fn changed_files(root: &Path, base: &str) -> Result, String> { + let diff = git(root, &["diff", "--name-only", "--no-renames", "-z", base, "--"])?; + let untracked = git(root, &["ls-files", "--others", "--exclude-standard", "-z"])?; + // Git otherwise C-quotes Unicode, tabs and newlines. Whitespace is part of the filename. + let set: BTreeSet = diff.split('\0').chain(untracked.split('\0')).filter(|p| !p.is_empty()).map(str::to_string).collect(); + Ok(set.into_iter().collect()) +} + +fn short_list(names: &BTreeSet) -> String { + if names.is_empty() { "none".into() } else { names.iter().map(|n| crate::short(n)).collect::>().join(", ") } +} + +fn first_few(files: &[String]) -> String { + let mut s = files.iter().take(3).cloned().collect::>().join(", "); + if files.len() > 3 { + s.push_str(&format!(", … (+{})", files.len() - 3)); + } + s +} + +/// One CI step: label and the cargo invocation (None = an in-process check). +struct Step { + label: String, + cmd: Option, + check: Option Result<(), String>>>, +} + +fn cargo(args: &[&str], pkgs: &[&String], tail: &[&str]) -> Command { + let mut c = Command::new(env!("CARGO")); + c.args(args); + for p in pkgs { + c.args(["-p", p.as_str()]); + } + c.args(tail); + c +} + +/// Library tests and doctests are independent: a library can opt out of doctests. +fn test_targets(plan: &Plan, pkgs: &[Pkg]) -> (bool, bool) { + pkgs.iter().filter(|p| plan.affected.contains(&p.name)).fold((false, false), |(lib, docs), p| (lib || p.has_lib, docs || p.has_doctests)) +} + +fn steps(plan: &Plan, pkgs: &[Pkg], nextest: bool) -> Vec { + let mut v = vec![Step { label: "fmt".into(), cmd: Some(cargo(&["fmt", "--check"], &[], &[])), check: None }]; + let aff: Vec<&String> = plan.affected.iter().collect(); + if !aff.is_empty() { + v.push(Step { label: "audio backend".into(), cmd: None, check: Some(Box::new(crate::audio_backend)) }); + let n = aff.len(); + v.push(Step { label: format!("clippy ({n})"), cmd: Some(cargo(&["clippy", "--all-targets"], &aff, &["--", "-D", "warnings"])), check: None }); + let (has_lib, doctests) = test_targets(plan, pkgs); + let selected: Vec = aff.iter().map(|p| p.to_string()).collect(); + v.push(Step { label: format!("tests ({n})"), cmd: None, check: Some(Box::new(move || crate::tests_for(&selected, nextest, has_lib, doctests))) }); + } + v.push(Step { label: "pdf-deps".into(), cmd: None, check: Some(Box::new(|| crate::pdf_deps::run(&crate::root()))) }); + v.push(Step { label: "lock-patches".into(), cmd: None, check: Some(Box::new(|| crate::lock_patches::run(&crate::root()))) }); + v.push(Step { label: "layers".into(), cmd: None, check: Some(Box::new(crate::layers)) }); + if plan.assets { + v.push(Step { label: "assets".into(), cmd: None, check: Some(Box::new(crate::assets)) }); + } + let wasm: Vec<&String> = aff.iter().copied().filter(|a| crate::layer_of(a).is_some_and(|l| l <= 4) || crate::WEB_CRATES.contains(&a.as_str())).collect(); + if !wasm.is_empty() { + v.push(Step { label: format!("wasm ({})", wasm.len()), cmd: Some(cargo(&["check", "--target", "wasm32-unknown-unknown"], &wasm, &[])), check: None }); + } + v +} + +fn fmt_dur(d: Duration) -> String { + let s = d.as_secs(); + if s >= 60 { format!("{}m{:02}s", s / 60, s % 60) } else { format!("{:.1}s", d.as_secs_f64()) } +} + +fn summary(times: &[(String, Duration, bool)]) { + let total: Duration = times.iter().map(|t| t.1).sum(); + eprintln!("\nci --affected timing:"); + for (label, d, ok) in times { + eprintln!(" {:<16} {:>8} {}", label, fmt_dur(*d), if *ok { "ok" } else { "FAILED" }); + } + eprintln!(" {:<16} {:>8}", "total", fmt_dur(total)); +} + +pub fn run(root: &Path, args: &[String]) -> Result<(), String> { + let dry = args.iter().any(|a| a == "--dry-run"); + let rev = match args.iter().position(|a| a == "--base") { + Some(i) => Some(args.get(i + 1).map(String::as_str).ok_or("--base needs a revision")?), + None => None, + }; + let (sha, why) = base(root, rev)?; + let files = changed_files(root, &sha)?; + let md = crate::metadata()?; + let pkgs = packages(root, &md, &files); + let plan = plan(&files, &pkgs); + let short_sha = sha.get(..9).unwrap_or(&sha); + println!("ci --affected: base {short_sha} ({why}), {} changed file(s)", files.len()); + if let Some(reason) = &plan.full { + println!(" full CI: {reason}"); + if dry { + return Ok(()); + } + let t = Instant::now(); + let r = crate::ci(); + summary(&[("full ci".into(), t.elapsed(), r.is_ok())]); + return r; + } + println!(" changed: {}", short_list(&plan.direct)); + println!(" affected: {} ({} of {} crates)", short_list(&plan.affected), plan.affected.len(), pkgs.len()); + if !plan.unmapped.is_empty() { + println!(" read by no crate: {}", first_few(&plan.unmapped)); + } + let nextest = Command::new(env!("CARGO")).args(["nextest", "--version"]).output().is_ok_and(|o| o.status.success()); + let steps = steps(&plan, &pkgs, nextest); + let labels: Vec<&str> = steps.iter().map(|s| s.label.as_str()).collect(); + println!(" steps: {}{}", labels.join(", "), if plan.assets { "" } else { " (assets skipped: no asset changes)" }); + if dry { + return Ok(()); + } + let mut times = vec![]; + let mut failed = vec![]; + for mut s in steps { + let t = Instant::now(); + let r = match (s.cmd.as_mut(), s.check) { + (Some(c), _) => crate::run(c), + (None, Some(f)) => f(), + (None, None) => Ok(()), + }; + if let Err(e) = &r { + eprintln!("ci --affected: {} failed: {e}", s.label); + failed.push(s.label.clone()); + } + times.push((s.label, t.elapsed(), r.is_ok())); + } + summary(×); + if failed.is_empty() { Ok(()) } else { Err(format!("ci --affected failed: {}", failed.join(", "))) } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pkg(name: &str, dir: &str, deps: &[(&str, bool)], inputs: &[&str]) -> Pkg { + Pkg { + name: name.into(), + dir: dir.into(), + deps: deps.iter().map(|(d, dev)| (d.to_string(), *dev)).collect(), + has_lib: true, + has_doctests: true, + inputs: inputs.iter().map(|s| s.to_string()).collect(), + } + } + + /// time ← render ← engine ← ui ← app; host has engine as a dev-dependency; text reads fonts. + fn ws() -> Vec { + vec![ + pkg("effectcraft-time", "crates/time", &[], &[]), + pkg("effectcraft-text", "crates/text", &[], &["assets/fonts"]), + pkg("effectcraft-render", "crates/render", &[("effectcraft-time", false), ("effectcraft-text", false)], &[]), + pkg("effectcraft-engine", "crates/engine", &[("effectcraft-render", false)], &["docs/preferences.md"]), + pkg("effectcraft-host", "crates/host", &[("effectcraft-time", false), ("effectcraft-engine", true)], &["crates/svg/tests/fixtures"]), + pkg("effectcraft-ui-egui", "crates/ui-egui", &[("effectcraft-engine", false)], &[]), + pkg("effectcraft", "apps/effectcraft", &[("effectcraft-ui-egui", false)], &[]), + pkg("effectcraft-cli", "apps/effectcraft-cli", &[("effectcraft", false)], &[]), + pkg("effectcraft-svg", "crates/svg", &[], &[]), + ] + } + + fn set(v: &[&str]) -> BTreeSet { + v.iter().map(|s| s.to_string()).collect() + } + + fn files(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn file_maps_to_innermost_package() { + let mut p = ws(); + p.push(pkg("nested", "crates/time/nested", &[], &[])); + assert_eq!(owner("crates/time/src/lib.rs", &p).map(|p| p.name.as_str()), Some("effectcraft-time")); + assert_eq!(owner("crates/time/nested/src/lib.rs", &p).map(|p| p.name.as_str()), Some("nested")); + // a prefix of the dir name is not a match + assert_eq!(owner("crates/timeline/src/lib.rs", &p).map(|p| p.name.as_str()), None); + assert_eq!(owner("apps/effectcraft-cli/src/main.rs", &p).map(|p| p.name.as_str()), Some("effectcraft-cli")); + assert_eq!(owner("apps/effectcraft/src/main.rs", &p).map(|p| p.name.as_str()), Some("effectcraft")); + assert!(owner("docs/testing.md", &p).is_none()); + } + + #[test] + fn reverse_deps_follow_normal_edges_and_one_dev_hop() { + let p = ws(); + // time: render, engine, ui, app, cli via normal edges; host directly; host is not + // a dependency of anything. + let a = expand(&set(&["effectcraft-time"]), &p); + assert_eq!( + a, + set(&["effectcraft-time", "effectcraft-render", "effectcraft-engine", "effectcraft-host", "effectcraft-ui-egui", "effectcraft", "effectcraft-cli"]) + ); + // ui-egui: only its dependents + assert_eq!(expand(&set(&["effectcraft-ui-egui"]), &p), set(&["effectcraft-ui-egui", "effectcraft", "effectcraft-cli"])); + // engine: host only through its dev-dependency, and nothing beyond host + let e = expand(&set(&["effectcraft-engine"]), &p); + assert!(e.contains("effectcraft-host")); + assert!(!e.contains("effectcraft-time") && !e.contains("effectcraft-render")); + assert_eq!(expand(&set(&["effectcraft-cli"]), &p), set(&["effectcraft-cli"])); + assert!(expand(&BTreeSet::new(), &p).is_empty()); + } + + #[test] + fn workspace_inputs_fall_back_to_full_ci() { + let p = ws(); + for f in ["Cargo.toml", "Cargo.lock", ".cargo/config.toml", "xtask/src/main.rs", "rust-toolchain.toml", "clippy.toml"] { + let pl = plan(&files(&["crates/time/src/lib.rs", f]), &p); + assert!(pl.full.is_some(), "{f}"); + } + // a crate manifest is not workspace-wide + assert_eq!(plan(&files(&["crates/time/Cargo.toml"]), &p).full, None); + assert_eq!(full_ci_reason("docs/xtask.md"), None); + } + + #[test] + fn nextest_policy_alone_requires_full_ci() { + let pl = plan(&files(&[".config/nextest.toml"]), &ws()); + assert_eq!(pl.full.as_deref(), Some("nextest configuration changed (.config/nextest.toml)")); + // Only the actual shared test input triggers this fallback, not every config file. + assert_eq!(full_ci_reason(".config/editor.toml"), None); + assert_eq!(full_ci_reason("docs/nextest.toml"), None); + } + + #[test] + fn docs_only_changes_affect_nothing() { + let pl = plan(&files(&["docs/gaps.md", "README.md", ".github/workflows/release.yml"]), &ws()); + assert_eq!(pl.full, None); + assert!(pl.affected.is_empty()); + assert!(!pl.assets); + assert_eq!(pl.unmapped.len(), 3); + } + + #[test] + fn files_read_from_outside_map_to_their_readers() { + let p = ws(); + // docs/preferences.md is read by an engine test + let pl = plan(&files(&["docs/preferences.md"]), &p); + assert_eq!(pl.direct, set(&["effectcraft-engine"])); + assert!(pl.unmapped.is_empty()); + // a font: read by text (directory input), and an asset + let pl = plan(&files(&["assets/fonts/Inter-Regular.ttf"]), &p); + assert_eq!(pl.direct, set(&["effectcraft-text"])); + assert!(pl.assets); + assert!(pl.affected.contains("effectcraft-cli")); + // svg fixtures belong to svg and are read by host + let pl = plan(&files(&["crates/svg/tests/fixtures/basic-shapes.svg"]), &p); + assert_eq!(pl.direct, set(&["effectcraft-svg", "effectcraft-host"])); + } + + #[test] + fn asset_rules() { + assert!(is_asset_related("ATTRIBUTION.md")); + assert!(is_asset_related("crates/engine/scripts/Rename Layers.jsx.attribution")); + assert!(is_asset_related("docs/brand/artcraft-mark.png")); + assert!(!is_asset_related("docs/testing.md")); + assert!(!is_asset_related("crates/time/src/lib.rs")); + } + + #[test] + fn rel_path_extraction() { + assert_eq!(rel_paths(r#"static F: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");"#), ["../../../assets/fonts/Inter-Regular.ttf"]); + assert_eq!(rel_paths(r#"let p = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/effects.md");"#), ["../../docs/effects.md"]); + assert_eq!(rel_paths(r#"format!("{}/../model/tests/fixtures/{n}", env!("CARGO_MANIFEST_DIR"))"#), ["../model/tests/fixtures"]); + assert_eq!(rel_paths(r#"let a = "x"; let b = "../y/z{n}.png";"#), ["../y"]); + assert!(rel_paths(r#"let s = "no parent here";"#).is_empty()); + } + + #[test] + fn rel_path_joining() { + assert_eq!(join_rel("crates/text/src", "../../../assets/fonts/A.ttf").as_deref(), Some("assets/fonts/A.ttf")); + assert_eq!(join_rel("crates/host", "../model/./tests").as_deref(), Some("crates/model/tests")); + assert_eq!(join_rel("crates", "../../outside"), None); + } + + #[test] + fn inputs_try_file_and_manifest_dirs() { + let existing = set(&["assets/fonts/Inter-Regular.ttf", "docs/effects.md", "crates/model/tests/fixtures", "crates/svg/tests/fixtures/a.svg"]); + let exists = |p: &str| existing.contains(p); + let src = r#" +static F: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); +let p = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/effects.md"); +let fx = format!("{}/../model/tests/fixtures/{n}", env!("CARGO_MANIFEST_DIR")); +let own = include_bytes!("../tests/fixtures/own.svg"); +let out = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out"); +// let commented = include_bytes!("../../svg/tests/fixtures/a.svg"); +"#; + let got = inputs_of("crates/host/src/lib.rs", "crates/host", src, &exists); + assert_eq!(got, set(&["assets/fonts/Inter-Regular.ttf", "docs/effects.md", "crates/model/tests/fixtures"])); + // integration tests in tests/ resolve include_bytes! against tests/ + let got = inputs_of("crates/host/tests/v.rs", "crates/host", r#"include_bytes!("../../svg/tests/fixtures/a.svg")"#, &exists); + assert_eq!(got, set(&["crates/svg/tests/fixtures/a.svg"])); + } + + fn committed_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + git(root, &["init", "-q"]).unwrap(); + git(root, &["config", "core.quotepath", "true"]).unwrap(); + std::fs::write(root.join("README.md"), "fixture\n").unwrap(); + commit_fixture(root); + dir + } + + fn commit_fixture(root: &Path) { + git(root, &["add", "--all"]).unwrap(); + git(root, &["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "commit", "--no-verify", "-qm", "fixture"]) + .unwrap(); + } + + #[test] + fn changed_files_preserve_unicode_in_tracked_and_untracked_paths() { + let dir = committed_repo(); + let root = dir.path(); + let tracked = "crates/time/src/caf\u{e9}.rs"; + let untracked = "crates/time/src/\u{65b0}\u{898f}.rs"; + std::fs::create_dir_all(root.join("crates/time/src")).unwrap(); + std::fs::write(root.join(tracked), "before").unwrap(); + commit_fixture(root); + std::fs::write(root.join(tracked), "after").unwrap(); + std::fs::write(root.join(untracked), "new").unwrap(); + let changed = changed_files(root, "HEAD").unwrap(); + assert_eq!(changed, files(&[tracked, untracked])); + assert_eq!(plan(&changed, &ws()).direct, set(&["effectcraft-time"])); + } + + #[cfg(unix)] + #[test] + fn changed_files_preserve_whitespace_quotes_and_backslashes() { + let dir = committed_repo(); + let root = dir.path(); + let tracked = " tracked\t\"\\name\n "; + let untracked = " untracked\t\"\\name\n "; + std::fs::write(root.join(tracked), "before").unwrap(); + commit_fixture(root); + std::fs::write(root.join(tracked), "after").unwrap(); + std::fs::write(root.join(untracked), "new").unwrap(); + assert_eq!(changed_files(root, "HEAD").unwrap(), files(&[tracked, untracked])); + } + + #[cfg(unix)] + #[test] + fn changed_files_reject_non_utf8_instead_of_silently_omitting_it() { + use std::os::unix::ffi::OsStringExt; + let dir = committed_repo(); + std::fs::write(dir.path().join(std::ffi::OsString::from_vec(b"invalid-\xff.rs".to_vec())), "new").unwrap(); + assert!(changed_files(dir.path(), "HEAD").unwrap_err().contains("not UTF-8")); + } + + #[test] + fn deleted_and_renamed_shared_inputs_still_select_their_readers() { + for rename in [false, true] { + let dir = committed_repo(); + let root = dir.path(); + std::fs::create_dir_all(root.join("crates/engine/src")).unwrap(); + std::fs::create_dir_all(root.join("docs/fixtures")).unwrap(); + std::fs::write(root.join("docs/preferences.md"), "preferences").unwrap(); + std::fs::write(root.join("docs/fixtures/example.json"), "{}").unwrap(); + std::fs::write( + root.join("crates/engine/src/lib.rs"), + r#" +const PREFS: &str = include_str!("../../../docs/preferences.md"); +fn fixture(n: &str) -> String { format!("{}/../../docs/fixtures/{n}", env!("CARGO_MANIFEST_DIR")) } +"#, + ) + .unwrap(); + commit_fixture(root); + if rename { + std::fs::rename(root.join("docs/preferences.md"), root.join("docs/renamed.md")).unwrap(); + std::fs::rename(root.join("docs/fixtures"), root.join("docs/moved")).unwrap(); + } else { + std::fs::remove_file(root.join("docs/preferences.md")).unwrap(); + std::fs::remove_dir_all(root.join("docs/fixtures")).unwrap(); + } + let changed = changed_files(root, "HEAD").unwrap(); + let md = serde_json::json!({ + "workspace_root": root, + "packages": [{ + "name": "effectcraft-engine", + "manifest_path": root.join("crates/engine/Cargo.toml"), + "targets": [{"kind": ["lib"], "doctest": true}], + "dependencies": [] + }] + }); + let pkgs = packages(root, &md, &changed); + let engine = pkgs.first().unwrap(); + assert!(engine.inputs.contains(&"docs/preferences.md".into())); + assert!(engine.inputs.contains(&"docs/fixtures".into())); + for deleted in ["docs/preferences.md", "docs/fixtures/example.json"] { + assert!(changed.contains(&deleted.to_string())); + assert_eq!(plan(&files(&[deleted]), &pkgs).direct, set(&["effectcraft-engine"])); + } + } + } + + #[test] + fn test_runner_keeps_dev_profile_target_selection_and_gpu_isolation() { + for packages in [vec![], files(&["effectcraft-gpu", "effectcraft-ui-egui"])] { + for nextest in [false, true] { + let command = crate::test_command(&packages, nextest, true); + let args: Vec<_> = command.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert!(!args.iter().any(|a| a == "--release" || a == "--profile")); + for target in ["--lib", "--bins", "--tests", "--no-fail-fast"] { + assert!(args.iter().any(|a| a == target)); + } + assert_eq!(args.iter().any(|a| a == "--workspace"), packages.is_empty()); + for package in &packages { + assert!(args.windows(2).any(|w| w[0] == "-p" && w[1] == *package)); + } + assert_eq!(args.windows(2).any(|w| w == ["-E", "not package(effectcraft-gpu)"]), nextest); + assert_eq!(args.iter().any(|a| a == "--no-tests=pass"), nextest && !packages.is_empty()); + } + } + } + + #[test] + fn full_workspace_test_commands_keep_library_tests() { + for nextest in [false, true] { + let command = crate::test_command(&[], nextest, false); + assert!(command.get_args().any(|a| a == "--workspace")); + assert!(command.get_args().any(|a| a == "--lib")); + } + } + + #[test] + fn bin_only_and_doctest_disabled_packages_select_the_right_test_targets() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let md = serde_json::json!({ + "workspace_root": root, + "packages": [ + { + "name": "effectcraft-cli", + "manifest_path": root.join("apps/effectcraft-cli/Cargo.toml"), + "targets": [{"kind": ["bin"], "doctest": false}], + "dependencies": [] + }, + { + "name": "effectcraft-time", + "manifest_path": root.join("crates/time/Cargo.toml"), + "targets": [{"kind": ["lib"], "doctest": false}], + "dependencies": [] + }, + { + "name": "effectcraft-color", + "manifest_path": root.join("crates/color/Cargo.toml"), + "targets": [{"kind": ["lib"], "doctest": true}], + "dependencies": [] + } + ] + }); + let pkgs = packages(root, &md, &[]); + for (name, changed, want_lib, want_doctests) in [ + ("effectcraft-cli", "apps/effectcraft-cli/src/main.rs", false, false), + ("effectcraft-time", "crates/time/src/lib.rs", true, false), + ("effectcraft-color", "crates/color/src/lib.rs", true, true), + ] { + let plan = plan(&files(&[changed]), &pkgs); + assert_eq!(plan.affected, set(&[name])); + let (has_lib, doctests) = test_targets(&plan, &pkgs); + assert_eq!((has_lib, doctests), (want_lib, want_doctests)); + let selected = plan.affected.into_iter().collect::>(); + for nextest in [false, true] { + let command = crate::test_command(&selected, nextest, has_lib); + let args: Vec<_> = command.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert_eq!(args.iter().any(|a| a == "--lib"), want_lib); + assert!(args.windows(2).any(|w| w == ["-p", name])); + for target in ["--bins", "--tests", "--no-fail-fast"] { + assert!(args.iter().any(|a| a == target)); + } + } + } + } + + fn labels(s: &[Step]) -> Vec { + s.iter().map(|s| s.label.clone()).collect() + } + + #[test] + fn steps_follow_the_plan() { + let mut p = ws(); + if let Some(cli) = p.iter_mut().find(|p| p.name == "effectcraft-cli") { + cli.has_lib = false; + cli.has_doctests = false; + } + // docs only: cheap checks + assert_eq!(labels(&steps(&plan(&files(&["docs/gaps.md"]), &p), &p, true)), ["fmt", "pdf-deps", "lock-patches", "layers"]); + // ui-egui (L5, a web crate) and its two app dependents; cli has no doctests + let s = steps(&plan(&files(&["crates/ui-egui/src/dock.rs"]), &p), &p, true); + assert_eq!(labels(&s), ["fmt", "audio backend", "clippy (3)", "tests (3)", "pdf-deps", "lock-patches", "layers", "wasm (1)"]); + let args = |i: usize| s.get(i).and_then(|s| s.cmd.as_ref()).map(|c| c.get_args().map(|a| a.to_string_lossy().into_owned()).collect::>()); + let clippy = args(2).unwrap_or_default(); + assert!(!clippy.iter().any(|a| a == "--release" || a == "--profile")); + assert!(clippy.windows(2).any(|w| w == ["-p", "effectcraft-ui-egui"])); + assert!(clippy.ends_with(&["--".into(), "-D".into(), "warnings".into()])); + assert!(args(7).unwrap_or_default().windows(2).any(|w| w == ["-p", "effectcraft-ui-egui"])); + // without nextest: one cargo test step; an asset change adds the assets check + let s = steps(&plan(&files(&["assets/fonts/A.ttf"]), &p), &p, false); + assert!(labels(&s).contains(&"tests (7)".to_string()) && labels(&s).contains(&"assets".to_string())); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 06d32e51a..16e28fa07 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -13,11 +13,14 @@ //! `crates/aep/tests/fixtures/corpus.lock`, verify every file's SHA-256, install into `corpus/aep`. //! - `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. +//! PDF dependency policy, lock patches, layers, assets, wasm. Everything builds in the dev profile. +//! - `ci --affected [--base REV] [--dry-run]`: the same gates for only the crates the change since +//! REV (default: the merge-base with `main`) can affect; see `affected.rs` and docs/testing.md. //! - `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). mod aep_corpus; +mod affected; mod bench; mod ico; mod lock_patches; @@ -480,34 +483,73 @@ fn audio_backend() -> Result<(), String> { /// /// Each run goes ahead when an earlier one fails; the error names every run that failed. fn tests() -> Result<(), String> { + let nextest = Command::new(env!("CARGO")).args(["nextest", "--version"]).output().is_ok_and(|o| o.status.success()); + tests_for(&[], nextest, true, true) +} + +/// Keep the full and affected gates on the same test runner, including GPU isolation and the +/// translation report. An empty package list selects the whole workspace. +fn tests_for(packages: &[String], nextest: bool, has_lib: bool, doctests: bool) -> Result<(), String> { let cargo = env!("CARGO"); - let targets = ["--workspace", "--lib", "--bins", "--tests", "--no-fail-fast"]; - let nextest = Command::new(cargo).args(["nextest", "--version"]).output().is_ok_and(|o| o.status.success()); - let mut runs = Vec::new(); - if nextest { - runs.push(run(Command::new(cargo).args(["nextest", "run"]).args(targets).args(["-E", "not package(effectcraft-gpu)"]))); - // effectcraft-gpu's tests share one device, so they run in one libtest process: the - // binary nextest just built (`cargo test -p effectcraft-gpu` would rebuild ~2 minutes). - runs.push(lib_test_binary("effectcraft-gpu").and_then(|mut c| run(&mut c))); - } else { + let selection = test_selection(packages); + let includes = |name: &str| packages.is_empty() || packages.iter().any(|p| p == name); + if !nextest { println!("cargo-nextest not found, running cargo test (install: cargo install cargo-nextest --locked)"); - runs.push(run(Command::new(cargo).arg("test").args(targets))); + } + let mut runs = vec![run(&mut test_command(packages, nextest, has_lib))]; + if nextest && includes("effectcraft-gpu") { + // GPU tests share one device, so reuse the selected build in one libtest process. + runs.push(lib_test_binary("effectcraft-gpu", &selection).and_then(|mut c| run(&mut c))); } // The test runs above hide a passing test's output, so the always-passing report runs again // with --nocapture, from the binary already built. - runs.push(lib_test_binary("effectcraft-ui-egui").and_then(|mut c| run(c.args(["missing_translations_report", "--nocapture"])))); - runs.push(run(Command::new(cargo).args(["test", "--workspace", "--doc", "--no-fail-fast"]))); + if includes("effectcraft-ui-egui") { + runs.push(lib_test_binary("effectcraft-ui-egui", &selection).and_then(|mut c| run(c.args(["missing_translations_report", "--nocapture"])))); + } + if doctests { + runs.push(run(Command::new(cargo).arg("test").args(&selection).args(["--doc", "--no-fail-fast"]))); + } let failed: Vec = runs.into_iter().filter_map(Result::err).collect(); if failed.is_empty() { Ok(()) } else { Err(failed.join("; ")) } } +/// Select library tests only when the selected packages contain a library; bin-only package +/// sets otherwise fail before running any tests. The full workspace always has library targets. +fn test_command(packages: &[String], nextest: bool, has_lib: bool) -> Command { + let mut c = Command::new(env!("CARGO")); + if nextest { + c.args(["nextest", "run"]); + // A selected package can have no tests, or only GPU tests that run separately. + if !packages.is_empty() { + c.arg("--no-tests=pass"); + } + } else { + c.arg("test"); + } + c.args(test_selection(packages)); + if packages.is_empty() || has_lib { + c.arg("--lib"); + } + c.args(["--bins", "--tests", "--no-fail-fast"]); + if nextest { + c.args(["-E", "not package(effectcraft-gpu)"]); + } + c +} + +fn test_selection(packages: &[String]) -> Vec { + if packages.is_empty() { vec!["--workspace".into()] } else { packages.iter().flat_map(|p| ["-p".into(), p.clone()]).collect() } +} + /// A command running `package`'s lib test binary from the test build above, the way `cargo test` -/// runs it (from the package directory), found with `--no-run` and the same workspace selection +/// runs it (from the package directory), found with `--no-run` and the same package selection /// so nothing rebuilds: `cargo test -p ` resolves features for that package alone and /// recompiles much of the workspace. -fn lib_test_binary(package: &str) -> Result { +fn lib_test_binary(package: &str, selection: &[String]) -> Result { let out = Command::new(env!("CARGO")) - .args(["test", "--workspace", "--lib", "--bins", "--tests", "--no-run", "--message-format=json-render-diagnostics"]) + .arg("test") + .args(selection) + .args(["--lib", "--bins", "--tests", "--no-run", "--message-format=json-render-diagnostics"]) .stderr(std::process::Stdio::inherit()) .output() .map_err(|e| format!("cargo test --no-run: {e}"))?; @@ -560,7 +602,10 @@ fn main() -> ExitCode { "pdf-deps" => pdf_deps::run(&root()), "lock-patches" => lock_patches::run(&root()), "aep-corpus" => aep_corpus::run(&root(), &std::env::args().skip(2).collect::>()), - "ci" => ci(), + "ci" => { + let rest: Vec = std::env::args().skip(2).collect(); + if rest.iter().any(|a| a == "--affected") { affected::run(&root(), &rest) } else { ci() } + } "audio-backend" => audio_backend(), "web" => web(&std::env::args().skip(2).collect::>()), "bench" => bench::run(&root(), &std::env::args().skip(2).collect::>()), @@ -572,7 +617,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, @@ -585,7 +630,7 @@ fn main() -> ExitCode { /// The workspace root (the parent of `xtask/`). pub fn root() -> std::path::PathBuf { - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().map(std::path::Path::to_path_buf).unwrap_or_else(|| std::path::PathBuf::from(".")) } #[cfg(all(test, target_os = "linux"))] From 38dffd4aea498eeb0db91b9f2c462e8e2f837db3 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Fri, 6 Nov 2026 07:13:15 +0100 Subject: [PATCH 038/227] Disk cache: frames and layer buffers are compressed on the writer thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A render thread handed each frame to the disk cache already copied pixel by pixel and LZ4-compressed (~9 ms per 1080p frame) before it could start the next one, and layer buffers were encoded even when the queue then dropped them. The queue now holds raw RGBA (one memcpy via ColorImage::as_raw) and Arcs; the writer thread encodes them. Every frame of a 120-frame bench run now reaches disk (113–119 before). cache_bench prints the flush time. --- crates/render/src/cache.rs | 11 +-- crates/render/src/disk_cache.rs | 97 ++++++++++++++++++++------ crates/ui-egui/examples/cache_bench.rs | 4 +- crates/ui-egui/src/frames.rs | 6 +- 4 files changed, 85 insertions(+), 33 deletions(-) diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 7bc52606f..4f72e79fa 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -361,10 +361,11 @@ pub trait LayerStore: Send + Sync { /// mid-render). An entry whose files changed, or damaged, is a miss and is dropped, so the /// buffer rendered again replaces it. `virtual_id` names files served from memory. fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Arc, ReadFiles)>; - /// Keep a buffer that was slow to render, with the source files it was made of. `since`: the + /// Keep a buffer that was slow to render, with the source files it was made of (shared, so + /// a store can encode it off the render thread). `since`: the /// [`LayerStore::drop_generation`] when its render started (a buffer made of a file dropped /// after that is refused). - fn put_layer(&self, key: u128, buf: &Buf, files: &EntryFiles, since: u64); + fn put_layer(&self, key: u128, buf: &Arc, files: &EntryFiles, since: u64); /// How many times files were dropped from the store (File ▸ Reload Footage, /// [`crate::disk_cache::DiskCache::drop_files`]); `0` for a store that never drops. fn drop_generation(&self) -> u64 { @@ -540,7 +541,7 @@ impl LayerStore for PrefetchStore { } /// Queues the layer for writing to the persistent store the first time `key` is seen. - fn put_layer(&self, key: u128, buf: &Buf, files: &EntryFiles, _since: u64) { + fn put_layer(&self, key: u128, buf: &Arc, files: &EntryFiles, _since: u64) { let Ok(mut s) = self.st.lock() else { return }; if s.known.insert(key) { s.writes.push((key, crate::disk_cache::layer_entry(buf, files))); @@ -983,7 +984,7 @@ mod shared_insert_tests { assert!(store.get_layer(5, Some(&two)).is_none()); assert!(!store.has(5)); assert_eq!(store.take_misses(), [5]); - store.put_layer(5, &buf(), &EntryFiles::of(&files, Some(&two)).unwrap(), 0); + store.put_layer(5, &Arc::new(buf()), &EntryFiles::of(&files, Some(&two)).unwrap(), 0); assert_eq!(store.take_writes().len(), 1, "written over the changed entry"); // Through the layer cache: a hit reports its files; incomplete lists are not queued. let cache = LayerCache::default(); @@ -1065,7 +1066,7 @@ mod shared_insert_tests { fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Arc, crate::reads::ReadFiles)> { super::LayerStore::get_layer(&*self.dc, key, virtual_id) } - fn put_layer(&self, key: u128, buf: &Buf, files: &crate::disk_cache::EntryFiles, since: u64) { + fn put_layer(&self, key: u128, buf: &Arc, files: &crate::disk_cache::EntryFiles, since: u64) { self.arrived.wait(); self.go_on.wait(); super::LayerStore::put_layer(&*self.dc, key, buf, files, since); diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index f509a5896..fa0a97696 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -264,11 +264,41 @@ impl LayerPrefetch { #[cfg_attr(target_arch = "wasm32", allow(dead_code))] enum Job { /// Kind, key, header format byte, payload. - Write(Kind, u128, u8, Vec), + Write(Kind, u128, u8, Payload), /// Delete the frame and layer entries made of these files ([`DiskCache::drop_files`]). Drop(Vec>), } +/// What a queued write holds. 8-bit frames and layer buffers are encoded (LZ4) on the writer +/// thread, not on the render thread that queued them: encoding a 1080p frame took ~9 ms of every +/// frame's render, and a full queue no longer throws away encoding work. +enum Payload { + Encoded(Vec), + /// 8-bit premultiplied RGBA, `width * height * 4` bytes, and the files it was made of. + Frame(u32, u32, Vec, EntryFiles), + Layer(Arc, EntryFiles), +} + +impl Payload { + /// Bytes it holds (the frame queue's budget). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + fn len(&self) -> usize { + match self { + Payload::Encoded(p) | Payload::Frame(_, _, p, _) => p.len(), + Payload::Layer(b, _) => b.img.data.len().saturating_mul(std::mem::size_of::()), + } + } + + /// The entry's payload: its source files, then its pixels. + fn encode(self) -> Vec { + match self { + Payload::Encoded(p) => p, + Payload::Frame(w, h, rgba, files) => with_files(&files, encode_frame(w, h, &rgba)), + Payload::Layer(b, files) => with_files(&files, encode_layer(&b)), + } + } +} + struct Queue { /// Frame writes (taken first) and their payload bytes. frames: std::collections::VecDeque, @@ -771,20 +801,20 @@ impl DiskCache { /// Queue a write; `false` when it was dropped (a full queue). fn enqueue(&self, kind: Kind, key: u128, payload: Vec) -> bool { - self.enqueue_format(kind, key, FORMAT_RGBA8, payload, None) + self.enqueue_format(kind, key, FORMAT_RGBA8, Payload::Encoded(payload), None) } /// [`DiskCache::enqueue`] with the entry's header format byte. `made`: the source files of a /// frame or layer entry and the drop generation its reads started at /// ([`DiskCache::drop_generation`]); refused (`false`) when one of them was dropped since. - fn enqueue_format(&self, kind: Kind, key: u128, format: u8, payload: Vec, made: Option<(&EntryFiles, u64)>) -> bool { + fn enqueue_format(&self, kind: Kind, key: u128, format: u8, payload: Payload, made: Option<(&EntryFiles, u64)>) -> bool { let stale = |q: &Queue| made.is_some_and(|(files, since)| q.drops.stale(files.files().iter().map(|f| &f.path), since)); #[cfg(target_arch = "wasm32")] { if self.queue.0.lock().is_ok_and(|q| stale(&q)) { return false; } - self.write_format(kind, key, format, &payload); + self.write_format(kind, key, format, &payload.encode()); true } #[cfg(not(target_arch = "wasm32"))] @@ -1033,7 +1063,7 @@ impl DiskCache { } self.count_encode(); let payload = with_files(&files, encode_frame_f16(width, height, rgba)); - self.enqueue_format(Kind::Frame, key, FORMAT_F16_FILES, payload, Some((&files, since))) + self.enqueue_format(Kind::Frame, key, FORMAT_F16_FILES, Payload::Encoded(payload), Some((&files, since))) } /// Write a half-float frame synchronously (tests); a list that can't be stored is not. @@ -1050,6 +1080,22 @@ impl DiskCache { /// render started at). #[allow(clippy::too_many_arguments)] pub fn put_frame(&self, key: u128, width: u32, height: u32, rgba: &[u8], files: &ReadFiles, virtual_id: Option>, since: u64) -> bool { + self.put_frame_owned(key, width, height, rgba.to_vec(), files, virtual_id, since) + } + + /// [`DiskCache::put_frame`] of a frame the caller hands over (no copy; compressed on the + /// writer thread). + #[allow(clippy::too_many_arguments)] + pub fn put_frame_owned( + &self, + key: u128, + width: u32, + height: u32, + rgba: Vec, + files: &ReadFiles, + virtual_id: Option>, + since: u64, + ) -> bool { if rgba.len() as u64 != width as u64 * height as u64 * 4 { return false; } @@ -1057,13 +1103,13 @@ impl DiskCache { if self.contains(Kind::Frame, key) { return true; } - // (no room on the disk: refused before the pixels are compressed) + // (no room on the disk: refused before the pixels are queued and compressed) if !self.accepts_writes() { return false; } self.count_encode(); - let payload = with_files(&files, encode_frame(width, height, rgba)); - self.enqueue_format(Kind::Frame, key, FORMAT_RGBA8_FILES, payload, Some((&files, since))) + let made = files.clone(); + self.enqueue_format(Kind::Frame, key, FORMAT_RGBA8_FILES, Payload::Frame(width, height, rgba, files), Some((&made, since))) } /// A layer buffer (key: [`layer_disk_key`]) whose source files are unchanged, with those @@ -1078,13 +1124,14 @@ impl DiskCache { /// [`EntryFiles::of`]: a buffer whose files can't all be named must not be stored). Refused /// when one of them was dropped after `since`, the [`DiskCache::drop_generation`] its render /// started at. - pub fn put_layer(&self, key: u128, buf: &Buf, files: &EntryFiles, since: u64) { - // (no room on the disk: refused before the buffer is compressed) + /// The buffer is shared, and encoded on the writer thread. + pub fn put_layer(&self, key: u128, buf: &Arc, files: &EntryFiles, since: u64) { + // (no room on the disk: refused before the buffer is queued and compressed) if self.contains(Kind::Layer, key) || !self.accepts_writes() { return; } self.count_encode(); - let _ = self.enqueue_format(Kind::Layer, key, FORMAT_LAYER_FILES, with_files(files, encode_layer(buf)), Some((files, since))); + let _ = self.enqueue_format(Kind::Layer, key, FORMAT_LAYER_FILES, Payload::Layer(buf.clone(), files.clone()), Some((files, since))); } /// Write a layer buffer synchronously (tests); a list that can't be stored is not. @@ -1192,7 +1239,7 @@ fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { let Some(cache) = dc.upgrade() else { return }; let written = match job { Job::Write(kind, key, format, payload) => { - cache.write_format(kind, key, format, &payload); + cache.write_format(kind, key, format, &payload.encode()); (kind == Kind::Frame).then_some(key) } Job::Drop(paths) => { @@ -1292,7 +1339,7 @@ impl crate::cache::LayerStore for DiskCache { DiskCache::get_layer(self, key, virtual_id).map(|(b, f)| (Arc::new(b), f)) } /// Queues a layer write on the disk cache. - fn put_layer(&self, key: u128, buf: &Buf, files: &EntryFiles, since: u64) { + fn put_layer(&self, key: u128, buf: &Arc, files: &EntryFiles, since: u64) { DiskCache::put_layer(self, key, buf, files, since); } /// The current drop generation of the disk cache. @@ -1706,7 +1753,7 @@ mod tests { let dc = DiskCache::open(&dir, 1 << 30).unwrap(); assert!(dc.get_layer(42, None).is_none()); let b = buf(1, 33, 17); - dc.put_layer(42, &b, &EntryFiles::default(), 0); + dc.put_layer(42, &Arc::new(b.clone()), &EntryFiles::default(), 0); dc.flush(); let (back, files) = dc.get_layer(42, None).unwrap(); assert!(files.is_empty() && files.is_complete()); @@ -1715,10 +1762,14 @@ mod tests { let rgba: Vec = (0..20 * 10 * 4).map(|i| (i % 251) as u8).collect(); assert!(dc.put_frame(7, 20, 10, &rgba, &ReadFiles::default(), None, 0)); assert!(!dc.put_frame(8, 2, 2, &[0; 3], &ReadFiles::default(), None, 0), "a wrong-size frame is refused"); + // Handed over and compressed on the writer thread: the same pixels come back. + assert!(dc.put_frame_owned(9, 20, 10, rgba.clone(), &ReadFiles::default(), None, 0)); + assert!(!dc.put_frame_owned(10, 2, 2, vec![0; 3], &ReadFiles::default(), None, 0), "a wrong-size frame is refused"); dc.flush(); assert_eq!(dc.get_frame(7, None).map(|f| f.0).unwrap().rgba, rgba); + assert_eq!(dc.get_frame(9, None).map(|f| f.0).unwrap().rgba, rgba); let st = dc.stats(); - assert_eq!((st.entries, st.hits, st.misses, st.writes), (2, 2, 1, 2)); + assert_eq!((st.entries, st.hits, st.misses, st.writes), (3, 3, 1, 3)); // Wrong kind is a miss. assert!(dc.get_frame(42, None).map(|f| f.0).is_none()); } @@ -1780,7 +1831,7 @@ mod tests { let dir = tmpdir("burst"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); for k in 0..300 { - dc.put_layer(10_000 + k, &buf(1, 64, 64), &EntryFiles::default(), 0); + dc.put_layer(10_000 + k, &Arc::new(buf(1, 64, 64)), &EntryFiles::default(), 0); } let rgba = vec![7u8; 32 * 32 * 4]; for k in 0..300 { @@ -1950,7 +2001,7 @@ mod tests { let rgba = vec![7u8; 16 * 16 * 4]; assert!(!dc.put_frame(1, 16, 16, &rgba, &ReadFiles::default(), None, 0), "refused"); assert!(!dc.put_frame_f16(2, 16, 16, &[0x3c00u16; 16 * 16 * 4], &ReadFiles::default(), None, 0), "refused"); - dc.put_layer(3, &buf(3, 16, 16), &EntryFiles::default(), 0); + dc.put_layer(3, &Arc::new(buf(3, 16, 16)), &EntryFiles::default(), 0); dc.flush(); assert_eq!(dc.encodes(), 0, "nothing compressed"); assert_eq!(dc.stats().entries, 0); @@ -1960,7 +2011,7 @@ mod tests { assert_eq!(dc.stats().limit_bytes, 32 << 20); assert!(!dc.accepts_writes()); assert!(!dc.put_frame(1, 16, 16, &rgba, &ReadFiles::default(), None, 0), "refused"); - dc.put_layer(3, &buf(3, 16, 16), &EntryFiles::default(), 0); + dc.put_layer(3, &Arc::new(buf(3, 16, 16)), &EntryFiles::default(), 0); assert_eq!(dc.encodes(), 0, "nothing compressed"); // A small limit the setting asks for (not free space) is used. dc.set_disk_space(None); @@ -1971,7 +2022,7 @@ mod tests { dc.set_disk_space(Some((4096 * GB, 500 * GB))); assert!(dc.put_frame(1, 16, 16, &rgba, &ReadFiles::default(), None, 0)); assert!(dc.put_frame_f16(2, 16, 16, &[0x3c00u16; 16 * 16 * 4], &ReadFiles::default(), None, 0)); - dc.put_layer(3, &buf(3, 16, 16), &EntryFiles::default(), 0); + dc.put_layer(3, &Arc::new(buf(3, 16, 16)), &EntryFiles::default(), 0); dc.flush(); assert_eq!(dc.encodes(), 3); assert_eq!(dc.stats().entries, 3); @@ -2076,11 +2127,11 @@ mod tests { assert!(dc.get_frame(k, Some(&two)).is_none() && !dc.contains(Kind::Frame, k), "replaced: a miss, deleted"); // A layer buffer made of it. let lk = layer_disk_key(7); - dc.put_layer(lk, &buf(1, 4, 4), &EntryFiles::of(&files, Some(&one)).unwrap(), 0); + dc.put_layer(lk, &Arc::new(buf(1, 4, 4)), &EntryFiles::of(&files, Some(&one)).unwrap(), 0); dc.flush(); assert!(dc.get_layer(lk, Some(&one)).is_some_and(|(b, f)| b.img == buf(1, 4, 4).img && f == files && f.is_complete())); assert!(dc.get_layer(lk, None).is_none() && !dc.contains(Kind::Layer, lk), "no identity: a miss, deleted"); - dc.put_layer(lk, &buf(1, 4, 4), &EntryFiles::of(&files, Some(&one)).unwrap(), 0); + dc.put_layer(lk, &Arc::new(buf(1, 4, 4)), &EntryFiles::of(&files, Some(&one)).unwrap(), 0); dc.flush(); assert!(dc.get_layer(lk, Some(&two)).is_none() && !dc.contains(Kind::Layer, lk), "replaced: a miss, deleted"); // The browser's sealed layer entries carry the same list. @@ -2324,7 +2375,7 @@ mod tests { assert!(dc.dropped_since(&read(&[&a]), before) && !dc.dropped_since(&read(&[&b]), before)); assert!(!dc.put_frame(1, 1, 1, &[1; 4], &read(&[&a, &b]), None, before), "a frame from before the drop"); assert!(!dc.put_frame_f16(2, 1, 1, &[1; 4], &read(&[&a]), None, before)); - dc.put_layer(3, &buf(3, 2, 2), &EntryFiles::of(&read(&[&a]), None).unwrap(), before); + dc.put_layer(3, &Arc::new(buf(3, 2, 2)), &EntryFiles::of(&read(&[&a]), None).unwrap(), before); assert!(dc.put_frame(4, 1, 1, &[4; 4], &read(&[&b]), None, before), "b.png wasn't dropped"); dc.flush(); assert!(!dc.contains(Kind::Frame, 1) && !dc.contains(Kind::Frame, 2) && !dc.contains(Kind::Layer, 3)); @@ -2334,7 +2385,7 @@ mod tests { let after = dc.drop_generation(); assert!(after > before && !dc.dropped_since(&read(&[&a]), after)); assert!(dc.put_frame(5, 1, 1, &[5; 4], &read(&[&a]), None, after)); - dc.put_layer(6, &buf(6, 2, 2), &EntryFiles::of(&read(&[&a]), None).unwrap(), after); + dc.put_layer(6, &Arc::new(buf(6, 2, 2)), &EntryFiles::of(&read(&[&a]), None).unwrap(), after); dc.flush(); assert_eq!(dc.get_frame(5, None).map(|f| f.0.rgba), Some(vec![5; 4])); assert!(dc.get_layer(6, None).is_some()); diff --git a/crates/ui-egui/examples/cache_bench.rs b/crates/ui-egui/examples/cache_bench.rs index 828075249..7d57ea773 100644 --- a/crates/ui-egui/examples/cache_bench.rs +++ b/crates/ui-egui/examples/cache_bench.rs @@ -170,8 +170,10 @@ fn main() { println!("footage cache {} hits, {} misses, {} frames ({} MB) kept", p.hits, p.misses, p.frames, p.bytes >> 20); if let Some(dc) = &s.disk_cache { // (writes finish on the disk cache's own thread) + let t = Instant::now(); dc.flush(); + let flushed = t.elapsed().as_secs_f64(); let d = dc.stats(); - println!("disk cache {} written, {} dropped, {} entries ({} MB)", d.writes, d.dropped, d.entries, d.bytes >> 20); + println!("disk cache {} written, {} dropped, {} entries ({} MB), flushed in {flushed:.2} s", d.writes, d.dropped, d.entries, d.bytes >> 20); } } diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 32f0dd161..800306385 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -571,10 +571,8 @@ fn put_disk_frame(dc: &DiskCache, dk: u128, img: &FrameImage, files: &ReadFiles, return false; } match img { - FrameImage::Cpu(img) => { - let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); - dc.put_frame(dk, img.size[0] as u32, img.size[1] as u32, &rgba, files, virtual_id, since) - } + // (one copy; the disk cache's writer thread compresses it) + FrameImage::Cpu(img) => dc.put_frame_owned(dk, img.size[0] as u32, img.size[1] as u32, img.as_raw().to_vec(), files, virtual_id, since), FrameImage::CpuF16(f) => dc.put_frame_f16(dk, f.width, f.height, f.bits(), files, virtual_id, since), FrameImage::Gpu(_) => false, } From 87bfaa8f1c3848d11f5d7ecdf723c6b3e96c38a6 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Fri, 6 Nov 2026 07:13:15 +0100 Subject: [PATCH 039/227] Preview: Cache Frames When Idle is on by default, as in After Effects A comp left alone for a second fills its RAM preview from the current time, so the first playback is cached instead of rendering frame by frame. Saved settings keep their value. Two UI tests that assumed the old default set it explicitly. --- crates/engine/src/settings/prefs/sections.rs | 2 +- crates/ui-egui/src/state.rs | 2 +- crates/ui-egui/tests/it/ui_comp_presets.rs | 2 ++ crates/ui-egui/tests/it/ui_preview_align.rs | 5 +++-- docs/preferences.md | 3 ++- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index 328f93bf4..9a9f4ab21 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -93,7 +93,7 @@ page!(Previews { /// Largest downsampling while playing back: `1/2`, `1/4`, `1/8`. adaptive_resolution_limit: String = "1/8".into(), show_internal_wireframes: bool = false, - cache_frames_when_idle: bool = false, + cache_frames_when_idle: bool = true, fast_previews: bool = false, /// `faster` or `moreAccurate`. zoom_quality: String = "moreAccurate".into(), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 612aa0a10..635b46acd 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -806,7 +806,7 @@ impl Default for UiState { home_learn: false, home_templates: false, align_to_selection: false, - cache_when_idle: false, + cache_when_idle: true, tracker_source: None, layer_panel: None, layer_view: None, diff --git a/crates/ui-egui/tests/it/ui_comp_presets.rs b/crates/ui-egui/tests/it/ui_comp_presets.rs index 3fda8a8cc..93cd02030 100644 --- a/crates/ui-egui/tests/it/ui_comp_presets.rs +++ b/crates/ui-egui/tests/it/ui_comp_presets.rs @@ -174,6 +174,8 @@ fn composition_settings_view_stays_put_when_it_scrolls() { #[test] fn composition_settings_refits_when_the_language_changes() { let mut s = Session::default(); + // Idle caching keeps repainting while it renders; this test waits for repaints to stop. + s.prefs.previews.cache_frames_when_idle = false; s.execute("comp.new", json!({"name": "Edit", "width": 2560, "height": 1440, "frameRate": 60, "duration": 4})).unwrap(); let mut h = Harness::builder() .with_size(egui::vec2(1600.0, 1000.0)) diff --git a/crates/ui-egui/tests/it/ui_preview_align.rs b/crates/ui-egui/tests/it/ui_preview_align.rs index 1469a5e9e..622df0397 100644 --- a/crates/ui-egui/tests/it/ui_preview_align.rs +++ b/crates/ui-egui/tests/it/ui_preview_align.rs @@ -238,8 +238,9 @@ fn cache_frames_when_idle_fills_the_work_area() { let mut h = harness(); let cid = h.state().session.active_comp_id().unwrap(); let cached = |h: &Harness<'_, EffectcraftApp>| h.state().frames.cached_frames(&h.state().shown_series(cid)).len(); - // Off: only a few frames ahead of the current time are prefetched (the work area is the - // whole 240 frames). + // Off (it is on by default): only a few frames ahead of the current time are prefetched (the + // work area is the whole 240 frames). + invoke(&mut h, "playback.cacheWhenIdle", json!({"value": false})); for _ in 0..120 { h.step(); std::thread::sleep(std::time::Duration::from_millis(2)); diff --git a/docs/preferences.md b/docs/preferences.md index 927b66788..de7e426bc 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -55,7 +55,8 @@ Commands (CLI, MCP, control channel): Adaptive Resolution drops to while you drag; a playing preview keeps the viewer's resolution) - `previews.cacheFramesWhenIdle`: Cache Frames When Idle (Composition ▸ Preview): after a second without input or edits, the viewer renders the work area into the RAM preview in the - background, from the current time on, until it is cached or the budget is full + background, from the current time on, until it is cached or the budget is full. On by default, + as in After Effects, so the first playback of a comp left alone for a second is already cached - `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects) - `appearance.appearanceMode`: Appearance Mode (`auto` / `dark` / `light`). `auto` (Sync with System) follows the operating system's light or dark appearance while the app runs: on Linux the desktop app reads the XDG desktop portal (`org.freedesktop.appearance` `color-scheme`) once and then listens for its `SettingChanged` signal on a worker thread, repainting only when the value changes (nothing polls); `gsettings` runs at most once at start-up, by absolute path, when the portal gives no answer. Elsewhere it uses what the window system reports. With no answer it shows the dark theme. New installs stay `dark`; Sync with System is opt-in. The Tools bar's appearance button (a monitor, sun or moon) cycles Sync with System, Light and Dark (`view.theme.toggle`, Next Appearance Mode). - `appearance.darkTheme`: Dark Theme (`dark` / `darker`), shown in dark appearance From 42989edd8eb1adda172ad4d2b082801f6e18f982 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Fri, 6 Nov 2026 07:13:15 +0100 Subject: [PATCH 040/227] Gaussian Blur: the exact kernel for small blurs runs row by row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sampled Gaussian (sigma < 3) read every tap through a bounds-checked get, and its vertical pass walked a column per pixel. Taps inside a row are now one slice, and the vertical pass adds whole input rows in 128-pixel tiles. Same f64 sums in the same order: bit-identical output (tested against the per-pixel kernel). Each pass is 15–25% faster. --- crates/raster/src/blur.rs | 117 ++++++++++++++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 11 deletions(-) diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index c8689a7b6..9b785959f 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -166,6 +166,9 @@ pub fn gaussian_is_exact(sigma_x: f64, sigma_y: f64) -> bool { sigma_x.max(sigma_y) < EXACT_SIGMA_BELOW } +/// Pixels per tile of [`gauss_pass`]'s vertical pass. +const TILE: usize = 128; + /// One separable pass of a sampled Gaussian (radius 3 sigma) along x or y. fn gauss_pass(img: &Image, sigma: f64, vertical: bool, repeat: bool) -> Image { // Below 1e-3 the kernel is the identity in f64; tiny sigmas would also make 2σ² underflow to 0 (NaN taps). @@ -176,20 +179,65 @@ fn gauss_pass(img: &Image, sigma: f64, vertical: bool, repeat: bool) -> Image { let mut k: Vec = (-r..=r).map(|i| (-((i * i) as f64) / (2.0 * sigma * sigma)).exp()).collect(); let total: f64 = k.iter().sum(); k.iter_mut().for_each(|v| *v /= total); - let w = img.width as usize; + let (w, h) = (img.width as usize, img.height as usize); let mut out = Image::new(img.width, img.height); - out.rows_mut().for_each(|(y, row)| { - for x in 0..w { - let mut acc = [0.0f64; 4]; - for (i, wt) in k.iter().enumerate() { - let d = i as i64 - r; - let (sx, sy) = if vertical { (x as i64, y as i64 + d) } else { (x as i64 + d, y as i64) }; - let p = if repeat { img.get_clamped(sx, sy) } else { img.get(sx, sy) }; - for c in 0..4 { - acc[c] += p[c] as f64 * wt; + // Every output pixel adds its taps in kernel order, in f64, as a per-pixel loop would: the + // result is the same to the bit. Taps outside a transparent edge are skipped (adding `+0.0` + // leaves a sum that started at `+0.0` unchanged). + if vertical { + // Row by row: each output row adds whole input rows (contiguous, vectorises), instead of + // walking a column per pixel (a strided read per tap). + out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + // 128 pixels at a time: the f64 sums (4 KB) stay in L1 across the taps. + for (t, out_tile) in row.chunks_mut(TILE).enumerate() { + let x0 = t * TILE; + let mut acc = [[0.0f64; 4]; TILE]; + for (i, &wt) in k.iter().enumerate() { + let sy = y as i64 + i as i64 - r; + let sy = if repeat { + sy.clamp(0, h as i64 - 1) + } else if sy < 0 || sy >= h as i64 { + continue; + } else { + sy + }; + let start = sy as usize * w + x0; + let Some(src) = img.data.get(start..start + out_tile.len()) else { continue }; + for (a, p) in acc.iter_mut().zip(src) { + for c in 0..4 { + a[c] += p[c] as f64 * wt; + } + } + } + for (o, a) in out_tile.iter_mut().zip(acc.iter()) { + *o = a.map(|v| v as f32); } } - row[x] = acc.map(|v| v as f32); + }); + return out; + } + let n = k.len(); + out.data.par_chunks_mut(w).zip(img.data.par_chunks(w)).for_each(|(row, src)| { + for (x, o) in row.iter_mut().enumerate() { + let mut acc = [0.0f64; 4]; + // Inside the row: the taps are one slice (no per-tap edge checks). + if let Some(taps) = x.checked_sub(r as usize).and_then(|x0| src.get(x0..x0 + n)) { + for (p, wt) in taps.iter().zip(&k) { + for c in 0..4 { + acc[c] += p[c] as f64 * wt; + } + } + } else { + for (i, wt) in k.iter().enumerate() { + let sx = x as i64 + i as i64 - r; + let at = if repeat { Some(sx.clamp(0, w as i64 - 1)) } else { Some(sx) }; + let Some(p) = at.and_then(|i| usize::try_from(i).ok()).and_then(|i| src.get(i)) else { continue }; + for c in 0..4 { + acc[c] += p[c] as f64 * wt; + } + } + } + *o = acc.map(|v| v as f32); } }); out @@ -264,6 +312,53 @@ pub fn radial_blur(img: &Image, center: (f64, f64), amount: f64, zoom: bool) -> mod tests { use super::*; + /// The sampled Gaussian pass as it was (one pixel at a time, bounds-checked taps): the + /// row-wise passes must match it to the bit. + fn gauss_pass_per_pixel(img: &Image, sigma: f64, vertical: bool, repeat: bool) -> Image { + let r = (sigma * 3.0).ceil() as i64; + let mut k: Vec = (-r..=r).map(|i| (-((i * i) as f64) / (2.0 * sigma * sigma)).exp()).collect(); + let total: f64 = k.iter().sum(); + k.iter_mut().for_each(|v| *v /= total); + let mut out = Image::new(img.width, img.height); + for y in 0..img.height as i64 { + for x in 0..img.width as i64 { + let mut acc = [0.0f64; 4]; + for (i, wt) in k.iter().enumerate() { + let d = i as i64 - r; + let (sx, sy) = if vertical { (x, y + d) } else { (x + d, y) }; + let p = if repeat { img.get_clamped(sx, sy) } else { img.get(sx, sy) }; + for c in 0..4 { + acc[c] += p[c] as f64 * wt; + } + } + out.set(x as u32, y as u32, acc.map(|v| v as f32)); + } + } + out + } + + #[test] + fn gauss_pass_is_bit_identical_to_the_per_pixel_kernel() { + // Sizes narrower and shorter than the kernel too (every tap near an edge), with + // negative, zero and HDR values. + for (w, h) in [(37, 23), (3, 2), (1, 9), (64, 1)] { + let mut img = Image::new(w, h); + for (i, p) in img.data.iter_mut().enumerate() { + let v = ((i * 7919) % 101) as f32 / 37.0 - 0.6; + *p = [v, -v * 0.5, if i % 5 == 0 { 0.0 } else { v * 3.0 }, (v + 0.6).min(1.0)]; + } + for sigma in [0.4, 1.3, 2.9] { + for vertical in [false, true] { + for repeat in [false, true] { + let (a, b) = (gauss_pass(&img, sigma, vertical, repeat), gauss_pass_per_pixel(&img, sigma, vertical, repeat)); + let bits = |i: &Image| i.data.iter().flat_map(|p| p.map(f32::to_bits)).collect::>(); + assert!(bits(&a) == bits(&b), "{w}×{h} sigma {sigma} vertical {vertical} repeat {repeat}"); + } + } + } + } + } + #[test] fn gaussian_preserves_mass() { let mut img = Image::new(64, 64); From b3dc49a0cf034d46042e21aa9b4fc5878a8e20bb Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Fri, 6 Nov 2026 07:13:15 +0100 Subject: [PATCH 041/227] Render: plain footage is copied, converted and quantised in one pass A footage layer's frame was copied from the footage cache, converted from its colour profile, then quantised to the project depth in masked_source: three passes over a 33 MB 1080p float frame. Footage without masks now comes quantised from one fused pass (Pipe::copy_converted); other source() callers still get unquantised pixels. Bit-identical (tested at 8/16/32 bpc). --- crates/render/src/color.rs | 69 +++++++++++++++++++++++++++++++---- crates/render/src/pipeline.rs | 64 ++++++++++++++++++++++++-------- 2 files changed, 110 insertions(+), 23 deletions(-) diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index c423665d2..158dde42f 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -153,6 +153,26 @@ impl Pipe { p } + /// A copy of `src` converted by `c` and quantised to the bit depth, in one pass: one read and + /// one write of the frame instead of a copy, a conversion and a quantise pass (a 1080p float + /// frame is 33 MB, and these passes are limited by memory speed). The same pixels as + /// [`convert`] then [`Pipe::quantize`]. + pub fn copy_converted(&self, src: &Image, c: Option<&Conversion>) -> Image { + let levels = self.levels; + let data = src + .data + .par_iter() + .map(|&p| { + let mut p = c.map_or(p, |c| convert_px(p, c)); + if let Some(l) = levels { + quantize_px(&mut p, l, 1.0 / l); + } + p + }) + .collect(); + Image { width: src.width, height: src.height, data } + } + /// Clamp and quantise to the bit depth (no-op in 32 bpc). pub fn quantize(&self, img: &mut Image) { self.quantize_region(img, Region::Full); @@ -265,14 +285,18 @@ fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) { /// Convert premultiplied pixels (on straight colour). pub fn convert(img: &mut Image, c: &Conversion) { - img.data.par_iter_mut().for_each(|p| { - let a = p[3]; - if a <= 0.0 { - return; - } - let o = c.apply([p[0] / a, p[1] / a, p[2] / a]); - *p = [o[0] * a, o[1] * a, o[2] * a, a]; - }); + img.data.par_iter_mut().for_each(|p| *p = convert_px(*p, c)); +} + +/// [`convert`] of one pixel. +#[inline] +fn convert_px(p: [f32; 4], c: &Conversion) -> [f32; 4] { + let a = p[3]; + if a <= 0.0 { + return p; + } + let o = c.apply([p[0] / a, p[1] / a, p[2] / a]); + [o[0] * a, o[1] * a, o[2] * a, a] } /// Compand: an exponential knee from 80%, approaching 1.0 (linear light, per channel). @@ -349,6 +373,35 @@ mod tests { } } + /// The fused footage pass gives the pixels of a copy, a conversion and a quantise pass, bit + /// for bit, at every depth, with and without a conversion. + #[test] + fn copy_converted_is_convert_then_quantize() { + let mut img = Image::new(17, 5); + for (i, p) in img.data.iter_mut().enumerate() { + *p = match i % 7 { + 0 => [0.3, -0.2, 0.71, 0.9], + 1 => [-0.0, 0.0, 0.0, 0.0], + 2 => [1.7, 0.4, f32::NAN, 0.5], + 3 => [0.0; 4], + _ => [i as f32 / 90.0, 0.5, 0.25, 1.0], + }; + } + for depth in [effectcraft_project::BitDepth::Bpc8, effectcraft_project::BitDepth::Bpc16, effectcraft_project::BitDepth::Bpc32] { + let pipe = Pipe::of(&ProjectSettings { bit_depth: depth, ..Default::default() }); + for c in [None, Some(Conversion::linearize(ColorSpace::Srgb))] { + let mut want = img.clone(); + if let Some(c) = &c { + convert(&mut want, c); + } + pipe.quantize(&mut want); + let got = pipe.copy_converted(&img, c.as_ref()); + let bits = |i: &Image| i.data.iter().map(|p| p.map(f32::to_bits)).collect::>(); + assert_eq!(bits(&got), bits(&want), "{depth:?} conversion {}", c.is_some()); + } + } + } + /// Both forms `round_half_up` picks between agree on everything quantising feeds it: /// 0..=levels, exact halves included. #[test] diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 6265cb5f7..6979afc85 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -878,6 +878,12 @@ impl<'a> Renderer<'a> { /// Source pixels of a layer in layer space (before masks/effects). pub(crate) fn source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + self.source_with(ctx, layer, false) + } + + /// [`Renderer::source`]; with `quantized`, footage that [`Renderer::prequantized_footage`] + /// counts comes quantised to the project depth. + fn source_with(&self, ctx: &EvalCtx, layer: &Layer, quantized: bool) -> Option { let s = self.opts.scale; match &layer.source { LayerSource::Solid { item } => { @@ -910,7 +916,7 @@ impl<'a> Renderer<'a> { && let Some(nc) = self.project.comp(*item) { let img = self.footage_frame(ctx, layer, *item, pf, lt, None)?; - return Some(self.footage_buf(&img, pf, nc.width, nc.height)); + return Some(self.footage_buf(&img, pf, nc.width, nc.height, false)); } // Essential Properties overrides render the nested comp with this instance's // values. @@ -959,7 +965,7 @@ impl<'a> Renderer<'a> { } } let (img, pf) = self.footage_picture_of(ctx, layer, item, f)?; - Some(self.footage_buf(&img, pf, f.width, f.height)) + Some(self.footage_buf(&img, pf, f.width, f.height, quantized && Self::prequantized_footage(layer))) } // (the font files go to this render's log, which `ctx` may not carry: see // [`Renderer::with_reads`]) @@ -992,15 +998,42 @@ impl<'a> Renderer<'a> { /// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's /// nominal size; proxies are scaled up to it), converted from its colour profile. - fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf { - let mut buf = match self.footage_layout(img, w, h) { - FootageLayout::Native { scale } => Buf { img: img.clone(), offset: [0.0; 2], scale }, - FootageLayout::Resample { width, height, scale } => Buf { img: effectcraft_raster::resample(img, width, height), offset: [0.0; 2], scale }, - }; - if let Some(c) = self.pipe.media_in(f) { - color::convert(&mut buf.img, &c); + /// + /// `quantized`: also clamp and quantise it to the project depth, as [`Renderer::masked_source`] + /// would (fused with the copy of the cached frame: one pass over it instead of three). + fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32, quantized: bool) -> Buf { + let c = self.pipe.media_in(f); + match self.footage_layout(img, w, h) { + FootageLayout::Native { scale } => { + let img = match (quantized, &c) { + (true, _) => self.pipe.copy_converted(img, c.as_ref()), + (false, Some(c)) => { + let mut img = img.clone(); + color::convert(&mut img, c); + img + } + (false, None) => img.clone(), + }; + Buf { img, offset: [0.0; 2], scale } + } + FootageLayout::Resample { width, height, scale } => { + let mut img = effectcraft_raster::resample(img, width, height); + if let Some(c) = &c { + color::convert(&mut img, c); + } + if quantized { + self.pipe.quantize(&mut img); + } + Buf { img, offset: [0.0; 2], scale } + } } - buf + } + + /// The layer's source is footage that [`Renderer::source`] hands over already quantised + /// ([`Renderer::footage_buf`]): footage without masks (masks apply to unquantised pixels). + /// Collapsed layers may be vector footage, drawn elsewhere: not counted. + fn prequantized_footage(layer: &Layer) -> bool { + matches!(layer.source, LayerSource::Footage { .. }) && !layer.switches.collapse && layer.masks().is_none_or(|m| m.groups().next().is_none()) } /// How [`Renderer::footage_buf`] lays out a decoded frame `img` of footage `w`×`h` layer @@ -1159,13 +1192,14 @@ impl<'a> Renderer<'a> { /// Source → masks, clamped/quantised to the project depth. fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { - let mut buf = match self.content_times(ctx, layer) { - Some(times) => self.blurred_source(ctx, layer, ×)?, - None => self.source(ctx, layer)?, + let (mut buf, quantized) = match self.content_times(ctx, layer) { + Some(times) => (self.blurred_source(ctx, layer, ×)?, false), + None => (self.source_with(ctx, layer, true)?, Self::prequantized_footage(layer)), }; - // A solid is filled with a quantised colour: only masks can take it off the grid. + // A solid is filled with a quantised colour, and plain footage comes quantised: only + // masks can take them off the grid. let solid = matches!(layer.source, LayerSource::Solid { .. }); - if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !solid { + if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !(solid || quantized) { self.pipe.quantize(&mut buf.img); } Some(buf) From 6bb52b1cb1fec4ed661650a0d46c95d11e0165cc Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Fri, 6 Nov 2026 07:32:19 +0100 Subject: [PATCH 042/227] cache_bench: --gpu measures the GPU compositor --gpu renders on a headless GPU of its own and reads the frames back, as the desktop viewer does; --backend cpu|gpu|auto picks the compositor (default auto, the viewer's Mercury GPU Acceleration). With auto the bench prints Auto's per-comp timing history: how many frames each side rendered and how long they took. --- crates/ui-egui/examples/cache_bench.rs | 35 +++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/examples/cache_bench.rs b/crates/ui-egui/examples/cache_bench.rs index 7d57ea773..9367d9e5c 100644 --- a/crates/ui-egui/examples/cache_bench.rs +++ b/crates/ui-egui/examples/cache_bench.rs @@ -12,7 +12,10 @@ //! default 0.5: a viewer at half size), `--frames N` (frames to cache, default the whole comp), //! `--slots N` (frames in flight, at least 1, default one per frame thread as the app does when //! idle), -//! `--disk` (with a fresh disk cache in `target/cache-bench/disk`), `--no-adjustment`. +//! `--disk` (with a fresh disk cache in `target/cache-bench/disk`), `--no-adjustment`, +//! `--gpu` (composite on a GPU of its own, as the desktop app does with Mercury GPU Acceleration: +//! the GPU or the CPU, whichever Auto measures faster per comp, read back to the CPU; the viewer's +//! backend), `--backend cpu|gpu|auto` (with `--gpu`: force one compositor, default `auto`). use std::sync::Arc; use std::time::{Duration, Instant}; @@ -122,13 +125,25 @@ fn main() { let slots: usize = arg("--slots").and_then(|v| v.parse().ok()).map(|n: usize| n.max(1)).unwrap_or(frames.parallelism().max(1)); // Room for every frame: this measures speed, not eviction. frames.set_budget(64 << 30); - let opts = RenderOpts { scale, motion_blur: true, ..Default::default() }; + use effectcraft_engine::render::Backend; + let backend = match arg("--backend").as_deref() { + Some("cpu") => Backend::Cpu, + Some("gpu") => Backend::Gpu, + _ => Backend::Auto, + }; + let opts = RenderOpts { scale, motion_blur: true, backend, ..Default::default() }; + let gpu = std::env::args().any(|a| a == "--gpu").then(|| { + effectcraft_gpu::Gpu::headless().unwrap_or_else(|| { + eprintln!("--gpu: no usable GPU adapter"); + std::process::exit(2) + }) + }); let src = RenderSource { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), layer_cache: s.layer_cache.clone(), - gpu: None, + gpu, gpu_display: false, disk: s.disk_cache.clone(), }; @@ -142,7 +157,12 @@ fn main() { opts: opts_hash(&opts), }; - eprintln!("caching {n} frames of {}×{} at scale {scale} with {slots} in flight…", comp.width, comp.height); + eprintln!( + "caching {n} frames of {}×{} at scale {scale} with {slots} in flight{}…", + comp.width, + comp.height, + if src.gpu.is_some() { " on the GPU" } else { "" } + ); let (gop0, t0) = (effectcraft_media::gop_stats(), Instant::now()); let mut next = 0; loop { @@ -166,6 +186,13 @@ fn main() { gop.seeks - gop0.seeks, gop.hits - gop0.hits ); + if let Some(g) = &src.gpu + && let Some(p) = effectcraft_engine::render::Accelerator::auto_pick(g) + && let Some(a) = p.stats(effectcraft_engine::render::AutoKey::new(cid, scale, false)) + { + let ms = |v: Option| v.map_or("–".into(), |v| format!("{v:.1} ms")); + println!("auto (Mercury) GPU {} over {} frames, CPU {} over {} frames", ms(a.gpu_ms), a.gpu_frames, ms(a.cpu_ms), a.cpu_frames); + } let p = pool.stats(); println!("footage cache {} hits, {} misses, {} frames ({} MB) kept", p.hits, p.misses, p.frames, p.bytes >> 20); if let Some(dc) = &s.disk_cache { From 50a889737ef72bb77bc6d5682c32de2445d2577d Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Fri, 6 Nov 2026 08:04:53 +0100 Subject: [PATCH 043/227] Auto: frames rendered side by side no longer all warm up the GPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preview renders up to 12 frames at once, and Auto only learns a frame's time when it is done. A starting preview sent every lane to the GPU for its warm-up, and on an integrated GPU 2–4× slower than the CPU each first playback stalled. Now at most one GPU warm-up frame (and one re-probe frame per side) runs at once, a frame's time counts per frame running on its side at once, and the warm-up starts on the CPU, so the frames on screen come first. Iris Xe, 1080p: Auto went from 13–34% slower than CPU only to 0–17%. --- crates/render/src/auto.rs | 92 ++++++++++++++++++++++++++++----- crates/render/src/tests/auto.rs | 68 ++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 17 deletions(-) diff --git a/crates/render/src/auto.rs b/crates/render/src/auto.rs index 39b3b02e6..b1138588b 100644 --- a/crates/render/src/auto.rs +++ b/crates/render/src/auto.rs @@ -10,6 +10,13 @@ //! (its first frame, with layer cache misses and texture uploads, is not counted; the fastest of //! the next ones starts the average), and the slower side is re-measured every //! [`AutoPick::REPROBE`] frames so the choice follows the comp as it changes. +//! +//! Frames render several at a time (a preview's lanes), and a frame is only measured once it +//! is done: so at most one GPU warm-up frame, or re-probe frame on either side, runs at once, +//! and the frames asked for meanwhile go to the other side. And a frame's time counts per +//! frame running on its side at once: twelve CPU frames that each take 200 ms side by side cost +//! about 17 ms a frame, against a GPU frame of 175 ms on its own. Otherwise every lane of a starting preview took a +//! warm-up frame on a GPU that turned out 2–4× slower (an integrated GPU), all at once. use std::collections::HashMap; use std::sync::Mutex; @@ -51,7 +58,37 @@ pub struct AutoStats { /// accelerator, see [`Accelerator::auto_pick`](crate::Accelerator::auto_pick)). #[derive(Default)] pub struct AutoPick { - stats: Mutex>, + stats: Mutex>, +} + +/// One key's history, when its running warm-up or re-probe frame started on each side, and +/// how many of its frames are running on each side (`[CPU, GPU]`). +#[derive(Default)] +struct Entry { + stats: AutoStats, + measuring: [Option; 2], + // ponytail: a frame that never reports back (a GPU failure path, a replaced remote frame) + // leaves its side counted as running, which only makes that side look cheaper; capped at + // MAX_RUNNING, cleared by `AutoPick::reset`. + running: [u32; 2], +} + +impl Entry { + /// A warm-up or re-probe frame is running on `gpu`'s side. It counts as done once + /// [`AutoPick::MEASURING_FOR`] has passed (a frame that failed without reporting back). + fn measuring(&self, gpu: bool) -> bool { + self.measuring[usize::from(gpu)].is_some_and(|t| t.elapsed() < AutoPick::MEASURING_FOR) + } + + /// Render the next warm-up or re-probe frame on `gpu`'s side, unless one is running there: + /// then the other side's (`!gpu`). + fn measure(&mut self, gpu: bool) -> bool { + if self.measuring(gpu) { + return !gpu; + } + self.measuring[usize::from(gpu)] = Some(web_time::Instant::now()); + gpu + } } impl AutoPick { @@ -66,19 +103,35 @@ impl AutoPick { pub const MARGIN: f64 = 0.85; /// Keys remembered before the history is reset. const MAX_KEYS: usize = 1024; + /// Frames counted as running on one side at most (see `Entry::running`). + const MAX_RUNNING: u32 = 256; + /// A warm-up or re-probe frame not reported back by then no longer holds its side. + const MEASURING_FOR: std::time::Duration = std::time::Duration::from_secs(10); - /// Render the frame for `key` on the GPU? + /// Render the frame for `key` on the GPU? Every frame asked for reports back with + /// [`AutoPick::record`] (or [`AutoPick::declined`]). pub fn choose(&self, key: AutoKey) -> bool { let Ok(mut m) = self.stats.lock() else { return true }; if m.len() > Self::MAX_KEYS && !m.contains_key(&key) { m.clear(); } - let s = m.entry(key).or_default(); - // Warm up both sides, alternating, GPU first. + let e = m.entry(key).or_default(); + let gpu = Self::pick(e); + let n = &mut e.running[usize::from(gpu)]; + *n = n.saturating_add(1).min(Self::MAX_RUNNING); + gpu + } + + fn pick(e: &mut Entry) -> bool { + let s = e.stats; + // Warm up both sides, alternating, CPU first: the first frames asked for are the + // ones on screen, and the CPU is the side that is never slow to start (no pipelines + // to build; an integrated GPU took 2 s for the frame at the playhead). let gpu_warm = s.declined || s.gpu_frames >= Self::WARMUP; - if !gpu_warm && s.gpu_frames <= s.cpu_frames { - return true; + if !gpu_warm && s.gpu_frames < s.cpu_frames { + return e.measure(true); } + // (CPU frames already render side by side, on lanes of their own.) if s.cpu_frames < Self::WARMUP { return false; } @@ -86,12 +139,12 @@ impl AutoPick { (Some(g), Some(c)) => c >= g * Self::MARGIN, (g, _) => g.is_some(), }; - // Periodically re-measure the slower side. - if s.since_probe >= Self::REPROBE { - s.since_probe = 0; - return !gpu_faster; + // Periodically re-measure the slower side (one frame at a time). + if s.since_probe >= Self::REPROBE && !e.measuring(!gpu_faster) { + e.stats.since_probe = 0; + return e.measure(!gpu_faster); } - s.since_probe += 1; + e.stats.since_probe = s.since_probe.saturating_add(1); gpu_faster } @@ -103,7 +156,13 @@ impl AutoPick { /// period). pub fn record(&self, key: AutoKey, gpu: bool, ms: f64) { let Ok(mut m) = self.stats.lock() else { return }; - let s = m.entry(key).or_default(); + let e = m.entry(key).or_default(); + e.measuring[usize::from(gpu)] = None; + // Per frame running on this side alongside it (itself included). + let n = &mut e.running[usize::from(gpu)]; + let ms = ms / f64::from((*n).max(1)); + *n = n.saturating_sub(1); + let s = &mut e.stats; let (avg, frames) = if gpu { (&mut s.gpu_ms, &mut s.gpu_frames) } else { (&mut s.cpu_ms, &mut s.cpu_frames) }; *frames = frames.saturating_add(1); if gpu { @@ -123,7 +182,12 @@ impl AutoPick { /// The GPU did not handle a frame for `key` (it rendered on the CPU instead). pub fn declined(&self, key: AutoKey) { if let Ok(mut m) = self.stats.lock() { - let s = m.entry(key).or_default(); + let e = m.entry(key).or_default(); + e.measuring[1] = None; + // The frame renders on the CPU instead (and reports back from there). + e.running[1] = e.running[1].saturating_sub(1); + e.running[0] = e.running[0].saturating_add(1).min(Self::MAX_RUNNING); + let s = &mut e.stats; s.declined = true; s.gpu_ms = None; s.since_probe = 0; @@ -132,7 +196,7 @@ impl AutoPick { /// The history of `key`. pub fn stats(&self, key: AutoKey) -> Option { - self.stats.lock().ok()?.get(&key).copied() + self.stats.lock().ok()?.get(&key).map(|e| e.stats) } /// Forget all history (settings that change both paths' costs). diff --git a/crates/render/src/tests/auto.rs b/crates/render/src/tests/auto.rs index 5cc503173..e7387eed3 100644 --- a/crates/render/src/tests/auto.rs +++ b/crates/render/src/tests/auto.rs @@ -229,6 +229,12 @@ fn healthy_gpu_decline_renders_cpu_once_outside_scope() { let src = ScopedFootage { in_scope: false.into(), reads: 0.into() }; let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Auto, ..Default::default() }); r.accel = Some(&a); + // Auto warms up CPU first: the first frame renders on the CPU without a GPU scope. + r.comp_frame_scoped(cid, Tick::ZERO, |_| -> Result, ()> { panic!("Auto's CPU warm-up frame must not enter GPU error scopes") }) + .unwrap(); + assert_eq!(src.reads.load(Ordering::SeqCst), 1); + assert_eq!(a.calls(), 0); + // The next one is the GPU's warm-up frame, which the accelerator declines. let frame = r .comp_frame_scoped(cid, Tick::ZERO, |a| { src.in_scope.store(true, Ordering::SeqCst); @@ -238,11 +244,67 @@ fn healthy_gpu_decline_renders_cpu_once_outside_scope() { }) .unwrap(); assert!(frame.get(100, 50)[3] > 0.99); - assert_eq!(src.reads.load(Ordering::SeqCst), 1); + assert_eq!(src.reads.load(Ordering::SeqCst), 2); assert_eq!(a.calls(), 1); - assert_eq!(a.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap().cpu_frames, 1); + assert_eq!(a.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap().cpu_frames, 2); // Auto's next frame stays on CPU, without entering a GPU scope at all. r.comp_frame_scoped(cid, Tick::ZERO, |_| -> Result, ()> { panic!("Auto CPU choice must not enter GPU error scopes") }).unwrap(); - assert_eq!(src.reads.load(Ordering::SeqCst), 2); + assert_eq!(src.reads.load(Ordering::SeqCst), 3); assert_eq!(a.effect_calls.load(Ordering::SeqCst), 0, "CPU effects must not use the GPU outside its scope"); } + +/// A preview starts with every lane asking at once, before any frame is measured: the first +/// frames (the ones on screen) render on the CPU; once one is measured, one lane takes a GPU +/// warm-up frame, and the next waits for its time. (Every lane took a GPU warm-up frame on an +/// integrated GPU 2–4× slower than the CPU, and each first playback stalled at its start.) +#[test] +fn auto_pick_warms_the_gpu_up_one_frame_at_a_time() { + let pick = AutoPick::default(); + let key = AutoKey::new(ItemId(3), 1.0, false); + let first: Vec = (0..12).map(|_| pick.choose(key)).collect(); + assert!(!first.iter().any(|g| *g), "{first:?}"); + pick.record(key, false, 10.0); + let next: Vec = (0..12).map(|_| pick.choose(key)).collect(); + assert_eq!(next.iter().filter(|g| **g).count(), 1, "{next:?}"); + // The CPU frames finish first: still no second GPU frame while the first runs. + for _ in 0..22 { + pick.record(key, false, 10.0); + } + assert!(!(0..12).any(|_| pick.choose(key)), "a second GPU warm-up frame while one runs"); + // Measured: the next warm-up frame may go to the GPU. + pick.record(key, true, 40.0); + assert!(pick.choose(key)); + // A re-probe of the slow side runs one frame at a time too. + let pick = AutoPick::default(); + let mut gpu_asked = 0; + for _ in 0..(4 * AutoPick::REPROBE) { + // (sequential warm-up, then GPU frames held: as lanes that never report back) + if pick.choose(key) { + gpu_asked += 1; + if pick.stats(key).is_none_or(|s| s.gpu_frames < AutoPick::WARMUP) { + pick.record(key, true, 40.0); + } + } else { + pick.record(key, false, 10.0); + } + } + assert!(gpu_asked <= AutoPick::WARMUP as usize + 1, "asked the GPU {gpu_asked} times"); +} + +/// Frames render side by side: twelve CPU frames of 210 ms each at once cost ~18 ms a frame, +/// so a lone GPU frame of 175 ms is the slower side (it measured faster and took the comp). +#[test] +fn auto_pick_counts_frames_running_side_by_side() { + let pick = AutoPick::default(); + let key = AutoKey::new(ItemId(4), 0.5, false); + // Twelve lanes: each starts its next frame as soon as one is done. + let mut lanes: std::collections::VecDeque = (0..12).map(|_| pick.choose(key)).collect(); + for _ in 0..240 { + let Some(gpu) = lanes.pop_front() else { break }; + pick.record(key, gpu, if gpu { 175.0 } else { 210.0 }); + lanes.push_back(pick.choose(key)); + } + let s = pick.stats(key).unwrap(); + assert!(s.cpu_ms.unwrap() < 30.0 && s.gpu_ms.unwrap() > 100.0, "{s:?}"); + assert!(lanes.iter().filter(|g| **g).count() <= 1, "{lanes:?}"); +} From 76e4008151759606b942f68cf3c19c96dd1e2498 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Fri, 6 Nov 2026 08:43:57 +0100 Subject: [PATCH 044/227] Footage: 8 bpc renders keep plain movie frames as 8-bit levels An 8 bpc render at Full quantises plain footage straight away, so the media pool now keeps those movie frames as the 8-bit levels it is quantised to (a quarter of the memory) and the renderer expands them into the layer buffer: the same pixels to the bit. Other depths, reduced renders, masks, colour profiles, fields, frame blending, Cineon, Invert Alpha, linear light and 10/12-bit or float movies keep the float path. First playback of plain 1080p clips at Full caches ~43% faster on the CPU, and the footage cache holds 4x the frames. --- crates/automation/src/confine.rs | 4 + crates/media/src/convert.rs | 103 ++++++++++++++++--- crates/media/src/pool.rs | 130 +++++++++++++++++++----- crates/raster/src/lib.rs | 46 ++++++++- crates/render/src/footage.rs | 11 ++ crates/render/src/pipeline.rs | 33 ++++++ crates/render/src/tests/footage_size.rs | 51 ++++++++++ 7 files changed, 334 insertions(+), 44 deletions(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index a9fc4665f..371085938 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -388,6 +388,10 @@ impl FootageSource for ConfinedFootage { fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { self.readable(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten() } + /// The frame as 8-bit levels, or `None` when the footage lies outside the allowed roots. + fn frame8(&self, item: ItemId, footage: &Footage, t: Tick, float: FloatCurve) -> Option> { + self.readable(footage).then(|| self.inner.frame8(item, footage, t, float)).flatten() + } /// The footage audio from the inner source, or `None` when the footage lies outside the allowed roots. fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { self.readable(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten() diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index a2b24b3ae..86fb088f9 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -7,8 +7,9 @@ //! are encoded in the file. Alpha follows the footage's [`AlphaMode`] interpretation. use effectcraft_project::{AlphaMode, Footage}; -use effectcraft_raster::{Image, Px}; +use effectcraft_raster::{Image, Px, quantize_px_u8}; use effectcraft_render::FloatCurve; +use effectcraft_render::disk_cache::Frame8; use filmcraft_color::{Matrix, Range}; use filmcraft_frame::{Chroma, PixelData, VideoFrame}; use rayon::prelude::*; @@ -80,14 +81,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - return img; } match &f.data { - PixelData::Rgba8(d) => { - img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { - let src = &d[y * w * 4..(y + 1) * w * 4]; - for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) { - *o = op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize]); - } - }); - } + PixelData::Rgba8(d) => convert_rgba8(&mut img.data, d, w, op, |p| p), PixelData::RgbaF32(d) => { // linear premultiplied → encoded premultiplied img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { @@ -104,7 +98,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - let yuv = Yuv::new(f.color.matrix, f.color.range, 8); let amax = 255.0; convert_yuv( - &mut img, + &mut img.data, YuvSource { size: (w, h), chroma: *chroma, @@ -114,13 +108,14 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - }, &yuv, op, + |p| p, ); } PixelData::Yuv16 { planes, chroma, bits, alpha } => { let yuv = Yuv::new(f.color.matrix, f.color.range, *bits); let amax = ((1u32 << *bits) - 1) as f32; convert_yuv( - &mut img, + &mut img.data, YuvSource { size: (w, h), chroma: *chroma, @@ -130,12 +125,52 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - }, &yuv, op, + |p| p, ); } } img } +/// An 8-bit frame as [`frame_to_image_in`] converts it and an 8 bpc render then quantises it +/// ([`effectcraft_raster::quantize_px`]), kept as the 8-bit levels: expanded with +/// [`effectcraft_raster::expand_u8`] it is that quantised image to the bit, in a quarter of +/// the memory. `None` for frames with more than 8 bits (10/12-bit Y'CbCr, float). +pub(crate) fn frame_to_rgba8(f: &VideoFrame, op: AlphaOp) -> Option { + let (w, h) = (f.width as usize, f.height as usize); + let mut px = vec![[0u8; 4]; w * h]; + if w > 0 && h > 0 { + match &f.data { + PixelData::Rgba8(d) => convert_rgba8(&mut px, d, w, op, quantize_px_u8), + PixelData::Yuv8 { planes, chroma, alpha } => convert_yuv( + &mut px, + YuvSource { + size: (w, h), + chroma: *chroma, + planes: [&planes[0][..], &planes[1][..], &planes[2][..]], + alpha: alpha.as_deref().map(|a| &a[..]), + amax: 255.0, + }, + &Yuv::new(f.color.matrix, f.color.range, 8), + op, + quantize_px_u8, + ), + PixelData::RgbaF32(_) | PixelData::Yuv16 { .. } => return None, + } + } + Some(Frame8 { width: f.width, height: f.height, rgba: px.into_flattened() }) +} + +/// Straight or premultiplied 8-bit RGBA into `out` (one `O` per pixel, made by `store`). +fn convert_rgba8(out: &mut [O], d: &[u8], w: usize, op: AlphaOp, store: impl Fn(Px) -> O + Sync) { + out.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let Some(src) = d.get(y * w * 4..(y + 1) * w * 4) else { return }; + for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) { + *o = store(op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize])); + } + }); +} + /// A movie frame turned the way its file says it is shown ([`crate::probe::video_turn`]: /// clockwise quarter turns; anything else leaves it as decoded). pub(crate) fn turned(img: Image, turn: image::metadata::Orientation) -> Image { @@ -311,13 +346,14 @@ struct YuvSource<'a, T> { amax: f32, } -fn convert_yuv(img: &mut Image, source: YuvSource<'_, T>, k: &Yuv, op: AlphaOp) { +/// Y'CbCr into `out` (one `O` per pixel, made by `store` from the converted pixel). +fn convert_yuv(out: &mut [O], source: YuvSource<'_, T>, k: &Yuv, op: AlphaOp, store: impl Fn(Px) -> O + Sync) { let YuvSource { size: (w, h), chroma, planes, alpha, amax } = source; let (sx, sy) = chroma.shifts(); let cw = w.div_ceil(1 << sx); let ch = h.div_ceil(1 << sy); let inv_amax = 1.0 / amax; - img.data.par_chunks_mut(w).enumerate().for_each_init( + out.par_chunks_mut(w).enumerate().for_each_init( || (vec![0f32; cw], vec![0f32; cw], vec![0f32; w], vec![0f32; w]), |(uc, vc, uf, vf), (y, row)| { // vertical chroma interpolation into normalised rows (Cb, Cr in −0.5..0.5) @@ -357,13 +393,13 @@ fn convert_yuv(img: &mut Image, source: YuvSource<'_, T>, k: &Yuv, op None => { for (x, o) in row.iter_mut().enumerate() { let c = rgb(x); - *o = [c[0], c[1], c[2], 1.0]; + *o = store([c[0], c[1], c[2], 1.0]); } } Some(al) => { let arow = &al[y * w..y * w + w]; for (x, o) in row.iter_mut().enumerate() { - *o = op.apply(rgb(x), (arow[x].f() * inv_amax).clamp(0.0, 1.0)); + *o = store(op.apply(rgb(x), (arow[x].f() * inv_amax).clamp(0.0, 1.0))); } } } @@ -552,6 +588,43 @@ mod tests { assert!((p[0] - 0.5).abs() < 1e-3 && (p[1] - 1.0).abs() < 1e-5 && p[2] > 1.5, "{p:?}"); } + /// The 8-bit levels of a frame expand to the float frame quantised to 8 bpc, bit for bit: + /// 4:2:0 and 4:4:4 Y'CbCr with and without alpha, and RGBA, under every alpha interpretation. + #[test] + fn rgba8_levels_are_the_quantised_float_frame() { + let (w, h) = (37usize, 18usize); + let noise = |n: usize, seed: usize| Arc::new((0..n).map(|i| ((i * 7919 + seed * 104_729) % 256) as u8).collect::>()); + let frame = |data| VideoFrame { + width: w as u32, + height: h as u32, + data, + color: filmcraft_color::ColorInfo::REC709, + par: (1, 1), + pts: filmcraft_time::Tick::ZERO, + }; + let yuv = |chroma: Chroma, alpha: bool| { + let n = w.div_ceil(1 << chroma.shifts().0) * h.div_ceil(1 << chroma.shifts().1); + frame(PixelData::Yuv8 { planes: [noise(w * h, 1), noise(n, 2), noise(n, 3)], chroma, alpha: alpha.then(|| noise(w * h, 4)) }) + }; + let frames = [yuv(Chroma::C420, false), yuv(Chroma::C420, true), yuv(Chroma::C444, true), frame(PixelData::Rgba8(noise(w * h * 4, 5)))]; + for f in &frames { + for mode in [AlphaMode::Straight, AlphaMode::Premultiplied, AlphaMode::Ignore] { + let op = AlphaOp::new(mode, [0.2, 0.5, 0.9]); + let q8 = frame_to_rgba8(f, op).expect("8-bit"); + let mut want = frame_to_image(f, op); + for p in &mut want.data { + effectcraft_raster::quantize_px(p, 255.0, 1.0 / 255.0); + } + assert_eq!((q8.width, q8.height), (want.width, want.height)); + for (got, want) in q8.rgba.as_chunks::<4>().0.iter().zip(&want.data) { + assert_eq!(effectcraft_raster::expand_u8(*got).map(f32::to_bits), want.map(f32::to_bits)); + } + } + } + let wide = frame(PixelData::RgbaF32(Arc::new(vec![0.5; w * h * 4]))); + assert!(frame_to_rgba8(&wide, AlphaOp::new(AlphaMode::Straight, [0.0; 3])).is_none()); + } + /// `cargo test -p effectcraft-media --lib bench -- --ignored --nocapture` #[test] #[ignore] diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index cd6676220..e35ff37fc 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -6,13 +6,14 @@ use std::sync::{Arc, Condvar, Mutex, OnceLock}; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId}; use effectcraft_raster::{Image, Px}; +use effectcraft_render::disk_cache::Frame8; use effectcraft_render::reads::{FileStamp, ReadFile, ReadFiles, ReadLog}; use effectcraft_render::{FloatCurve, FootageSource}; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; 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::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, frame_to_rgba8, turned}; use crate::downmix::Order; use crate::gif_anim::{self, Anim}; use crate::reads; @@ -53,10 +54,28 @@ struct Key { /// resampled (see `Inner::still_at_size`); also at its own size for a placed PDF / /// Illustrator page, which is drawn to fill it ([`Footage::placed_page`]); (0, 0) otherwise. nominal: (u32, u32), + /// Kept as 8-bit levels ([`FootageSource::frame8`]), not float. + q8: bool, +} + +/// A cached frame's pixels: float, or the 8-bit levels an 8 bpc render quantises it to. +#[derive(Clone)] +enum Pixels { + F32(Arc), + Q8(Arc), +} + +impl Pixels { + fn bytes(&self) -> usize { + match self { + Pixels::F32(img) => img.data.len() * std::mem::size_of::(), + Pixels::Q8(f) => f.rgba.len(), + } + } } struct Entry { - img: Arc, + img: Pixels, /// The files the frame was decoded from (see [`crate::reads`]). files: Arc, stamp: u64, @@ -112,8 +131,8 @@ struct Lru { } impl Lru { - /// The cached image and its read files for `k`, marking the entry as most recently used. - fn get(&mut self, k: &Key) -> Option<(Arc, Arc)> { + /// The cached frame and its read files for `k`, marking the entry as most recently used. + fn get(&mut self, k: &Key) -> Option<(Pixels, Arc)> { let e = self.map.get_mut(k)?; self.order.remove(&e.stamp); self.clock += 1; @@ -121,9 +140,9 @@ impl Lru { self.order.insert(e.stamp, k.clone()); Some((e.img.clone(), e.files.clone())) } - /// Stores an image with its read files under `k` and evicts the least recently used entries until the cache fits `budget` bytes. - fn insert(&mut self, k: Key, img: Arc, files: Arc, budget: usize) { - let bytes = img.data.len() * std::mem::size_of::() + 64; + /// Stores a frame with its read files under `k` and evicts the least recently used entries until the cache fits `budget` bytes. + fn insert(&mut self, k: Key, img: Pixels, files: Arc, budget: usize) { + let bytes = img.bytes() + 64; self.clock += 1; if let Some(old) = self.map.insert(k.clone(), Entry { img, files, stamp: self.clock, bytes }) { self.order.remove(&old.stamp); @@ -145,8 +164,10 @@ impl Lru { let Some((_, k)) = self.order.pop_first() else { break }; if let Some(e) = self.map.remove(&k) { self.bytes -= e.bytes; + // ponytail: float buffers only; add 8-bit spares if their allocation shows up in a profile if self.spare.len() < MAX_SPARE - && let Ok(img) = Arc::try_unwrap(e.img) + && let Pixels::F32(img) = e.img + && let Ok(img) = Arc::try_unwrap(img) { self.spare.push(img.data); } @@ -185,6 +206,8 @@ struct CacheState { inflight: HashSet, /// Last frame index requested per movie (sequential-playback detection). last: HashMap, i64>, + /// Movies whose frames can't be kept as 8-bit levels (more bits, or shown turned). + wide: HashSet>, } /// A movie/audio source by path ([`Opened`]), opened by the first caller while the others wait @@ -407,6 +430,7 @@ impl MediaPool { c.lru.remove(&k); } c.last.remove(path); + c.wide.remove(path); drop(c); crate::exr_channels::forget(path); } @@ -422,6 +446,21 @@ impl MediaPool { Inner::frame_at(&self.inner, footage, t, true, None, FloatCurve::Srgb) } + /// [`FootageSource::frame8`]: `None` (the renderer then asks for the float frame) for + /// anything but an 8-bit movie frame shown as decoded, and on errors. + fn frame8_of(&self, footage: &Footage, t: Tick, float: FloatCurve) -> Option> { + if footage.missing || footage.kind != FootageKind::Video || gif_anim::is_animated(footage) { + return None; + } + if lock(&self.inner.cache).wide.contains(footage.path.as_str()) { + return None; + } + match Inner::frame_px(&self.inner, footage, t, true, None, float, true) { + Ok(Pixels::Q8(f)) => Some(f), + _ => None, + } + } + /// [`FootageSource::frame`] / [`FootageSource::frame_at_size`]: `None` (logged) on errors. fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { if footage.missing { @@ -775,7 +814,7 @@ impl Inner { } << 2) | (u8::from(footage.placed_page) << 4); let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] }; - let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0) }; + let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0), q8: false }; match footage.kind { FootageKind::Sequence if !footage.sequence.is_empty() => { let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count); @@ -811,10 +850,22 @@ impl Inner { /// The frame of `footage` at `t`, resampled to `size` (see [`FootageSource::frame_at_size`]): /// a movie's made at that size directly, a still's resampled once and kept at that size. fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result> { + match Self::frame_px(self, footage, t, playback, size, float, false)? { + Pixels::F32(img) => Ok(img), + // (a float request's key never holds 8-bit levels) + Pixels::Q8(_) => Err(MediaError::Decode(format!("{}: 8-bit frame for a float request", footage.path))), + } + } + + /// [`Self::frame_at`], kept as 8-bit levels when `q8` is set and the frame has 8 bits (a + /// float frame under the float key otherwise, and the movie is remembered as `wide`). + #[allow(clippy::too_many_arguments)] + fn frame_px(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve, q8: bool) -> Result { if !footage.has_video { return Err(MediaError::Unsupported(format!("{}: no video", footage.path))); } let mut loc = Self::locate(footage, t, float); + loc.key.q8 = q8; let movie = loc.media_t.is_some(); // A still asked for at its own size is the unsized frame (one decode, one entry). if let Some(size) = size.filter(|s| movie || *s != (footage.width, footage.height)) { @@ -854,7 +905,7 @@ impl Inner { drop(c); let (img, files) = reads::captured(|| self.decode(&loc, footage)); reads::note_all(&files); - return Ok(Arc::new(img?)); + return img; } c = self.done.wait(c).unwrap_or_else(std::sync::PoisonError::into_inner); } @@ -864,8 +915,14 @@ impl Inner { let _guard = Inflight { inner: self, key: loc.key.clone() }; let (img, files) = reads::captured(|| self.decode(&loc, footage)); reads::note_all(&files); - let img = Arc::new(img?); - lock(&self.cache).lru.insert(loc.key, img.clone(), Arc::new(files), self.budget()); + let img = img?; + let mut c = lock(&self.cache); + if q8 && let Pixels::F32(_) = img { + // not 8-bit: kept as the float frame it is, which the renderer asks for next + c.wide.insert(loc.key.path.clone()); + loc.key.q8 = false; + } + c.lru.insert(loc.key, img.clone(), Arc::new(files), self.budget()); Ok(img) } @@ -876,24 +933,25 @@ impl Inner { let next_t = t + footage.frame_rate.frame_duration(); let mut next = Self::locate(footage, next_t, float); next.key.size = loc.key.size; + next.key.q8 = loc.key.q8; if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) { - self.prefetch(footage, next_t, size, float); + self.prefetch(footage, next_t, size, float, loc.key.q8); } } } #[cfg(not(target_arch = "wasm32"))] - fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) { + fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve, q8: bool) { let (me, f) = (self.clone(), footage.clone()); self.prefetched.fetch_add(1, Ordering::Relaxed); // A plain thread, not a rayon job: the decode below parallelises with rayon itself. std::thread::spawn(move || { - let _ = me.frame_at(&f, t, false, size, float); + let _ = me.frame_px(&f, t, false, size, float, q8); }); } #[cfg(target_arch = "wasm32")] - fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {} + fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve, _q8: bool) {} /// A still (image file or sequence frame) at `loc.key.size` when one is set: resampled as the /// renderer would ([`FootageSource::frame_at_size`]), so the pool keeps the reduced frame and a @@ -910,9 +968,9 @@ impl Inner { effectcraft_raster::resample(&img, w, h) } - fn decode(&self, loc: &Loc, footage: &Footage) -> Result { + fn decode(&self, loc: &Loc, footage: &Footage) -> Result { match loc.media_t { - None => self.decode_still(loc, footage).map(|img| Self::still_at_size(img, loc, footage)), + None => self.decode_still(loc, footage).map(|img| Pixels::F32(Arc::new(Self::still_at_size(img, loc, footage)))), Some(_) => self.decode_media(loc, footage), } } @@ -959,15 +1017,16 @@ impl Inner { } } - fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result { + fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result { let op = AlphaOp::of(footage, loc.float); let path = &loc.key.path; + let f32 = |img| Pixels::F32(Arc::new(img)); match loc.media_t { - None => self.decode_still(loc, footage), + None => self.decode_still(loc, footage).map(f32), Some(mt) if gif_anim::is_animated(footage) => { let anim = self.gif(path)?; let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?; - Ok(dynamic_to_image(frame, op)) + Ok(f32(dynamic_to_image(frame, op))) } Some(mt) => { let Opened { src, turn, .. } = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; @@ -985,10 +1044,16 @@ impl Inner { && (vf.width, vf.height) == side((footage.width, footage.height)) && let Some(img) = frame_to_image_scaled(&vf, w, h) { - return Ok(turned(img, turn)); + return Ok(f32(turned(img, turn))); + } + if loc.key.q8 + && turn == Orientation::NoTransforms + && let Some(f) = frame_to_rgba8(&vf, op) + { + return Ok(Pixels::Q8(Arc::new(f))); } let buf = lock(&self.cache).lru.take_spare(vf.width as usize * vf.height as usize); - Ok(turned(frame_to_image_in(&vf, op, buf), turn)) + Ok(f32(turned(frame_to_image_in(&vf, op, buf), turn))) } } } @@ -1038,6 +1103,9 @@ impl FootageSource for MediaPool { fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { self.frame_sized(item, footage, t, size, float) } + fn frame8(&self, _item: ItemId, footage: &Footage, t: Tick, float: FloatCurve) -> Option> { + self.frame8_of(footage, t, float) + } fn problem(&self, footage: &Footage) -> Option { if footage.missing || footage.kind != FootageKind::Video { @@ -1275,8 +1343,8 @@ mod tests { #[test] fn lru_respects_budget_and_recycles() { let mut c = Lru::default(); - let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead - let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0) }; + let img = || Pixels::F32(Arc::new(Image::new(16, 16))); // 4 KiB + overhead + let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0), q8: false }; let budget = 3 * (4096 + 64); for i in 0..10 { c.insert(k(i), img(), Arc::default(), budget); @@ -1292,6 +1360,18 @@ mod tests { assert!(c.take_spare(17).is_empty()); } + /// A frame kept as 8-bit levels is its own entry beside the float one, counted at 4 bytes + /// a pixel. + #[test] + fn lru_keeps_8_bit_frames_apart_at_a_quarter_of_the_bytes() { + let mut c = Lru::default(); + let k = |q8| Key { path: "a".into(), frame: 0, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0), q8 }; + c.insert(k(false), Pixels::F32(Arc::new(Image::new(16, 16))), Arc::default(), usize::MAX); + c.insert(k(true), Pixels::Q8(Arc::new(Frame8 { width: 16, height: 16, rgba: vec![0; 16 * 16 * 4] })), Arc::default(), usize::MAX); + assert!(matches!(c.get(&k(false)), Some((Pixels::F32(_), _))) && matches!(c.get(&k(true)), Some((Pixels::Q8(_), _)))); + assert_eq!(c.bytes, (16 * 16 * 16 + 64) + (16 * 16 * 4 + 64)); + } + /// #482: an OpenEXR file with HTJ2K compression (which the decoder lacks) says so on import /// and when its frame is read, instead of "no non-deep rgb channels" or a transparent frame. #[test] diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index fd2fd183b..e18a24c63 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -31,15 +31,36 @@ pub const MAX_PADDED_SIDE: u32 = 30_000; /// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up to `levels` steps (`inv` = 1 / levels): /// one pixel of an 8/16 bpc buffer. Values are non-negative after the clamp, so the integer /// conversion rounds like `round()` and vectorises. -#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN #[inline(always)] pub fn quantize_px(p: &mut Px, levels: f32, inv: f32) { - let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv; + *p = quantize_levels(*p, levels, inv).map(|n| n * inv); +} + +/// [`quantize_px`]'s steps: the whole number of `levels` each channel rounds to (0..=levels). +#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN +#[inline(always)] +pub fn quantize_levels(p: Px, levels: f32, inv: f32) -> [f32; 4] { + let na = round_half_up(p[3].max(0.0).min(1.0) * levels); + let a = na * inv; let hi = [a, a, a, a]; + let mut n = [0.0, 0.0, 0.0, na]; for c in 0..3 { - p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv; + n[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels); } - p[3] = a; + n +} + +/// [`quantize_px`] at 8 bpc, as bytes: [`expand_u8`] gives back exactly the pixel +/// [`quantize_px`] writes, so an 8 bpc buffer can be kept in a quarter of the memory. +#[inline(always)] +pub fn quantize_px_u8(p: Px) -> [u8; 4] { + quantize_levels(p, 255.0, 1.0 / 255.0).map(|n| n as u8) +} + +/// The 8 bpc pixel of [`quantize_px_u8`]'s bytes. +#[inline(always)] +pub fn expand_u8(n: [u8; 4]) -> Px { + n.map(|v| f32::from(v) * (1.0 / 255.0)) } /// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's @@ -403,6 +424,23 @@ impl Mask { mod tests { use super::*; + /// Pixels kept as `quantize_px_u8` bytes expand to the 8 bpc pixel `quantize_px` writes, bit + /// for bit: every level, exact halves, out-of-range, negative, NaN and premultiplied colour + /// brighter than its alpha. + #[test] + fn u8_levels_expand_to_the_quantised_pixel() { + let mut values: Vec = (0..=255 * 4).map(|i| i as f32 / (255.0 * 4.0)).collect(); + values.extend([-0.0, -1.0, 1.0, 1.5, f32::NAN, f32::INFINITY, 0.5 / 255.0, 254.5 / 255.0]); + for &a in &[1.0, 0.5, 0.2, 0.0, 2.0, f32::NAN] { + for &v in &values { + let p = [v, 1.0 - v, v * 0.5, a]; + let mut want = p; + quantize_px(&mut want, 255.0, 1.0 / 255.0); + assert_eq!(expand_u8(quantize_px_u8(p)).map(f32::to_bits), want.map(f32::to_bits), "{p:?}"); + } + } + } + #[test] fn dither_keeps_exact_codes_and_averages_the_rest() { // A value on an 8-bit code is that code at every position. diff --git a/crates/render/src/footage.rs b/crates/render/src/footage.rs index 6b7fdcbf8..c14961d0f 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -37,6 +37,14 @@ pub trait FootageSource: Send + Sync { None => self.frame(item, footage, t), } } + /// [`FootageSource::frame_with`]'s full-size frame as 8-bit levels: each pixel is + /// [`effectcraft_raster::quantize_px_u8`] of the float pixel `frame_with` would give, so + /// [`effectcraft_raster::expand_u8`] makes that frame quantised to 8 bpc, bit for bit, from a + /// quarter of the memory (an 8 bpc render of 8-bit footage). `None` when the source has no + /// cheaper way than the float frame; the renderer then takes that. + fn frame8(&self, _item: ItemId, _footage: &Footage, _t: Tick, _float: FloatCurve) -> Option> { + None + } /// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at /// `rate` Hz (`None` when unavailable). The first sample is `t.to_units_floor(rate)`; /// a sample boundary uses the earliest representable tick that selects that sample. @@ -215,6 +223,9 @@ impl FootageSource for CancellableFootage<'_> { fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { self.read(|| self.inner.frame_with(item, footage, t, size, float)) } + fn frame8(&self, item: ItemId, footage: &Footage, t: Tick, float: FloatCurve) -> Option> { + self.read(|| self.inner.frame8(item, footage, t, float)) + } } /// Footage that can be rasterised at any scale: SVG, PDF, Illustrator and EPS files, and a diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 6979afc85..fd46260c2 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -964,6 +964,10 @@ impl<'a> Renderer<'a> { return Some(buf); } } + // 8 bpc plain footage: made from the footage's 8-bit levels, no float frame. + if quantized && let Some(buf) = self.footage_buf8(ctx, layer, item, self.proxy_for(item).unwrap_or(f), f, ctx.source_time(layer)) { + return Some(buf); + } let (img, pf) = self.footage_picture_of(ctx, layer, item, f)?; Some(self.footage_buf(&img, pf, f.width, f.height, quantized && Self::prequantized_footage(layer))) } @@ -1029,6 +1033,35 @@ impl<'a> Renderer<'a> { } } + /// [`Renderer::footage_buf`]'s quantised buffer made from the footage's 8-bit levels + /// ([`FootageSource::frame8`]), the same pixels to the bit, when nothing before the quantise + /// changes them: an 8 bpc render at Full size of plain footage (no masks, colour profile, + /// Separate Fields, Frame Blending, Cineon, Invert Alpha or linear light interpretation). + /// `None` otherwise, and when the source has no 8-bit frame. + #[allow(clippy::too_many_arguments)] + fn footage_buf8(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, pf: &Footage, f: &Footage, t: Tick) -> Option { + let plain = self.pipe.levels == Some(255.0) + && Self::prequantized_footage(layer) + && self.reduced_size(f.width, f.height).is_none() + && self.pipe.media_in(pf).is_none() + && pf.fields == effectcraft_project::FieldOrder::Off + && !(pf.invert_alpha || pf.linear_light_applies() || pf.cineon_applies()) + && frame_blend_mode(ctx, layer) == FrameBlend::Off; + if !plain { + return None; + } + let fr = self.footage.frame8(item, pf, t, self.pipe.float_curve(pf))?; + let n = (fr.width as usize).checked_mul(fr.height as usize)?; + let px = fr.rgba.as_chunks::<4>().0; + if px.len() != n { + return None; + } + use rayon::prelude::*; + let data = px.par_iter().map(|&p| effectcraft_raster::expand_u8(p)).collect(); + let k = if fr.width > 0 { f.width.max(1) as f64 / fr.width as f64 } else { 1.0 }; + Some(Buf { img: Image { width: fr.width, height: fr.height, data }, offset: [0.0; 2], scale: 1.0 / k }) + } + /// The layer's source is footage that [`Renderer::source`] hands over already quantised /// ([`Renderer::footage_buf`]): footage without masks (masks apply to unquantised pixels). /// Collapsed layers may be vector footage, drawn elsewhere: not counted. diff --git a/crates/render/src/tests/footage_size.rs b/crates/render/src/tests/footage_size.rs index 065941d8a..25c1a1814 100644 --- a/crates/render/src/tests/footage_size.rs +++ b/crates/render/src/tests/footage_size.rs @@ -106,3 +106,54 @@ fn still_layers_are_cached_across_times_and_movies_are_not() { assert_eq!(src.sizes.unwrap().into_inner().unwrap().len(), fetches + 3, "{kind:?}"); } } + +/// [`Clip`] that also keeps its frames as 8-bit levels, counting the times it's asked for them. +struct Clip8 { + asked: std::sync::atomic::AtomicUsize, +} + +impl FootageSource for Clip8 { + fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option> { + let mut img = (*Clip { sizes: None }.frame(item, f, t)?).clone(); + // (partly transparent, premultiplied, as decoded with alpha) + img.data.iter_mut().enumerate().for_each(|(i, p)| { + let a = (i % 7) as f32 / 6.0; + *p = [p[0] * a, p[1] * a, p[2] * a, a]; + }); + Some(Arc::new(img)) + } + fn frame8(&self, item: ItemId, f: &Footage, t: Tick, _: crate::FloatCurve) -> Option> { + self.asked.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let img = self.frame(item, f, t)?; + let rgba = img.data.iter().flat_map(|p| effectcraft_raster::quantize_px_u8(*p)).collect(); + Some(Arc::new(crate::disk_cache::Frame8 { width: img.width, height: img.height, rgba })) + } +} + +/// An 8 bpc render at Full takes plain footage as 8-bit levels and renders the same pixels to +/// the bit; other depths, reduced renders and interpreted pixels never ask for them. +#[test] +fn footage_kept_as_8_bit_levels_renders_the_same_pixels() { + struct Float(Clip8); + impl FootageSource for Float { + fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option> { + self.0.frame(item, f, t) + } + } + for (depth, invert, scale, used) in [ + (BitDepth::Bpc8, false, 1.0, true), + (BitDepth::Bpc8, false, 0.75, true), + (BitDepth::Bpc8, false, 0.5, false), + (BitDepth::Bpc8, true, 1.0, false), + (BitDepth::Bpc16, false, 1.0, false), + (BitDepth::Bpc32, false, 1.0, false), + ] { + let (p, cid) = project(depth, invert); + let opts = RenderOpts { scale, ..Default::default() }; + let q8 = Clip8 { asked: Default::default() }; + let frame = Renderer::new(&p, &q8, opts).comp_frame(cid, Tick::ZERO); + let plain = Renderer::new(&p, &Float(Clip8 { asked: Default::default() }), opts).comp_frame(cid, Tick::ZERO); + assert!(frame.data.iter().zip(&plain.data).all(|(a, b)| a.map(f32::to_bits) == b.map(f32::to_bits)), "{depth:?} {invert} {scale}"); + assert_eq!(q8.asked.into_inner() > 0, used, "{depth:?} {invert} {scale}"); + } +} From cc4f834a94a41527d4c6086ec54b9856547e45ea Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Sat, 7 Nov 2026 19:12:55 +0100 Subject: [PATCH 045/227] cache_bench: --play measures a first playback in the app --play SECS runs the app headless (egui_kittest) on the bench comp, with the viewer at 100 %, Resolution Auto, nothing cached and Cache Frames When Idle off, presses play and steps it for SECS seconds of wall time. It prints the frames shown per second, the time the clock stalled on uncached frames, the frame reached and the share of passes at each viewer scale. --gpu composites on the GPU (Mercury Auto) as the desktop app does; --res N sets the comp's Resolution to every N-th pixel. --- crates/ui-egui/examples/cache_bench.rs | 66 ++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/ui-egui/examples/cache_bench.rs b/crates/ui-egui/examples/cache_bench.rs index 9367d9e5c..9cfd5b26b 100644 --- a/crates/ui-egui/examples/cache_bench.rs +++ b/crates/ui-egui/examples/cache_bench.rs @@ -16,6 +16,12 @@ //! `--gpu` (composite on a GPU of its own, as the desktop app does with Mercury GPU Acceleration: //! the GPU or the CPU, whichever Auto measures faster per comp, read back to the CPU; the viewer's //! backend), `--backend cpu|gpu|auto` (with `--gpu`: force one compositor, default `auto`). +//! +//! `--play SECS` instead plays the comp in the app (headless, a viewer at 100 %, Resolution +//! Auto, nothing cached, Cache Frames When Idle off) for SECS seconds of wall time, as a first +//! playback, and prints the frames shown per second, the time stalled and the scale it played at. +//! With `--gpu` the app composites on the GPU (Mercury Auto), otherwise on the CPU; `--res N` +//! sets the comp's Resolution to every N-th pixel instead of Auto. use std::sync::Arc; use std::time::{Duration, Instant}; @@ -115,6 +121,10 @@ fn main() { cid } }; + if let Some(secs) = arg("--play").and_then(|v| v.parse().ok()) { + play(s, cid, secs, std::env::args().any(|a| a == "--gpu")); + return; + } let comp = s.project.comp(cid).expect("comp").clone(); let total = comp.frame_rate.frame_at(comp.duration); let n = arg("--frames").and_then(|v| v.parse().ok()).unwrap_or(total).min(total); @@ -204,3 +214,59 @@ fn main() { println!("disk cache {} written, {} dropped, {} entries ({} MB), flushed in {flushed:.2} s", d.writes, d.dropped, d.entries, d.bytes >> 20); } } + +/// `--play`: a first playback of `cid` in the app, as someone pressing play in it would see. +fn play(session: effectcraft_engine::Session, cid: ItemId, secs: f64, gpu: bool) { + use effectcraft_ui_egui::{ControlRequest, EffectcraftApp}; + let (tx, rx) = std::sync::mpsc::channel::(); + let builder = egui_kittest::Harness::builder().with_size(egui::vec2(1920.0, 1200.0)).with_pixels_per_point(1.0); + let builder = if gpu { builder.wgpu() } else { builder }; + let mut h = builder.build_eframe(move |_| EffectcraftApp::new(session).with_control(rx)); + { + let app = h.state_mut(); + let _ = app.session.execute("comp.open", json!({"comp": cid.0})); + app.ui.viewer.zoom = Some(1.0); + app.ui.cache_when_idle = false; + if let (Some(n), Some(c)) = (arg("--res").and_then(|v| v.parse().ok()), Arc::make_mut(&mut app.session.project).comp_mut(cid)) { + c.resolution = effectcraft_engine::project::Resolution::from_factor(n); + } + } + // Let the UI settle (fonts, the GPU compositor's pipelines) before the clock starts. + let ready = Instant::now() + Duration::from_secs(if gpu { 8 } else { 2 }); + while Instant::now() < ready { + h.step(); + std::thread::sleep(Duration::from_millis(10)); + } + h.state().frames.clear(); + let (reply, _r) = std::sync::mpsc::channel(); + let _ = tx.send(ControlRequest { method: "ui.playback".into(), params: json!({"action": "play"}), reply }); + let t0 = Instant::now(); + let (mut started, mut stalled, mut last, mut scales) = (None, 0.0, t0, std::collections::BTreeMap::::new()); + while t0.elapsed().as_secs_f64() < secs { + h.step(); + let now = Instant::now(); + let app = h.state(); + if app.playback.playing { + let t = *started.get_or_insert(now); + if app.playback.waiting && now > t { + stalled += (now - last).as_secs_f64(); + } + } + *scales.entry(app.shown_series(cid).scale).or_default() += 1; + last = now; + std::thread::sleep(Duration::from_millis(2)); + } + let app = h.state(); + let played = started.map_or(0.0, |t| t.elapsed().as_secs_f64()); + let shown = app.playback.shown; + let fr = app.session.project.comp(cid).map(|c| c.frame_rate); + let frame = fr.map_or(0, |fr| fr.frame_at(app.session.time())); + println!( + "played {shown} frames shown in {played:.2} s: {:.1} frames/s, stalled {stalled:.2} s, reached frame {frame}{}", + shown as f64 / played.max(1e-9), + if gpu { " (GPU)" } else { "" } + ); + let passes: u64 = scales.values().sum(); + let s: Vec = scales.iter().map(|(k, v)| format!("{:.3}: {:.0} %", *k as f64 / 1000.0, 100.0 * *v as f64 / passes.max(1) as f64)).collect(); + println!("viewer scale {}", s.join(", ")); +} From 893d9a0b04faf3f98b83fc3cd29f408fbbbf4fa3 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Sat, 7 Nov 2026 19:12:55 +0100 Subject: [PATCH 046/227] Preview: playback steps down a resolution when it can't keep up Settings > Previews > Lower Resolution When Playback Can't Keep Up (on by default): a preview at Resolution Auto that stalls on uncached frames and shows under 80 % of its frame rate for two seconds plays on at half the scale, and two seconds later at a quarter if still slow, no lower than the Adaptive Resolution Limit. Stopping goes back to the viewer's resolution and Cache Frames When Idle fills it in. An explicit Resolution, the comp's or the Preview panel's, is kept as set. First playback of a 1080p comp with a blur (cache_bench --play): 9.0 to 22.1 frames/s on the CPU (about 27 once at Half), 4.1 to 16.1 with Mercury Auto on an integrated GPU; plain clips that keep up stay at Full. --- crates/engine/src/settings/prefs/dialog.rs | 1 + crates/engine/src/settings/prefs/sections.rs | 3 ++ crates/ui-egui/src/app/playback.rs | 1 + crates/ui-egui/src/app/prefetch.rs | 28 +++++++++++ crates/ui-egui/src/app/viewer_frames.rs | 7 +++ crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/tests/it/ui_preview_align.rs | 50 ++++++++++++++++++++ docs/preferences.md | 8 +++- 8 files changed, 100 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index 1e79cdd46..c23b2b5b1 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -170,6 +170,7 @@ pub fn pages() -> Vec { title: "Previews", items: vec![ s("previews.adaptiveResolutionLimit", "Adaptive Resolution Limit", Kind::Choice(ON_OFF_RES), true), + s("previews.adaptivePlayback", "Lower Resolution When Playback Can't Keep Up", B, true), s("previews.showInternalWireframes", "Show Internal Wireframes", B, true), s("previews.cacheFramesWhenIdle", "Cache Frames When Idle", B, true), s("previews.fastPreviews", "Fast Previews (Draft 3D, Faster Effects)", B, true), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index 9a9f4ab21..ee86ce4c4 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -92,6 +92,9 @@ page!(CompositionPrefs { page!(Previews { /// Largest downsampling while playing back: `1/2`, `1/4`, `1/8`. adaptive_resolution_limit: String = "1/8".into(), + /// A preview at Resolution Auto that renders slower than real time steps down a resolution + /// (½, then ¼, no further than the Adaptive Resolution Limit) for the rest of that play. + adaptive_playback: bool = true, show_internal_wireframes: bool = false, cache_frames_when_idle: bool = true, fast_previews: bool = false, diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 51dd04ee1..79be99544 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -35,6 +35,7 @@ impl EffectcraftApp { plan: Some(plan), caching: plan.cache_first && plan.video, res: preset.resolution.factor(preset.custom_resolution), + adaptive: None, overlays: preset.include_overlays, layer_controls: preset.include_layer_controls, restore_max, diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 22e587643..3da62a17c 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -340,6 +340,8 @@ impl EffectcraftApp { self.session.set_time(fr.tick_of(f)); self.playback.frame_shown(now); } + // Dropping frames: a lower resolution may keep up (the rate shown says so). + self.step_down(now, pass.scale); } ctx.request_repaint(); true @@ -421,6 +423,7 @@ impl EffectcraftApp { } else { // Not cached yet: hold the clock at the current frame (cache first, then play). self.playback.waiting = true; + self.step_down(now, pass.scale); let next = plan.and_then(|p| p.frame_after(cur, 1)).unwrap_or(if cur + 1 > wb { wa } else { cur + 1 }); if self.frames.is_cached(&FrameKey { frame: next, ..series }) { self.session.set_time(fr.tick_of(next)); @@ -431,6 +434,31 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// Settings ▸ Previews ▸ Lower Resolution When Playback Can't Keep Up: a preview at + /// Resolution Auto stalled on a frame that isn't cached and shows under 80 % of its frame + /// rate two seconds after it started or last stepped down (the first frames at a new scale, + /// and the GPU's warm-up frames, take a moment), so it plays on at half `scale`, no lower than + /// the Adaptive Resolution Limit. An explicit Resolution is kept. + // ponytail: it never steps back up during a play, and frames cached at the higher scale aren't + // shown once it stepped down; add both if loops over a partly cached comp need them. + fn step_down(&mut self, now: f64, scale: f64) { + let pb = &self.playback; + let since = pb.adaptive.map_or(pb.starting_since, |(_, t)| t); + let step = pb.plan.map_or(1, |p| p.step).max(1) as f64; + let lower = scale * 0.5; + if !self.session.prefs.previews.adaptive_playback + || pb.res.is_some() + || !self.viewer_res().is_auto() + || now - since < 2.0 + || pb.achieved_fps().is_some_and(|f| f * step >= pb.fps * 0.8) + || lower < self.session.prefs.adaptive_limit() - 1e-9 + { + return; + } + self.playback.adaptive = Some((lower, now)); + self.playback.shown_at.clear(); + } + /// While the project keeps changing (an edit within [`EDIT_QUIET`] seconds), how long until /// it counts as settled. fn editing(&mut self, now: f64) -> Option { diff --git a/crates/ui-egui/src/app/viewer_frames.rs b/crates/ui-egui/src/app/viewer_frames.rs index a1a0faaf5..fe3ad8abb 100644 --- a/crates/ui-egui/src/app/viewer_frames.rs +++ b/crates/ui-egui/src/app/viewer_frames.rs @@ -98,6 +98,13 @@ impl EffectcraftApp { // play back, and frames cached by a preview stay on screen when it stops. let res = self.viewer_res(); let full = res.scale(zoom, ppp); + // A preview that couldn't keep up stepped down (`step_down`). + if self.playback.playing + && res.is_auto() + && let Some((s, _)) = self.playback.adaptive + { + return full.min(s); + } // Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging. let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting); if k < 1.0 && res.is_auto() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7a8805ec4..2c022595a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -190,6 +190,9 @@ pub struct Playback { pub caching: bool, /// Resolution override while playing (pixel step), `None` = the viewer's. pub res: Option, + /// Settings ▸ Previews ▸ Lower Resolution When Playback Can't Keep Up: the lower render + /// scale this play stepped down to, and when (seconds). See `step_down`. + pub adaptive: Option<(f64, f64)>, /// Include Overlays / Include Layer Controls. pub overlays: bool, pub layer_controls: bool, diff --git a/crates/ui-egui/tests/it/ui_preview_align.rs b/crates/ui-egui/tests/it/ui_preview_align.rs index 622df0397..81b6c8073 100644 --- a/crates/ui-egui/tests/it/ui_preview_align.rs +++ b/crates/ui-egui/tests/it/ui_preview_align.rs @@ -316,3 +316,53 @@ fn cache_frames_when_idle_survives_an_inverted_work_area() { } assert!(!h.state().playback.playing); } + +/// Settings ▸ Previews ▸ Lower Resolution When Playback Can't Keep Up: a preview at Auto that +/// stalls for two seconds steps down to half its scale, two more to a quarter; an explicit Resolution and +/// the pref turned off keep the scale, and stopping goes back to the viewer's. +#[test] +fn playback_that_cannot_keep_up_steps_down_a_resolution() { + let stall = |h: &mut Harness<'_, EffectcraftApp>, secs: f64| { + for _ in 0..(secs / 0.1).round() as usize { + h.state_mut().clock_mut().advance(0.1); + h.step(); + } + }; + let play = |h: &mut Harness<'_, EffectcraftApp>| { + h.state_mut().use_manual_clock(); + invoke(h, "playback.toggle", json!({})); + h.step(); + assert!(h.state().playback.playing); + }; + let mut h = harness(); + // No room in the RAM preview: frames never stay cached and every pass stalls, as with a comp + // too heavy to render in real time. + h.state().frames.set_budget(0); + play(&mut h); + stall(&mut h, 1.5); + assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0, "it waits two seconds before it steps down"); + stall(&mut h, 0.7); + assert_eq!(h.state().viewer_scale(1.0, 1.0), 0.5); + stall(&mut h, 1.5); + assert_eq!(h.state().viewer_scale(1.0, 1.0), 0.5, "and two more before the next step"); + stall(&mut h, 0.7); + assert_eq!(h.state().viewer_scale(1.0, 1.0), 0.25); + invoke(&mut h, "playback.toggle", json!({})); + assert!(!h.state().playback.playing); + assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0, "stopped: the viewer's resolution again"); + + // An explicit Resolution is kept. + let cid = h.state().session.active_comp_id().unwrap(); + std::sync::Arc::make_mut(&mut h.state_mut().session.project).comp_mut(cid).unwrap().resolution = effectcraft_engine::project::Resolution::Full; + play(&mut h); + stall(&mut h, 2.5); + assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); + invoke(&mut h, "playback.toggle", json!({})); + + // So is Auto with the pref off. + std::sync::Arc::make_mut(&mut h.state_mut().session.project).comp_mut(cid).unwrap().resolution = effectcraft_engine::project::Resolution::Auto; + h.state_mut().session.prefs.previews.adaptive_playback = false; + play(&mut h); + stall(&mut h, 2.5); + assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); +} diff --git a/docs/preferences.md b/docs/preferences.md index de7e426bc..be7b8edb5 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -52,7 +52,13 @@ Commands (CLI, MCP, control channel): - `autoSave.saveOnRenderStart`: Save When Starting Render Queue - `composition.showRenderingProgress`: Show Rendering Progress in Info Panel and Flowchart - `previews.adaptiveResolutionLimit`: Adaptive Resolution Limit (the lowest resolution Fast Previews ▸ - Adaptive Resolution drops to while you drag; a playing preview keeps the viewer's resolution) + Adaptive Resolution drops to while you drag, and the lowest a playing preview steps down to) +- `previews.adaptivePlayback`: Lower Resolution When Playback Can't Keep Up. On by default. A + preview at Resolution Auto that stalls on frames and shows under 80 % of its frame rate steps + down a resolution (half the scale after two seconds, a quarter two seconds later if still slow, + no lower than the Adaptive Resolution Limit) for the rest of that play; stopping goes back to the viewer's resolution, and Cache + Frames When Idle fills it in. An explicit Resolution (the comp's or the Preview panel's) is + kept as set - `previews.cacheFramesWhenIdle`: Cache Frames When Idle (Composition ▸ Preview): after a second without input or edits, the viewer renders the work area into the RAM preview in the background, from the current time on, until it is cached or the budget is full. On by default, From 3709cccedf9e63cf23570ba3f27dee5d459fd18a Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Sun, 8 Nov 2026 17:57:50 +0100 Subject: [PATCH 047/227] Preview: frame lanes are four threads wide, and cache_bench measures the cadence --- crates/ui-egui/examples/cache_bench.rs | 60 ++++++++++++++++++++--- crates/ui-egui/src/app/prefetch.rs | 9 ++-- crates/ui-egui/src/frames.rs | 34 ++++++++++++- crates/ui-egui/tests/it/ui_audio_scrub.rs | 8 +-- 4 files changed, 91 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/examples/cache_bench.rs b/crates/ui-egui/examples/cache_bench.rs index 9cfd5b26b..43e937c92 100644 --- a/crates/ui-egui/examples/cache_bench.rs +++ b/crates/ui-egui/examples/cache_bench.rs @@ -10,8 +10,8 @@ //! //! Options: `--project P --comp C` (a saved project's comp instead), `--scale S` (render scale, //! default 0.5: a viewer at half size), `--frames N` (frames to cache, default the whole comp), -//! `--slots N` (frames in flight, at least 1, default one per frame thread as the app does when -//! idle), +//! `--slots N` (frames in flight, at least 1, default one per lane as the app does when idle), +//! `--lane-width W` (threads per lane; the app's default when absent; `--play` takes it too), //! `--disk` (with a fresh disk cache in `target/cache-bench/disk`), `--no-adjustment`, //! `--gpu` (composite on a GPU of its own, as the desktop app does with Mercury GPU Acceleration: //! the GPU or the CPU, whichever Auto measures faster per comp, read back to the CPU; the viewer's @@ -37,6 +37,11 @@ fn arg(name: &str) -> Option { a.iter().position(|x| x == name).and_then(|i| a.get(i + 1).cloned()) } +/// `--lane-width W`: threads per frame lane (the app's default when absent). +fn lane_width() -> Option { + arg("--lane-width").and_then(|v| v.parse().ok()) +} + fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } @@ -130,9 +135,9 @@ fn main() { let n = arg("--frames").and_then(|v| v.parse().ok()).unwrap_or(total).min(total); let scale: f64 = arg("--scale").and_then(|v| v.parse().ok()).unwrap_or(0.5); - let frames = Frames::default(); - // (as Cache Frames When Idle: one frame per frame thread, see `app/prefetch.rs`) - let slots: usize = arg("--slots").and_then(|v| v.parse().ok()).map(|n: usize| n.max(1)).unwrap_or(frames.parallelism().max(1)); + let frames = lane_width().map_or_else(Frames::default, Frames::with_lane_width); + // (as Cache Frames When Idle: one frame per lane, see `app/prefetch.rs`) + let slots: usize = arg("--slots").and_then(|v| v.parse().ok()).map(|n: usize| n.max(1)).unwrap_or(frames.lanes()); // Room for every frame: this measures speed, not eviction. frames.set_budget(64 << 30); use effectcraft_engine::render::Backend; @@ -168,9 +173,10 @@ fn main() { }; eprintln!( - "caching {n} frames of {}×{} at scale {scale} with {slots} in flight{}…", + "caching {n} frames of {}×{} at scale {scale} with {slots} in flight on {} lanes{}…", comp.width, comp.height, + frames.lanes(), if src.gpu.is_some() { " on the GPU" } else { "" } ); let (gop0, t0) = (effectcraft_media::gop_stats(), Instant::now()); @@ -224,6 +230,9 @@ fn play(session: effectcraft_engine::Session, cid: ItemId, secs: f64, gpu: bool) let mut h = builder.build_eframe(move |_| EffectcraftApp::new(session).with_control(rx)); { let app = h.state_mut(); + if let Some(w) = lane_width() { + app.frames = effectcraft_ui_egui::frames::Frames::with_lane_width(w); + } let _ = app.session.execute("comp.open", json!({"comp": cid.0})); app.ui.viewer.zoom = Some(1.0); app.ui.cache_when_idle = false; @@ -242,6 +251,10 @@ fn play(session: effectcraft_engine::Session, cid: ItemId, secs: f64, gpu: bool) let _ = tx.send(ControlRequest { method: "ui.playback".into(), params: json!({"action": "play"}), reply }); let t0 = Instant::now(); let (mut started, mut stalled, mut last, mut scales) = (None, 0.0, t0, std::collections::BTreeMap::::new()); + // When each new frame went on screen (seconds since play was pressed): the cadence. The + // current time, not `Playback::shown`: frames shown while the clock holds don't count there. + let fr = h.state().session.project.comp(cid).map(|c| c.frame_rate); + let (mut shown_at, mut last_frame) = (Vec::::new(), None); while t0.elapsed().as_secs_f64() < secs { h.step(); let now = Instant::now(); @@ -252,20 +265,51 @@ fn play(session: effectcraft_engine::Session, cid: ItemId, secs: f64, gpu: bool) stalled += (now - last).as_secs_f64(); } } + let frame = fr.map(|fr| fr.frame_at(app.session.time())); + if app.playback.playing && frame != last_frame { + last_frame = frame; + shown_at.push((now - t0).as_secs_f64()); + } *scales.entry(app.shown_series(cid).scale).or_default() += 1; last = now; std::thread::sleep(Duration::from_millis(2)); } let app = h.state(); let played = started.map_or(0.0, |t| t.elapsed().as_secs_f64()); - let shown = app.playback.shown; - let fr = app.session.project.comp(cid).map(|c| c.frame_rate); + let shown = shown_at.len(); let frame = fr.map_or(0, |fr| fr.frame_at(app.session.time())); println!( "played {shown} frames shown in {played:.2} s: {:.1} frames/s, stalled {stalled:.2} s, reached frame {frame}{}", shown as f64 / played.max(1e-9), if gpu { " (GPU)" } else { "" } ); + // Cadence: how evenly the frames went on screen (the intervals between consecutive shown + // frames after the first), and how long the first two took. + let mut gaps: Vec = shown_at.windows(2).map(|w| w[1] - w[0]).collect(); + gaps.sort_by(f64::total_cmp); + let at = |q: f64| gaps.get(((gaps.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0) * 1000.0; + let holds = gaps.iter().filter(|g| **g > 0.2).count(); + println!( + "cadence first frame {:.2} s, second {:.2} s; gaps median {:.0} ms, p95 {:.0} ms, max {:.0} ms, {holds} holds > 200 ms", + shown_at.first().copied().unwrap_or(0.0), + shown_at.get(1).copied().unwrap_or(0.0), + at(0.5), + at(0.95), + at(1.0) + ); + // The opening (the first 3 s, before an adaptive step down can have taken effect): how + // evenly the uncached frames at the starting resolution went on screen. + let mut early: Vec = shown_at.windows(2).filter(|w| w[1] <= 3.0).map(|w| w[1] - w[0]).collect(); + early.sort_by(f64::total_cmp); + let at = |q: f64| early.get(((early.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0) * 1000.0; + println!( + "opening (3 s) {} frames; gaps median {:.0} ms, p95 {:.0} ms, max {:.0} ms, {} holds > 200 ms", + early.len() + usize::from(!early.is_empty()), + at(0.5), + at(0.95), + at(1.0), + early.iter().filter(|g| **g > 0.2).count() + ); let passes: u64 = scales.values().sum(); let s: Vec = scales.iter().map(|(k, v)| format!("{:.3}: {:.0} %", *k as f64 / 1000.0, 100.0 * *v as f64 / passes.max(1) as f64)).collect(); println!("viewer scale {}", s.join(", ")); diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 3da62a17c..f31df7094 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -549,13 +549,10 @@ impl EffectcraftApp { side(comp.width) * side(comp.height) * frames::frame_pixel_bytes(&self.session.project.settings) as f64 } + /// Frames rendered at once by a preview or idle caching: the lanes (in the browser the frame + /// workers; without them frames render one at a time on the UI thread, `Frames::pump`). fn frames_parallelism(&self) -> usize { - if cfg!(target_arch = "wasm32") { - // frame workers render one frame each; without them frames render one at a time - // on the UI thread (`Frames::pump`) - return self.frames.remote_slots().max(1); - } - self.frames.parallelism() + self.frames.lanes() } } diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 800306385..2d51068b0 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -707,15 +707,36 @@ struct DiskCarry { #[cfg(not(target_arch = "wasm32"))] const MAX_DISK_SERIES: usize = 8; +/// Threads per lane (see [`Queue::lanes`]). A frame's passes are memory-bound and stop scaling +/// at about 4 threads, so a core per lane (12 frames at once, each on one thread) and 4 cores +/// per lane cache frames at the same rate (within 3 %, widths 1–4 measured on 12 cores); but the +/// 12 single-threaded frames start and finish together, so a preview that can't keep up shows +/// them in bursts, and its second frame waits for the whole batch (0.3–1.3 s). Three lanes of +/// four threads finish frames 3× as often and the second frame shows in 0.13–0.45 s. +/// Never fewer than two lanes: one would finish frames in a single file. +const LANE_WIDTH: usize = 4; + impl Default for Frames { fn default() -> Self { + Self::with_lane_width(LANE_WIDTH) + } +} + +impl Frames { + /// A frame cache whose lanes are `width` threads each (see [`LANE_WIDTH`]); `Default` uses + /// [`LANE_WIDTH`], benchmarks compare widths. + pub fn with_lane_width(width: usize) -> Self { + #[cfg(target_arch = "wasm32")] + let _ = width; #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); #[cfg(not(target_arch = "wasm32"))] - let lanes: Vec = (0..threads) + let width = width.clamp(1, (threads / 2).max(1)); + #[cfg(not(target_arch = "wasm32"))] + let lanes: Vec = (0..(threads / width).max(1)) .map_while(|i| { rayon::ThreadPoolBuilder::new() - .num_threads(1) + .num_threads(width) .thread_name(move |_| format!("ec-preview-{i}")) .panic_handler(|_| log::error!("a frame render panicked; that frame was skipped")) .build() @@ -765,6 +786,15 @@ impl Default for Frames { disk_series: Arc::default(), } } + + /// How many frames render at once on the lanes (a playing preview's prefetch, Cache Frames + /// When Idle); in the browser, the frame workers. + pub fn lanes(&self) -> usize { + #[cfg(target_arch = "wasm32")] + return self.remote_slots().max(1); + #[cfg(not(target_arch = "wasm32"))] + self.queue.lock().map_or(1, |q| q.lanes.max(1)) + } } /// Whether viewer frames of a project with `settings` are half floats ([`F16Frame`], rendered diff --git a/crates/ui-egui/tests/it/ui_audio_scrub.rs b/crates/ui-egui/tests/it/ui_audio_scrub.rs index ae724c9ad..d177d5654 100644 --- a/crates/ui-egui/tests/it/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/it/ui_audio_scrub.rs @@ -215,7 +215,7 @@ fn preview_with_audio_holds_the_first_frame_and_sounds_from_it() { let gate = GatedFrames::default(); let renderer: Arc = Arc::new(gate.clone()); app.frames.set_remote(Some(renderer)); - let ahead = (app.frames.parallelism() * 2).max(4) as i64; + let ahead = (app.frames.lanes() * 2).max(4) as i64; app.session.execute("comp.settings", json!({"comp": cid.0, "duration": (4 * ahead) as f64 / 30.0})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); h.state_mut().use_manual_clock(); @@ -258,7 +258,7 @@ fn preview_with_audio_sounds_without_caching_the_work_area() { h.state_mut().use_manual_clock(); h.run_steps(2); gate.discard(&mut h); - let ahead = (h.state().frames.parallelism() * 2).max(4) as i64; + let ahead = (h.state().frames.lanes() * 2).max(4) as i64; let now = h.state().now(); h.state_mut().play(now); let frames: Vec = (0..ahead).collect(); @@ -439,7 +439,7 @@ fn until_started(h: &mut Harness<'_, EffectcraftApp>) -> bool { fn startup_finishes_at_any_ram_preview_budget() { let probe = Fake::default(); let (app, _) = setup(true, &probe); - let ahead = (app.frames.parallelism() * 2).max(4) as i64; + let ahead = (app.frames.lanes() * 2).max(4) as i64; // A 64×36 frame is 9216 bytes; `fit` frames fit in (fit + ½) frames' worth / 0.9. let mut fits = vec![2, 3, 4, ahead - 1, ahead, ahead + 1]; fits.sort(); @@ -505,7 +505,7 @@ fn extended_viewer(fake: &Fake) -> (Harness<'static, EffectcraftApp>, ItemId, [u fn startup_finishes_when_extended_viewer_frames_are_larger() { let fake = Fake::default(); let (mut h, cid, size) = extended_viewer(&fake); - let ahead = (h.state().frames.parallelism() * 2).max(4); + let ahead = (h.state().frames.lanes() * 2).max(4); let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); // Room for `ahead` comp-sized frames (and a half), which is fewer real ones. let budget = ((ahead as f64 + 0.5) * comp_bytes as f64 / 0.9) as usize; From b3000aac70842153e4dd4208cd55b44100a0b1a7 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Sun, 8 Nov 2026 17:57:50 +0100 Subject: [PATCH 048/227] Preview: playback runs at the pace frames arrive when it can't keep up --- crates/ui-egui/src/app/playback.rs | 3 + crates/ui-egui/src/app/prefetch.rs | 161 +++++++++++++++++--- crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/tests/it/ui_preview_align.rs | 3 + docs/gaps.md | 2 +- docs/preferences.md | 3 +- 6 files changed, 156 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 79be99544..679d8683d 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -46,6 +46,9 @@ impl EffectcraftApp { starting_since: now, first_cached: None, shown_at: Default::default(), + pace: 0.0, + pace_acc: None, + paced_at: None, }; if !self.playback.caching { if plan.video { diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index f31df7094..dbde3f085 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -35,6 +35,46 @@ fn between_picture_and_sound(cur: i64, f: i64, target: i64, looping: bool) -> bo if target >= cur || !looping { cur < f && f < target } else { f > cur || f < target } } +/// How long the pace takes to follow the rate frames arrive at (seconds, the time constant of +/// its smoothing): short enough to follow a change, long enough to play through a batch of +/// frames finishing together. +const PACE_TAU: f64 = 0.5; + +/// The rate a preview plays at when frames arrive slower than its frame rate: proportional to +/// the cached frames ahead (`lead` of `target`), so it settles where the clock takes frames as +/// fast as they arrive and the lead stays put; smoothed over [`PACE_TAU`] (`dt` seconds since +/// the last pass). Never above the frame rate, never under an eighth of it (slower reads as +/// stopped: the clock holds instead). No frame ahead: the previous pace, the clock holds anyway. +fn pace_for(prev: f64, lead: i64, target: i64, fps: f64, dt: f64) -> f64 { + if lead <= 0 || target <= 0 || fps <= 0.0 { + return prev; + } + let raw = (fps * lead as f64 / target as f64).clamp(fps / 8.0, fps); + let k = 1.0 - (-dt.max(0.0) / PACE_TAU).exp(); + prev + (raw - prev) * k +} + +impl PlaybackPass<'_> { + /// The frame `k` played frames after `from` (wrapping as the preview loops), `None` once a + /// preview that doesn't loop has reached its end. + fn frame_after(&self, from: i64, k: i64) -> Option { + match self.plan { + Some(p) => p.frame_after(from, k), + None => { + let FrameWindow { first: wa, last: wb } = self.window; + let f = from + k; + if f <= wb { + Some(f) + } else if self.looping { + Some(wa + (f - wa).rem_euclid((wb - wa + 1).max(1))) + } else { + None + } + } + } + } +} + impl EffectcraftApp { /// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold /// and keep rendering ahead (the green cache bar fills, then playback runs in real time). @@ -376,29 +416,42 @@ impl EffectcraftApp { } /// The wall clock advances the preview when no audio device drives it, and shows the target - /// frame (or holds when it is not cached yet). + /// frame (or holds when it is not cached yet). While frames arrive slower than the frame + /// rate it runs at their pace ([`pace_for`]): every frame shows, evenly, as After Effects + /// plays a preview it hasn't cached yet; real time again once the frames ahead are cached. fn advance_frame_clock(&mut self, pass: &PlaybackPass<'_>, step: i64) { - let PlaybackPass { ctx, now, plan, video, looping, frame_rate: fr, cur, series, .. } = *pass; - let FrameWindow { first: wa, last: wb } = pass.window; - let elapsed = now - self.playback.start_wall; + let PlaybackPass { ctx, now, plan, video, frame_rate: fr, cur, series, .. } = *pass; + let FrameWindow { last: wb, .. } = pass.window; // The plan carries the rate the preview was started with (and `play_with` copies it to // `Playback::fps`). Deriving the frame from the plan keeps a logic-only tick correct even // when a host/test supplies only the plan; a stale duplicate cannot hold playback open. let fps = plan.map_or(self.playback.fps, |p| p.fps); - let n = ((elapsed * fps) / step as f64).floor() as i64; - let target = match plan { - Some(p) => p.frame_after(self.playback.start_frame, n), - None => { - let t = self.playback.start_frame + n; - if t <= wb { - Some(t) - } else if looping { - Some(wa + (t - wa).rem_euclid((wb - wa + 1).max(1))) - } else { - None - } + let dt = self.playback.paced_at.map_or(0.0, |t| (now - t).max(0.0)); + self.playback.paced_at = Some(now); + if video { + // The lead counts out of twice the prefetch depth: the prefetch keeps that many + // frames coming, so a full lead means rendering keeps up. + let target = (pass.ahead * 2).max(1); + let lead = self.cached_lead(pass, target); + let prev = if self.playback.pace > 0.0 { self.playback.pace } else { fps }; + let pace = pace_for(prev, lead, target, fps, dt); + self.playback.pace = pace; + if pace < fps * 0.8 { + self.step_down(now, pass.scale); } - }; + if pace < fps * 0.98 { + self.advance_paced(pass, pace, dt); + return; + } + if self.playback.pace_acc.take().is_some() { + // Back to real time from here. + self.playback.start_wall = now; + self.playback.start_frame = cur; + } + } + let elapsed = now - self.playback.start_wall; + let n = ((elapsed * fps) / step as f64).floor() as i64; + let target = pass.frame_after(self.playback.start_frame, n); let Some(target) = target else { if video { self.session.set_time(fr.tick_of(wb)); @@ -424,7 +477,7 @@ impl EffectcraftApp { // Not cached yet: hold the clock at the current frame (cache first, then play). self.playback.waiting = true; self.step_down(now, pass.scale); - let next = plan.and_then(|p| p.frame_after(cur, 1)).unwrap_or(if cur + 1 > wb { wa } else { cur + 1 }); + let next = pass.frame_after(cur, 1).unwrap_or(cur); if self.frames.is_cached(&FrameKey { frame: next, ..series }) { self.session.set_time(fr.tick_of(next)); } @@ -434,11 +487,54 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// The paced clock: it owes `pace` played frames a second (plus what the last passes left + /// over) and shows them one by one as far as they are cached; on a frame that isn't it holds + /// (owing one frame, so that frame shows the moment it arrives), and at the end of a preview + /// that doesn't loop it stops. + fn advance_paced(&mut self, pass: &PlaybackPass<'_>, pace: f64, dt: f64) { + let PlaybackPass { ctx, now, frame_rate: fr, cur, series, .. } = *pass; + let step = pass.plan.map_or(1, |p| p.step).max(1) as f64; + let mut owed = self.playback.pace_acc.unwrap_or(0.0) + dt * pace / step; + let mut f = cur; + while owed >= 1.0 { + match pass.frame_after(f, 1) { + Some(n) if self.frames.is_cached(&FrameKey { frame: n, ..series }) => { + f = n; + owed -= 1.0; + } + Some(_) => { + owed = 1.0; + break; + } + None => { + self.session.set_time(fr.tick_of(pass.window.last)); + self.stop(); + return; + } + } + } + self.playback.waiting = owed >= 1.0; + if f != cur { + self.session.set_time(fr.tick_of(f)); + self.playback.frame_shown(now); + } + self.playback.pace_acc = Some(owed); + ctx.request_repaint(); + } + + /// Cached frames among the next `max` in play order. Counted, not the consecutive run: one + /// late frame (a GPU warm-up frame, a heavy one) with frames cached beyond it holds the clock + /// for a moment when it gets there, rather than slowing the whole preview to a crawl. + fn cached_lead(&self, pass: &PlaybackPass<'_>, max: i64) -> i64 { + let cached = self.frames.cached_frames(&pass.series); + (1..=max).map_while(|k| pass.frame_after(pass.cur, k)).filter(|f| cached.binary_search(f).is_ok()).count() as i64 + } + /// Settings ▸ Previews ▸ Lower Resolution When Playback Can't Keep Up: a preview at - /// Resolution Auto stalled on a frame that isn't cached and shows under 80 % of its frame - /// rate two seconds after it started or last stepped down (the first frames at a new scale, - /// and the GPU's warm-up frames, take a moment), so it plays on at half `scale`, no lower than - /// the Adaptive Resolution Limit. An explicit Resolution is kept. + /// Resolution Auto plays under 80 % of its frame rate (its pace, or stalled on a frame that + /// isn't cached) two seconds after it started or last stepped down (the first frames at a + /// new scale, and the GPU's warm-up frames, take a moment), so it plays on at half `scale`, + /// no lower than the Adaptive Resolution Limit. An explicit Resolution is kept. // ponytail: it never steps back up during a play, and frames cached at the higher scale aren't // shown once it stepped down; add both if loops over a partly cached comp need them. fn step_down(&mut self, now: f64, scale: f64) { @@ -556,6 +652,27 @@ impl EffectcraftApp { } } +#[cfg(test)] +mod pace_tests { + use super::pace_for; + + /// The pace follows the cached lead: real time with a full lead, a third of it with a third, + /// never under an eighth, unchanged with nothing ahead, and smoothed over time. + #[test] + fn pace_follows_the_cached_lead() { + // Settled (a long dt): proportional to the lead. + assert!((pace_for(30.0, 12, 12, 30.0, 10.0) - 30.0).abs() < 1e-6); + assert!((pace_for(30.0, 4, 12, 30.0, 10.0) - 10.0).abs() < 1e-6); + assert!((pace_for(30.0, 1, 100, 30.0, 10.0) - 3.75).abs() < 1e-6, "never under an eighth"); + assert_eq!(pace_for(17.0, 0, 12, 30.0, 10.0), 17.0, "no frame ahead: unchanged"); + // Smoothing: after one time constant it has gone 63 % of the way. + let p = pace_for(30.0, 4, 12, 30.0, 0.5); + assert!((p - (30.0 - 20.0 * (1.0 - (-1.0f64).exp()))).abs() < 1e-6); + assert_eq!(pace_for(30.0, 4, 12, 30.0, 0.0), 30.0, "no time passed: unchanged"); + assert!(pace_for(10.0, 12, 12, 30.0, 0.5) > 10.0, "and up again as the lead fills"); + } +} + #[cfg(test)] mod startup_tests { use super::{between_picture_and_sound, startup_frames}; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2c022595a..2e911ea54 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -222,6 +222,14 @@ pub struct Playback { pub first_cached: Option, /// When the last frames were shown (seconds, the last second's worth): the achieved rate. pub shown_at: std::collections::VecDeque, + /// The rate the silent clock advances at (comp frames per second, at most `fps`; 0 = the + /// frame rate): below real time while frames arrive slower than that, so a first playback + /// shows them evenly instead of in bursts between freezes (see `pace_for`). + pub pace: f64, + /// Played frames the paced clock owes (fractional), `Some` while it runs below real time. + pub pace_acc: Option, + /// When the silent clock last ran (seconds), for the pace. + pub paced_at: Option, } impl Playback { diff --git a/crates/ui-egui/tests/it/ui_preview_align.rs b/crates/ui-egui/tests/it/ui_preview_align.rs index 81b6c8073..6b5525e09 100644 --- a/crates/ui-egui/tests/it/ui_preview_align.rs +++ b/crates/ui-egui/tests/it/ui_preview_align.rs @@ -285,6 +285,9 @@ fn playback_prefetches_past_the_cached_frames_ahead() { h.state_mut().use_manual_clock(); h.state_mut().session.set_time(Tick::ZERO); invoke(&mut h, "playback.toggle", json!({})); + // The first pass sees the frames ahead cached and starts the clock (a silent preview holds + // its first frame until a few are, as one with sound does). + h.step(); h.state_mut().clock_mut().advance(0.25); h.run_steps(2); assert!(h.state().playback.playing); diff --git a/docs/gaps.md b/docs/gaps.md index d9de84675..608ed60ed 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -61,7 +61,7 @@ are grouped by area; the issue tracker has the rest. | 2 | **Fidelity unmeasured** beyond scalar ease | [fidelity/](fidelity/README.md): 168 samples, one property type; `compare_frames` helper exists, no corpus | Every "done" feature may still differ; users find the differences | 60–100 h harness + P0/P1 corpus; more per feature | G1 · [target-app-parity.md](target-app-parity.md) | | 3 | **Open crash reports** | Open: #580, #501, #426, #341, #234, #879. Now closed: #591, #306, #511 | A crash loses work; outranks features ([AGENTS.md](../AGENTS.md)) | 10–25 h | G3 · this file | | 4 | ~~**No CI gate on pull requests**~~ closed | `.github/workflows/ci.yml` runs `cargo xtask ci` (fmt, clippy, tests, layers, assets, wasm) on every pull request and push to `main` through craft-common-actions' shared `gate.yml` (#2204) | — | done | G3 · this file | -| 5 | **Playback and scrubbing feel** | #595 stutter on cached frames (partly addressed: nearest-first RAM preview prefetch, cache bars at 1/3 resolution), #304, #504 / #335 audio late or crackling, #586 puppet lag (maintainers asked for details); #329 slow scrubbing (#784) and #554 closed | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) | +| 5 | **Playback and scrubbing feel** | #595 stutter on cached frames (partly addressed: nearest-first RAM preview prefetch, cache bars at 1/3 resolution; an uncached first playback plays at the rate frames render instead of freezing between bursts, with frame lanes four threads wide), #304, #504 / #335 audio late or crackling, #586 puppet lag (maintainers asked for details); #329 slow scrubbing (#784) and #554 closed | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) | | 6 | **Linux and Windows interaction bugs** | Open: #488 rename arrows (hardening landed, reporter asked for details), #492 anchor drag, #268 file drops on Wayland (winit), #613 Direct3D 12 compositor. Now closed: #491, #493, #467, #320, #302, #596 | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) | | 7 | **Animation presets: 76 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 76 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops; 22 Behaviors: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker on transform and opacity, #1510) and read only our `.ecpreset`. Missing: Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | | 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | diff --git a/docs/preferences.md b/docs/preferences.md index be7b8edb5..cad74aec2 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -54,7 +54,8 @@ Commands (CLI, MCP, control channel): - `previews.adaptiveResolutionLimit`: Adaptive Resolution Limit (the lowest resolution Fast Previews ▸ Adaptive Resolution drops to while you drag, and the lowest a playing preview steps down to) - `previews.adaptivePlayback`: Lower Resolution When Playback Can't Keep Up. On by default. A - preview at Resolution Auto that stalls on frames and shows under 80 % of its frame rate steps + preview at Resolution Auto that plays under 80 % of its frame rate (frames render slower than + that, so it plays them at the rate they arrive rather than freezing between them) steps down a resolution (half the scale after two seconds, a quarter two seconds later if still slow, no lower than the Adaptive Resolution Limit) for the rest of that play; stopping goes back to the viewer's resolution, and Cache Frames When Idle fills it in. An explicit Resolution (the comp's or the Preview panel's) is From 396495dfa0449560a33b8b2706ac539868d1ffa0 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 14:13:18 +0100 Subject: [PATCH 049/227] Auto: frames that end without a time stop counting as running A GPU frame that failed or failed its health check, and a remote frame replaced before it finished, stayed counted as running on its side, so every later frame there was timed as one of several side by side and that side looked cheaper. AutoPick::abandoned releases them. --- crates/engine/src/background/remote.rs | 5 ++++- crates/render/src/auto.rs | 21 +++++++++++++++++---- crates/render/src/pipeline.rs | 8 +++++++- crates/render/src/tests/auto.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/frames.rs | 4 ++++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/background/remote.rs b/crates/engine/src/background/remote.rs index aea210ec5..72b4598a2 100644 --- a/crates/engine/src/background/remote.rs +++ b/crates/engine/src/background/remote.rs @@ -430,7 +430,10 @@ impl FrameServer { } } // A frame still waiting for readbacks is replaced: end it, so every `frame_begin` has its `frame_end`. - if self.pending.take().is_some() { + if let Some(old) = self.pending.take() { + if let (Some((key, chose)), Some(pick)) = (old.auto, a.auto_pick()) { + pick.abandoned(key, chose); + } a.frame_end(); } a.frame_begin(); diff --git a/crates/render/src/auto.rs b/crates/render/src/auto.rs index b1138588b..fc7becb5d 100644 --- a/crates/render/src/auto.rs +++ b/crates/render/src/auto.rs @@ -67,9 +67,7 @@ pub struct AutoPick { struct Entry { stats: AutoStats, measuring: [Option; 2], - // ponytail: a frame that never reports back (a GPU failure path, a replaced remote frame) - // leaves its side counted as running, which only makes that side look cheaper; capped at - // MAX_RUNNING, cleared by `AutoPick::reset`. + // Lowered by `record`, `declined` and `abandoned`: every frame asked for ends in one. running: [u32; 2], } @@ -109,7 +107,7 @@ impl AutoPick { const MEASURING_FOR: std::time::Duration = std::time::Duration::from_secs(10); /// Render the frame for `key` on the GPU? Every frame asked for reports back with - /// [`AutoPick::record`] (or [`AutoPick::declined`]). + /// [`AutoPick::record`] (or [`AutoPick::declined`]), or ends with [`AutoPick::abandoned`]. pub fn choose(&self, key: AutoKey) -> bool { let Ok(mut m) = self.stats.lock() else { return true }; if m.len() > Self::MAX_KEYS && !m.contains_key(&key) { @@ -194,6 +192,21 @@ impl AutoPick { } } + /// A frame for `key` asked of `gpu`'s side ends without a time (it failed, or was replaced + /// before it finished). Without this its side stays counted as running, and every later + /// frame there is timed as one of several running side by side: that side looks cheaper. + // ponytail: clears the side's warm-up/probe mark even when another frame holds it; that + // only lets the next probe start early. + pub fn abandoned(&self, key: AutoKey, gpu: bool) { + if let Ok(mut m) = self.stats.lock() + && let Some(e) = m.get_mut(&key) + { + e.measuring[usize::from(gpu)] = None; + let n = &mut e.running[usize::from(gpu)]; + *n = n.saturating_sub(1); + } + } + /// The history of `key`. pub fn stats(&self, key: AutoKey) -> Option { self.stats.lock().ok()?.get(&key).map(|e| e.stats) diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index fd46260c2..a77f8661d 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -587,7 +587,13 @@ impl<'a> Renderer<'a> { let auto = self.frame_auto(a, comp_id, false); if auto.is_none_or(|(p, key)| p.choose(key)) { let t0 = web_time::Instant::now(); - if let Some(img) = scope(a)? { + let res = scope(a); + if res.is_err() + && let Some((p, key)) = auto + { + p.abandoned(key, true); + } + if let Some(img) = res? { if let Some((p, key)) = auto { p.record(key, true, t0.elapsed().as_secs_f64() * 1e3); } diff --git a/crates/render/src/tests/auto.rs b/crates/render/src/tests/auto.rs index e7387eed3..da3d9f348 100644 --- a/crates/render/src/tests/auto.rs +++ b/crates/render/src/tests/auto.rs @@ -308,3 +308,23 @@ fn auto_pick_counts_frames_running_side_by_side() { assert!(s.cpu_ms.unwrap() < 30.0 && s.gpu_ms.unwrap() > 100.0, "{s:?}"); assert!(lanes.iter().filter(|g| **g).count() <= 1, "{lanes:?}"); } + +/// A frame that ends without a time (a GPU failure, a remote frame replaced before it finished) +/// no longer counts as running: the frames after it are timed as one at a time. (It stayed +/// counted, and every later frame on its side was timed as half its cost.) +#[test] +fn auto_pick_abandoned_frames_stop_counting_as_running() { + let pick = AutoPick::default(); + let key = AutoKey::new(ItemId(5), 1.0, false); + let mut dropped = [false; 2]; + for _ in 0..16 { + let gpu = pick.choose(key); + if !std::mem::replace(&mut dropped[usize::from(gpu)], true) { + pick.abandoned(key, gpu); + continue; + } + pick.record(key, gpu, if gpu { 40.0 } else { 10.0 }); + } + let s = pick.stats(key).unwrap(); + assert_eq!((s.cpu_ms, s.gpu_ms), (Some(10.0), Some(40.0)), "{s:?}"); +} diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 2d51068b0..01b9824d6 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -2019,6 +2019,7 @@ impl Worker { // Time the GPU's work, not just its recording. g.wait(); if g.context().check_health().is_err() { + p.abandoned(key, true); return self.render_job(job, false, files); } p.record(key, true, t0.elapsed().as_secs_f64() * 1000.0); @@ -2031,6 +2032,9 @@ impl Worker { } } Err(e) => { + if let Some((p, key)) = auto { + p.abandoned(key, true); + } self.gpu_attempt_rejected(g, &e); return self.render_job(job, false, files); } From dd1aff17e57653f529f9f130bcce073ad6b4bb48 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 14:14:57 +0100 Subject: [PATCH 050/227] Footage: 8-bit levels are read while the render records its reads footage_buf8 asked for a movie's 8-bit levels outside Renderer::read, so the media pool noted the movie in no recording: a layer buffer made of it listed no files and was still marked complete, and could be served stale after the movie changed on disk. --- crates/render/src/pipeline.rs | 2 +- crates/render/src/tests/footage_size.rs | 37 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index a77f8661d..27103f19a 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -1056,7 +1056,7 @@ impl<'a> Renderer<'a> { if !plain { return None; } - let fr = self.footage.frame8(item, pf, t, self.pipe.float_curve(pf))?; + let fr = self.read(|| self.footage.frame8(item, pf, t, self.pipe.float_curve(pf)))?; let n = (fr.width as usize).checked_mul(fr.height as usize)?; let px = fr.rgba.as_chunks::<4>().0; if px.len() != n { diff --git a/crates/render/src/tests/footage_size.rs b/crates/render/src/tests/footage_size.rs index 25c1a1814..fbf776617 100644 --- a/crates/render/src/tests/footage_size.rs +++ b/crates/render/src/tests/footage_size.rs @@ -157,3 +157,40 @@ fn footage_kept_as_8_bit_levels_renders_the_same_pixels() { assert_eq!(q8.asked.into_inner() > 0, used, "{depth:?} {invert} {scale}"); } } + +/// The 8-bit levels are read while the render records its reads: the movie is in the files the +/// frame (and a cached layer buffer) was made of. (Read outside, a buffer of a movie listed no +/// files and was still marked complete: changed on disk, it was served stale.) +#[test] +fn footage_kept_as_8_bit_levels_is_noted_as_read() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + struct Noted { + clip: Clip8, + recording: AtomicBool, + outside: AtomicUsize, + } + impl FootageSource for Noted { + fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option> { + self.clip.frame(item, f, t) + } + fn frame8(&self, item: ItemId, f: &Footage, t: Tick, float: crate::FloatCurve) -> Option> { + if !self.recording.load(Ordering::SeqCst) { + self.outside.fetch_add(1, Ordering::SeqCst); + } + self.clip.frame8(item, f, t, float) + } + fn recording(&self, _: &crate::reads::ReadLog, f: &mut dyn FnMut()) { + self.recording.store(true, Ordering::SeqCst); + f(); + self.recording.store(false, Ordering::SeqCst); + } + } + let (p, cid) = project(BitDepth::Bpc8, false); + let src = Noted { clip: Clip8 { asked: Default::default() }, recording: AtomicBool::new(false), outside: AtomicUsize::new(0) }; + let log = crate::reads::ReadLog::new(); + let mut r = Renderer::new(&p, &src, RenderOpts::default()); + r.reads = Some(&log); + r.comp_frame(cid, Tick::ZERO); + assert!(src.clip.asked.load(Ordering::SeqCst) > 0, "the 8-bit levels were not asked for"); + assert_eq!(src.outside.load(Ordering::SeqCst), 0, "8-bit levels read outside the render's recording"); +} From bbc5f9889d3571717fd20995a5d283a00595fa01 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 14:16:51 +0100 Subject: [PATCH 051/227] Preview: a cached end of a preview that doesn't loop plays in real time The paced clock counted the cached lead out of twice the prefetch depth even when fewer frames were left before the end, so a fully cached preview that doesn't loop slowed down over its last frames, and one shorter than that depth played slow throughout (and could step down). The lead now counts out of the frames left. --- crates/ui-egui/src/app/prefetch.rs | 45 +++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index dbde3f085..7f6494d3f 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -54,6 +54,15 @@ fn pace_for(prev: f64, lead: i64, target: i64, fps: f64, dt: f64) -> f64 { prev + (raw - prev) * k } +/// Cached frames (`cached`, sorted) among the next `max` frames `after(1..=max)` plays, and how +/// many frames it plays (`after` is `None` past the end). Counted, not the consecutive run: one +/// late frame (a GPU warm-up frame, a heavy one) with frames cached beyond it holds the clock +/// for a moment when it gets there, rather than slowing the whole preview to a crawl. +fn lead_of(after: impl Fn(i64) -> Option, cached: &[i64], max: i64) -> (i64, i64) { + let ahead: Vec = (1..=max).map_while(after).collect(); + (ahead.iter().filter(|f| cached.binary_search(f).is_ok()).count() as i64, ahead.len() as i64) +} + impl PlaybackPass<'_> { /// The frame `k` played frames after `from` (wrapping as the preview loops), `None` once a /// preview that doesn't loop has reached its end. @@ -430,9 +439,8 @@ impl EffectcraftApp { self.playback.paced_at = Some(now); if video { // The lead counts out of twice the prefetch depth: the prefetch keeps that many - // frames coming, so a full lead means rendering keeps up. - let target = (pass.ahead * 2).max(1); - let lead = self.cached_lead(pass, target); + // frames coming (or the frames left before the end), so a full lead means rendering keeps up. + let (lead, target) = self.cached_lead(pass, (pass.ahead * 2).max(1)); let prev = if self.playback.pace > 0.0 { self.playback.pace } else { fps }; let pace = pace_for(prev, lead, target, fps, dt); self.playback.pace = pace; @@ -522,12 +530,11 @@ impl EffectcraftApp { ctx.request_repaint(); } - /// Cached frames among the next `max` in play order. Counted, not the consecutive run: one - /// late frame (a GPU warm-up frame, a heavy one) with frames cached beyond it holds the clock - /// for a moment when it gets there, rather than slowing the whole preview to a crawl. - fn cached_lead(&self, pass: &PlaybackPass<'_>, max: i64) -> i64 { - let cached = self.frames.cached_frames(&pass.series); - (1..=max).map_while(|k| pass.frame_after(pass.cur, k)).filter(|f| cached.binary_search(f).is_ok()).count() as i64 + /// Cached frames among the next `max` in play order, and how many of those there are (fewer + /// near the end of a preview that doesn't loop: its cached tail is a full lead). See + /// [`lead_of`]. + fn cached_lead(&self, pass: &PlaybackPass<'_>, max: i64) -> (i64, i64) { + lead_of(|k| pass.frame_after(pass.cur, k), &self.frames.cached_frames(&pass.series), max) } /// Settings ▸ Previews ▸ Lower Resolution When Playback Can't Keep Up: a preview at @@ -654,7 +661,7 @@ impl EffectcraftApp { #[cfg(test)] mod pace_tests { - use super::pace_for; + use super::{lead_of, pace_for}; /// The pace follows the cached lead: real time with a full lead, a third of it with a third, /// never under an eighth, unchanged with nothing ahead, and smoothed over time. @@ -671,6 +678,24 @@ mod pace_tests { assert_eq!(pace_for(30.0, 4, 12, 30.0, 0.0), 30.0, "no time passed: unchanged"); assert!(pace_for(10.0, 12, 12, 30.0, 0.5) > 10.0, "and up again as the lead fills"); } + + /// Near the end of a preview that doesn't loop, the lead counts out of the frames left: a + /// cached tail plays in real time. (It counted out of the full depth, and a cached preview + /// slowed down over its last frames, a short one for its whole length.) + #[test] + fn a_cached_end_is_a_full_lead() { + let cached: Vec = (0..=20).collect(); + let to_20 = |cur: i64| move |k: i64| Some(cur + k).filter(|f| *f <= 20); + assert_eq!(lead_of(to_20(5), &cached, 12), (12, 12)); + assert_eq!(lead_of(to_20(16), &cached, 12), (4, 4)); + assert_eq!(lead_of(to_20(20), &cached, 12), (0, 0)); + let (lead, target) = lead_of(to_20(16), &cached, 12); + assert!((pace_for(30.0, lead, target, 30.0, 10.0) - 30.0).abs() < 1e-6); + // Frames left uncached still count against it. + assert_eq!(lead_of(to_20(16), &[17, 19], 12), (2, 4)); + // Looping: the walk wraps, always `max` frames. + assert_eq!(lead_of(|k| Some((18 + k) % 21), &cached, 12), (12, 12)); + } } #[cfg(test)] From e4c49f9bc08749ee59cd755c3246dbce94096ed3 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 14:20:01 +0100 Subject: [PATCH 052/227] Disk cache: queued layer writes hold at most 1 GB Layer jobs keep their float buffer until the writer compresses it, so the 64-job limit could hold ~8.5 GB of evicted 4K buffers. Layer and matte writes now also have a byte budget, like frames; a single bigger buffer is still taken when nothing else is queued. --- crates/render/src/disk_cache.rs | 63 +++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index fa0a97696..5c34348fe 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -58,6 +58,10 @@ const QUEUE_LIMIT: usize = 64; /// frames, so a fully cached comp came back with gaps in its blue bar. #[cfg(not(target_arch = "wasm32"))] const FRAME_QUEUE_BYTES: usize = 1 << 30; +/// Pending layer and matte bytes before new ones are dropped: a layer job holds its float +/// buffer until the writer compresses it (64 of them at 4K were ~8.5 GB). +#[cfg(not(target_arch = "wasm32"))] +const LAYER_QUEUE_BYTES: usize = 1 << 30; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Kind { @@ -304,8 +308,10 @@ struct Queue { frames: std::collections::VecDeque, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] frame_bytes: usize, - /// Layer and matte writes. + /// Layer and matte writes, and their payload bytes. jobs: std::collections::VecDeque, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + job_bytes: usize, busy: bool, /// Keys of frames queued or being written. pending_frames: std::collections::HashSet, @@ -360,7 +366,21 @@ impl Queue { } return Some(j); } - self.jobs.pop_front() + let j = self.jobs.pop_front()?; + if let Job::Write(_, _, _, p) = &j { + self.job_bytes = self.job_bytes.saturating_sub(p.len()); + } + Some(j) + } + + /// No room for a `kind` write of `len` bytes. A layer bigger than the whole budget is still + /// taken when nothing else is queued. + #[cfg(not(target_arch = "wasm32"))] + fn full(&self, kind: Kind, len: usize) -> bool { + match kind { + Kind::Frame => self.frame_bytes.saturating_add(len) > FRAME_QUEUE_BYTES, + _ => self.jobs.len() >= QUEUE_LIMIT || (self.job_bytes > 0 && self.job_bytes.saturating_add(len) > LAYER_QUEUE_BYTES), + } } fn is_empty(&self) -> bool { self.frames.is_empty() && self.jobs.is_empty() @@ -492,6 +512,7 @@ impl DiskCache { frames: Default::default(), frame_bytes: 0, jobs: Default::default(), + job_bytes: 0, busy: cfg!(not(target_arch = "wasm32")), pending_frames: Default::default(), drops: Drops::default(), @@ -680,6 +701,7 @@ impl DiskCache { #[cfg(target_arch = "wasm32")] { q.jobs.clear(); + q.job_bytes = 0; q.frames.clear(); q.pending_frames.clear(); } @@ -824,11 +846,7 @@ impl DiskCache { if stale(&q) { return false; } - let full = match kind { - Kind::Frame => q.frame_bytes.saturating_add(payload.len()) > FRAME_QUEUE_BYTES, - _ => q.jobs.len() >= QUEUE_LIMIT, - }; - if full { + if q.full(kind, payload.len()) { self.dropped.fetch_add(1, Ordering::Relaxed); return false; } @@ -837,6 +855,7 @@ impl DiskCache { q.frame_bytes = q.frame_bytes.saturating_add(payload.len()); q.frames.push_back(Job::Write(kind, key, format, payload)); } else { + q.job_bytes = q.job_bytes.saturating_add(payload.len()); q.jobs.push_back(Job::Write(kind, key, format, payload)); } cv.notify_all(); @@ -1824,6 +1843,36 @@ mod tests { assert_ne!(comp_content_key(&p, cid), key, "the expression reads the model's name"); } + /// Layer jobs hold float buffers until they are compressed: the queue holds at most + /// [`LAYER_QUEUE_BYTES`] of them (64 at 4K held ~8.5 GB), and a single bigger one only alone. + #[test] + fn layer_writes_queue_within_a_byte_budget() { + let mut q = Queue { + frames: Default::default(), + frame_bytes: 0, + jobs: Default::default(), + job_bytes: 0, + busy: true, + pending_frames: Default::default(), + drops: Drops::default(), + }; + let layer = LAYER_QUEUE_BYTES / 4 + 1; + for _ in 0..3 { + assert!(!q.full(Kind::Layer, layer)); + q.jobs.push_back(Job::Write(Kind::Layer, 0, 0, Payload::Encoded(vec![]))); + q.job_bytes += layer; + } + assert!(q.full(Kind::Layer, layer), "a fourth would pass the budget"); + assert!(!q.full(Kind::Frame, 1024), "frames have their own budget"); + let mut empty = Queue { jobs: Default::default(), job_bytes: 0, ..q }; + assert!(!empty.full(Kind::Layer, 2 * LAYER_QUEUE_BYTES), "a single big layer, alone"); + // Taking a job gives its bytes back. + empty.job_bytes = 3; + empty.jobs.push_back(Job::Write(Kind::Layer, 0, 0, Payload::Encoded(vec![0; 3]))); + assert!(empty.pop().is_some()); + assert_eq!(empty.job_bytes, 0); + } + /// A burst of layer writes never crowds out frames: every frame of a fully cached comp is /// on disk afterwards (the blue bar came back with gaps). #[test] From 21d54b2a1afbea7235dedaeaacb7451e1d79df4f Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 14:20:01 +0100 Subject: [PATCH 053/227] Gaussian Blur: the bit-identity test covers tiles past the first A 300-pixel-wide case runs the row-by-row pass over tiles at x 128 and 256 and a partial last tile. --- crates/raster/src/blur.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index 9b785959f..d92148658 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -339,9 +339,10 @@ mod tests { #[test] fn gauss_pass_is_bit_identical_to_the_per_pixel_kernel() { - // Sizes narrower and shorter than the kernel too (every tap near an edge), with - // negative, zero and HDR values. - for (w, h) in [(37, 23), (3, 2), (1, 9), (64, 1)] { + // Sizes narrower and shorter than the kernel too (every tap near an edge), one wider + // than three tiles (tiles past the first, and a partial last one), with negative, zero + // and HDR values. + for (w, h) in [(37, 23), (3, 2), (1, 9), (64, 1), (300, 5)] { let mut img = Image::new(w, h); for (i, p) in img.data.iter_mut().enumerate() { let v = ((i * 7919) % 101) as f32 / 37.0 - 0.6; From e8b94271c46338c218676057d55058c1134ab423 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 14:20:01 +0100 Subject: [PATCH 054/227] Footage: the 8-bit levels test checks the movie is in the frame's files The test source notes its file while a read is recorded, as the media pool does, and the test checks the render's read log lists it. --- crates/render/src/tests/footage_size.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/render/src/tests/footage_size.rs b/crates/render/src/tests/footage_size.rs index fbf776617..6c59f3fb2 100644 --- a/crates/render/src/tests/footage_size.rs +++ b/crates/render/src/tests/footage_size.rs @@ -179,7 +179,9 @@ fn footage_kept_as_8_bit_levels_is_noted_as_read() { } self.clip.frame8(item, f, t, float) } - fn recording(&self, _: &crate::reads::ReadLog, f: &mut dyn FnMut()) { + // (as the media pool does: the files of what `f` reads go to `reads`) + fn recording(&self, reads: &crate::reads::ReadLog, f: &mut dyn FnMut()) { + reads.note(crate::reads::ReadFile::new("clip.mov", crate::reads::FileStamp::Native { len: 1, modified: Some(1) })); self.recording.store(true, Ordering::SeqCst); f(); self.recording.store(false, Ordering::SeqCst); @@ -193,4 +195,5 @@ fn footage_kept_as_8_bit_levels_is_noted_as_read() { r.comp_frame(cid, Tick::ZERO); assert!(src.clip.asked.load(Ordering::SeqCst) > 0, "the 8-bit levels were not asked for"); assert_eq!(src.outside.load(Ordering::SeqCst), 0, "8-bit levels read outside the render's recording"); + assert!(log.files().files().iter().any(|f| &*f.path == "clip.mov"), "the movie is not in the frame's files"); } From db21c733b489965e8fb56bff0a11b542f35a47ed Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 15:07:57 +0100 Subject: [PATCH 055/227] Auto: a frame's ticket ends it on every exit AutoPick::ticket chooses a side and reports the frame's time once; dropped without one it ends the frame unmeasured. The viewer's frames, the renderer's top-level frames and frames rendered in passes use it, so an Auto CPU frame turned away by GPU admission (#3028), a cancelled export frame or any later exit no longer leaves its frame counted as running. The frame server keeps its choice across calls and still ends a replaced frame itself. --- crates/render/src/auto.rs | 52 +++++++++++++++++++++++++++++++-- crates/render/src/lib.rs | 2 +- crates/render/src/passes.rs | 12 ++++---- crates/render/src/pipeline.rs | 30 +++++++++---------- crates/render/src/tests/auto.rs | 32 ++++++++++++++++++++ crates/ui-egui/src/frames.rs | 27 ++++++++--------- 6 files changed, 116 insertions(+), 39 deletions(-) diff --git a/crates/render/src/auto.rs b/crates/render/src/auto.rs index fc7becb5d..ce714c1f0 100644 --- a/crates/render/src/auto.rs +++ b/crates/render/src/auto.rs @@ -67,7 +67,8 @@ pub struct AutoPick { struct Entry { stats: AutoStats, measuring: [Option; 2], - // Lowered by `record`, `declined` and `abandoned`: every frame asked for ends in one. + // Lowered by `record`, `declined` and `abandoned`: every frame asked for ends in one (an + // [`AutoTicket`] makes sure of it). running: [u32; 2], } @@ -106,8 +107,16 @@ impl AutoPick { /// A warm-up or re-probe frame not reported back by then no longer holds its side. const MEASURING_FOR: std::time::Duration = std::time::Duration::from_secs(10); + /// [`AutoPick::choose`] for a frame that ends where it started: the ticket reports back, and + /// a ticket dropped without a time (an error, a retry elsewhere, a panic, a cancelled future) + /// ends the frame with [`AutoPick::abandoned`]. + pub fn ticket(&self, key: AutoKey) -> AutoTicket<'_> { + AutoTicket { pick: self, key, gpu: self.choose(key), open: true } + } + /// Render the frame for `key` on the GPU? Every frame asked for reports back with - /// [`AutoPick::record`] (or [`AutoPick::declined`]), or ends with [`AutoPick::abandoned`]. + /// [`AutoPick::record`] (or [`AutoPick::declined`]), or ends with [`AutoPick::abandoned`] + /// ([`AutoPick::ticket`] does that on its own). pub fn choose(&self, key: AutoKey) -> bool { let Ok(mut m) = self.stats.lock() else { return true }; if m.len() > Self::MAX_KEYS && !m.contains_key(&key) { @@ -219,3 +228,42 @@ impl AutoPick { } } } + +/// One frame [`AutoPick`] chose a side for ([`AutoPick::ticket`]): it reports the frame's time +/// once, and ends the frame unmeasured when dropped without one. +#[must_use = "a ticket dropped at once ends its frame unmeasured"] +pub struct AutoTicket<'a> { + pick: &'a AutoPick, + key: AutoKey, + gpu: bool, + open: bool, +} + +impl AutoTicket<'_> { + /// The frame renders on the GPU. + pub fn gpu(&self) -> bool { + self.gpu + } + + /// The frame took `ms` on its side. + pub fn record(mut self, ms: f64) { + self.open = false; + self.pick.record(self.key, self.gpu, ms); + } + + /// The GPU did not take the frame: it renders on the CPU, and the ticket reports from there. + pub fn declined(&mut self) { + if self.gpu { + self.pick.declined(self.key); + self.gpu = false; + } + } +} + +impl Drop for AutoTicket<'_> { + fn drop(&mut self) { + if self.open { + self.pick.abandoned(self.key, self.gpu); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d1be87a69..f47443b18 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -26,7 +26,7 @@ pub mod three_d; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -pub use auto::{AutoKey, AutoPick}; +pub use auto::{AutoKey, AutoPick, AutoTicket}; pub use cache::{CacheStats, LayerCache, LayerStore, PrefetchStore}; use effectcraft_color::BlendMode; use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params}; diff --git a/crates/render/src/passes.rs b/crates/render/src/passes.rs index 37c94fff4..b8f94fc46 100644 --- a/crates/render/src/passes.rs +++ b/crates/render/src/passes.rs @@ -86,10 +86,10 @@ pub async fn comp_frame(r: &Renderer<'_>, comp: ItemId, t: Tick) -> Image { } else if opts.roi.is_none() && let Some(pick) = a.auto_pick() { - let key = AutoKey::new(comp, opts.scale, false); - let gpu = pick.choose(key); - opts.backend = if gpu { Backend::Gpu } else { Backend::Cpu }; - auto = Some((key, gpu)); + // (a frame cancelled or panicking between passes drops it: ended unmeasured) + let ticket = pick.ticket(AutoKey::new(comp, opts.scale, false)); + opts.backend = if ticket.gpu() { Backend::Gpu } else { Backend::Cpu }; + auto = Some(ticket); } } let t0 = web_time::Instant::now(); @@ -98,8 +98,8 @@ pub async fn comp_frame(r: &Renderer<'_>, comp: ItemId, t: Tick) -> Image { } else { in_passes(Some(a), |acc| Renderer { opts, accel: acc, ..*r }.comp_frame(comp, t)).await.value }; - if let (Some((key, chose)), Some(pick)) = (auto, a.auto_pick()) { - pick.record(key, chose, t0.elapsed().as_secs_f64() * 1e3); + if let Some(t) = auto { + t.record(t0.elapsed().as_secs_f64() * 1e3); } img } diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 27103f19a..29ce6d69a 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -5,8 +5,9 @@ use super::fx_host::{FxHost, essential_overrides}; use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through}; use super::reads::{ReadFiles, ReadLog}; use super::{ - Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId, - ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2, + Arc, AutoKey, AutoPick, AutoTicket, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, + ItemId, ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, + vec2, }; use effectcraft_keyframe::Value; use effectcraft_project::LayerId; @@ -584,29 +585,24 @@ impl<'a> Renderer<'a> { if self.depth == 0 && let Some(a) = self.active_accel() { - let auto = self.frame_auto(a, comp_id, false); - if auto.is_none_or(|(p, key)| p.choose(key)) { + // (an error returns with the ticket, which ends the frame unmeasured) + let mut ticket = self.frame_auto(a, comp_id, false).map(|(p, key)| p.ticket(key)); + if ticket.as_ref().is_none_or(AutoTicket::gpu) { let t0 = web_time::Instant::now(); - let res = scope(a); - if res.is_err() - && let Some((p, key)) = auto - { - p.abandoned(key, true); - } - if let Some(img) = res? { - if let Some((p, key)) = auto { - p.record(key, true, t0.elapsed().as_secs_f64() * 1e3); + if let Some(img) = scope(a)? { + if let Some(t) = ticket { + t.record(t0.elapsed().as_secs_f64() * 1e3); } return Ok(img); } - if let Some((p, key)) = auto { - p.declined(key); + if let Some(t) = &mut ticket { + t.declined(); } } let t0 = web_time::Instant::now(); let img = cpu.comp_frame_cpu(comp_id, t); - if let Some((p, key)) = auto { - p.record(key, false, t0.elapsed().as_secs_f64() * 1e3); + if let Some(t) = ticket { + t.record(t0.elapsed().as_secs_f64() * 1e3); } return Ok(img); } diff --git a/crates/render/src/tests/auto.rs b/crates/render/src/tests/auto.rs index da3d9f348..a53bb718a 100644 --- a/crates/render/src/tests/auto.rs +++ b/crates/render/src/tests/auto.rs @@ -328,3 +328,35 @@ fn auto_pick_abandoned_frames_stop_counting_as_running() { let s = pick.stats(key).unwrap(); assert_eq!((s.cpu_ms, s.gpu_ms), (Some(10.0), Some(40.0)), "{s:?}"); } + +/// A ticket that never reports (dropped on an error, a retry elsewhere, a cancelled export +/// future) ends its frame like [`AutoPick::abandoned`]; one the GPU declined reports from the +/// CPU. (Each exit that skipped `record` left its frame counted as running, and every later +/// frame on that side was timed as one of several side by side.) +#[test] +fn auto_tickets_end_their_frame_on_every_exit() { + let pick = AutoPick::default(); + let key = AutoKey::new(ItemId(6), 1.0, false); + let mut dropped = [false; 2]; + for _ in 0..16 { + let t = pick.ticket(key); + let gpu = t.gpu(); + if !std::mem::replace(&mut dropped[usize::from(gpu)], true) { + drop(t); + continue; + } + t.record(if gpu { 40.0 } else { 10.0 }); + } + let s = pick.stats(key).unwrap(); + assert_eq!((s.cpu_ms, s.gpu_ms), (Some(10.0), Some(40.0)), "{s:?}"); + // Declined: the frame renders on the CPU and reports there, one frame on it. + let key = AutoKey::new(ItemId(7), 1.0, false); + pick.ticket(key).record(10.0); + let mut t = pick.ticket(key); + assert!(t.gpu(), "the GPU's warm-up frame"); + t.declined(); + assert!(!t.gpu()); + t.record(10.0); + pick.ticket(key).record(10.0); + assert_eq!(pick.stats(key).unwrap().cpu_ms, Some(10.0), "timed as one frame on the CPU"); +} diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 01b9824d6..c6f48ce0b 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -38,7 +38,7 @@ use std::sync::{Arc, Condvar, Mutex, PoisonError}; use effectcraft_engine::project::{Comp, Heard, ItemId, ItemKind, Node, Project, PropGroup}; use effectcraft_engine::render::disk_cache::{self, DiskCache}; use effectcraft_engine::render::reads::{ReadFiles, ReadLog}; -use effectcraft_engine::render::{CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; +use effectcraft_engine::render::{AutoTicket, CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; use effectcraft_gpu::{DisplayFrame, FrameError, Gpu}; use rayon::prelude::*; @@ -2009,32 +2009,32 @@ impl Worker { { // The browser cannot wait for the GPU to time it (and its CPU path is slow): always // the GPU there. - let auto = if cfg!(target_arch = "wasm32") { None } else { r.frame_auto(a, job.comp, true) }; - if auto.is_none_or(|(p, key)| p.choose(key)) { + // Every exit that doesn't record ends the frame unmeasured: the ticket is dropped + // before a retry (`take`), or on return. + let mut ticket = if cfg!(target_arch = "wasm32") { None } else { r.frame_auto(a, job.comp, true).map(|(p, key)| p.ticket(key)) }; + if ticket.as_ref().is_none_or(AutoTicket::gpu) { let t0 = web_time::Instant::now(); match gpu_attempt(g, || g.render_display(&r, job.comp, job.t)) { Ok(Some(f)) => { self.gpu_succeeded(); - if let Some((p, key)) = auto { + if let Some(t) = ticket.take() { // Time the GPU's work, not just its recording. g.wait(); if g.context().check_health().is_err() { - p.abandoned(key, true); + drop(t); return self.render_job(job, false, files); } - p.record(key, true, t0.elapsed().as_secs_f64() * 1000.0); + t.record(t0.elapsed().as_secs_f64() * 1000.0); } return FrameImage::Gpu(Arc::new(f)); } Ok(None) => { - if let Some((p, key)) = auto { - p.declined(key); + if let Some(t) = &mut ticket { + t.declined(); } } Err(e) => { - if let Some((p, key)) = auto { - p.abandoned(key, true); - } + drop(ticket.take()); self.gpu_attempt_rejected(g, &e); return self.render_job(job, false, files); } @@ -2046,12 +2046,13 @@ impl Worker { let img = match gpu_attempt(g, || r.comp_frame_cpu(job.comp, job.t)) { Ok(img) => cpu_frame(&img, &job.src.project, &job.opts), Err(e) => { + drop(ticket.take()); self.gpu_attempt_rejected(g, &e); return self.render_job(job, false, files); } }; - if let Some((p, key)) = auto { - p.record(key, false, t0.elapsed().as_secs_f64() * 1000.0); + if let Some(t) = ticket { + t.record(t0.elapsed().as_secs_f64() * 1000.0); } return img; } From b18bf9186a89fbd37cf42da753c7b32d20f0bfd6 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 15:09:40 +0100 Subject: [PATCH 056/227] Preview: GPU admission's reasoning holds for lanes four threads wide Only a lane's drain thread waits for a GPU slot. A lane runs one drain at a time (it is idle, and started again, only once its drain found no job), and its other threads only help with that frame's parallel work, as Rayon helpers that never wait. The comments said lanes had one thread; the test now runs on a lane LANE_WIDTH wide and checks that its other threads decline while the drain thread waits. --- crates/ui-egui/src/frames.rs | 10 +++++++--- crates/ui-egui/src/frames/tests.rs | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index c6f48ce0b..2b2b3d4b7 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -101,8 +101,12 @@ impl GpuRenderSlots { /// A Rayon helper may be executing a nested frame while an admitted render waits for /// its work. Blocking that helper on a permit would deadlock the render (#3028). fn for_render(&self, max: usize) -> Option> { - // Dedicated preview pools have one thread, protected from nested drains. They cannot - // be helpers of another admitted frame, so waiting preserves GPU preview throughput. + // Only a lane's drain thread waits: it renders one frame at a time, protected from + // nested drains, and a lane runs one drain at a time (it is idle again, and can be + // started anew, only once its drain found no job, so no frame of the lane is still + // running). The lane's other threads ([`LANE_WIDTH`]) only help with that frame's + // parallel work and are Rayon helpers here: they never wait. So no thread waits while + // an admitted frame waits for it, and waiting keeps the GPU preview's throughput. #[cfg(not(target_arch = "wasm32"))] if DRAINING_LANE.get() { return Some(self.acquire(max)); @@ -1720,7 +1724,7 @@ struct Worker { thread_local! { /// This pool thread is inside [`Worker::drain`] (rendering a frame). static DRAINING: std::cell::Cell = const { std::cell::Cell::new(false) }; - /// Only the single thread of a dedicated preview lane may wait for GPU admission. + /// Only a lane's drain thread may wait for GPU admission (see [`GpuRenderSlots::for_render`]). static DRAINING_LANE: std::cell::Cell = const { std::cell::Cell::new(false) }; } diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index 0a148b83c..baf6342e7 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -3325,18 +3325,19 @@ fn gpu_render_slots_never_block_rayon_helpers() { assert_eq!(*slots.busy.lock().unwrap(), 0); } -/// Dedicated preview lanes have one protected thread and can wait without creating the -/// shared-pool helper dependency cycle. They must keep rendering on a healthy GPU. +/// A lane's drain thread can wait without creating the shared-pool helper dependency cycle, +/// and must keep rendering on a healthy GPU; the lane's other threads ([`LANE_WIDTH`] wide) +/// help with its frame and never wait. #[cfg(not(target_arch = "wasm32"))] #[test] fn gpu_preview_lane_waits_for_admission_and_resets_its_marker() { let slots = GpuRenderSlots::new(); let first = slots.acquire(2); let second = slots.acquire(2); - let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let pool = rayon::ThreadPoolBuilder::new().num_threads(LANE_WIDTH).build().unwrap(); let (started, ready) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel(); - let (early, admitted) = std::thread::scope(|scope| { + let (early, admitted, helper) = std::thread::scope(|scope| { scope.spawn(|| { pool.install(|| { let _drain = Draining::enter(Some(0)).unwrap(); @@ -3347,13 +3348,19 @@ fn gpu_preview_lane_waits_for_admission_and_resets_its_marker() { }); }); ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + // Another thread of the same lane, while its drain thread waits: it declines. + let (htx, hrx) = std::sync::mpsc::channel(); + let (pool, slots) = (&pool, &slots); + scope.spawn(move || pool.install(|| htx.send(slots.for_render(2).is_none()).unwrap())); + let helper = hrx.recv_timeout(std::time::Duration::from_secs(2)); let early = rx.recv_timeout(std::time::Duration::from_millis(100)); drop((first, second)); let admitted = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2))); - (early, admitted) + (early, admitted, helper) }); assert!(early.is_err(), "busy dedicated lane must wait instead of falling back to CPU"); assert_eq!(admitted, Ok(true)); + assert_eq!(helper, Ok(true), "a lane's helper thread must not wait on GPU permits"); pool.install(|| { assert!(!DRAINING.get()); assert!(!DRAINING_LANE.get()); From 513d5eaea7310ba72692c3ae59fd9e2e28e74e54 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 15:10:24 +0100 Subject: [PATCH 057/227] Preview: Lower Resolution When Playback Can't Keep Up is off by default It never steps back up during a play, so a looping preview kept playing at a quarter of its resolution after its first loop. Without it the paced first loop caches the viewer's resolution and the next loops play in real time, as in After Effects. The setting stays for whoever prefers a lower first pass. --- crates/engine/src/settings/prefs/sections.rs | 7 +++++-- crates/ui-egui/tests/it/ui_preview_align.rs | 4 +++- docs/preferences.md | 4 +++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index ee86ce4c4..ca0e72bef 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -93,8 +93,11 @@ page!(Previews { /// Largest downsampling while playing back: `1/2`, `1/4`, `1/8`. adaptive_resolution_limit: String = "1/8".into(), /// A preview at Resolution Auto that renders slower than real time steps down a resolution - /// (½, then ¼, no further than the Adaptive Resolution Limit) for the rest of that play. - adaptive_playback: bool = true, + /// (½, then ¼, no further than the Adaptive Resolution Limit) for the rest of that play. Off + /// by default: it never steps back up during a play, so a looping preview kept playing at ¼ + /// after its first loop, where without it the first loop caches Full and the next ones play + /// in real time at Full, as in After Effects. + adaptive_playback: bool = false, show_internal_wireframes: bool = false, cache_frames_when_idle: bool = true, fast_previews: bool = false, diff --git a/crates/ui-egui/tests/it/ui_preview_align.rs b/crates/ui-egui/tests/it/ui_preview_align.rs index 6b5525e09..9d3119b21 100644 --- a/crates/ui-egui/tests/it/ui_preview_align.rs +++ b/crates/ui-egui/tests/it/ui_preview_align.rs @@ -322,7 +322,7 @@ fn cache_frames_when_idle_survives_an_inverted_work_area() { /// Settings ▸ Previews ▸ Lower Resolution When Playback Can't Keep Up: a preview at Auto that /// stalls for two seconds steps down to half its scale, two more to a quarter; an explicit Resolution and -/// the pref turned off keep the scale, and stopping goes back to the viewer's. +/// the pref turned off (its default) keep the scale, and stopping goes back to the viewer's. #[test] fn playback_that_cannot_keep_up_steps_down_a_resolution() { let stall = |h: &mut Harness<'_, EffectcraftApp>, secs: f64| { @@ -338,6 +338,8 @@ fn playback_that_cannot_keep_up_steps_down_a_resolution() { assert!(h.state().playback.playing); }; let mut h = harness(); + assert!(!h.state().session.prefs.previews.adaptive_playback, "off by default"); + h.state_mut().session.prefs.previews.adaptive_playback = true; // No room in the RAM preview: frames never stay cached and every pass stalls, as with a comp // too heavy to render in real time. h.state().frames.set_budget(0); diff --git a/docs/preferences.md b/docs/preferences.md index cad74aec2..07df169aa 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -53,7 +53,9 @@ Commands (CLI, MCP, control channel): - `composition.showRenderingProgress`: Show Rendering Progress in Info Panel and Flowchart - `previews.adaptiveResolutionLimit`: Adaptive Resolution Limit (the lowest resolution Fast Previews ▸ Adaptive Resolution drops to while you drag, and the lowest a playing preview steps down to) -- `previews.adaptivePlayback`: Lower Resolution When Playback Can't Keep Up. On by default. A +- `previews.adaptivePlayback`: Lower Resolution When Playback Can't Keep Up. Off by default (without it a first play renders + at the viewer's resolution at the rate frames arrive, and a looping preview plays its later loops + from the cache in real time, as in After Effects; with it the play stays lowered). A preview at Resolution Auto that plays under 80 % of its frame rate (frames render slower than that, so it plays them at the rate they arrive rather than freezing between them) steps down a resolution (half the scale after two seconds, a quarter two seconds later if still slow, From a10c7fa2cdafb15ea3012781ab6a5f421d7c211d Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 15:10:35 +0100 Subject: [PATCH 058/227] Footage: note that 8-bit and float frames of one movie decode apart In an 8 bpc comp, a movie used by both a plain and a masked layer has an 8-bit and a float entry in the media pool, decoded separately. Rare and only a speed cost, so it is noted where the 8-bit path starts rather than changed. --- crates/render/src/pipeline.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 29ce6d69a..b1ec0ca02 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -1040,6 +1040,9 @@ impl<'a> Renderer<'a> { /// changes them: an 8 bpc render at Full size of plain footage (no masks, colour profile, /// Separate Fields, Frame Blending, Cineon, Invert Alpha or linear light interpretation). /// `None` otherwise, and when the source has no 8-bit frame. + // ponytail: the media pool keeps the 8-bit and the float frame as separate entries, so a + // movie shown by both a plain and a masked layer in one 8 bpc comp is decoded twice per + // frame; derive one from the other in the pool if a comp like that turns out common. #[allow(clippy::too_many_arguments)] fn footage_buf8(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, pf: &Footage, f: &Footage, t: Tick) -> Option { let plain = self.pipe.levels == Some(255.0) From 9143393ce5acc109aa0b62c1bbbe591fad83039e Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:26:21 +0100 Subject: [PATCH 059/227] #945: play preview audio at the selected frame rate (#3416) --- crates/engine/src/preview.rs | 3 +- crates/ui-egui/src/app/playback.rs | 18 +- crates/ui-egui/src/app/prefetch.rs | 6 +- crates/ui-egui/src/audio.rs | 87 +++++++--- crates/ui-egui/src/audio/live_tests.rs | 7 +- crates/ui-egui/src/audio/preview_rate.rs | 199 ++++++++++++++++++++++ crates/ui-egui/tests/it/ui_audio_scrub.rs | 159 +++++++++++++++++ 7 files changed, 448 insertions(+), 31 deletions(-) create mode 100644 crates/ui-egui/src/audio/preview_rate.rs diff --git a/crates/engine/src/preview.rs b/crates/engine/src/preview.rs index 76d21a806..7c0ee02cd 100644 --- a/crates/engine/src/preview.rs +++ b/crates/engine/src/preview.rs @@ -159,7 +159,8 @@ pub struct PreviewPreset { pub pre_roll: f64, pub post_roll: f64, pub play_from: PlayFrom, - /// Frames per second; `None` = Auto (the comp's rate). + /// Composition frames played per second; `None` = Auto (the comp's rate). + /// An override changes preview speed, including the audio's speed and pitch. pub frame_rate: Option, /// Frames skipped between the frames played (0 = every frame). pub skip: u32, diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 51dd04ee1..b68e58f27 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -57,12 +57,10 @@ impl EffectcraftApp { } } - /// The preview includes audio at the comp's frame rate and the comp has something audible. + /// The preview includes audio and the comp has something audible. pub(super) fn audio_plays(&self) -> bool { let (Some(pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false }; - let Some(c) = self.session.project.comp(cid) else { return false }; - // A preview at another frame rate plays slower or faster than real time: silent. - pl.audio && (pl.fps - c.frame_rate.as_f64()).abs() <= 0.01 && effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) + pl.audio && effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) } /// Start audio preview from comp time `t` when [`Self::audio_plays`] and an output device @@ -76,7 +74,17 @@ impl EffectcraftApp { let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return }; let mix = self.session.prefs.audio.preview_sample_rate; let fr = c.frame_rate; - match audio::AudioPlayback::start(dev, self.render_source(), cid, t, fr.tick_of(pl.start), fr.tick_of(pl.end + 1), pl.looping, mix) { + match audio::AudioPlayback::start_at_speed( + dev, + self.render_source(), + cid, + t, + fr.tick_of(pl.start), + fr.tick_of(pl.end + 1), + pl.looping, + mix, + pl.fps / fr.as_f64(), + ) { Ok(a) => self.audio = Some(a), Err(e) => log::warn!("audio preview: {e}"), } diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 22e587643..4287ea8f6 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -1,4 +1,4 @@ -use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio, frames}; +use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, frames}; /// Seconds without a project change before paused prefetch resumes (see /// [`EffectcraftApp::editing`]). @@ -321,7 +321,7 @@ impl EffectcraftApp { return true; } let snap = |f: i64| wa + (f - wa).div_euclid(step) * step; - let target = snap(audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb)); + let target = snap(fr.frame_at(a.clock_time()).clamp(wa, wb)); if !video { self.playback.audio_frame = Some(target); } else if self.frames.is_cached(&FrameKey { frame: target, ..series }) { @@ -350,7 +350,7 @@ impl EffectcraftApp { fn sound_frame(&self, fr: effectcraft_engine::time::FrameRate, wa: i64, wb: i64, step: i64) -> Option { let a = self.audio.as_ref()?; let step = step.max(1); - Some(wa + (audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb) - wa).div_euclid(step) * step) + Some(wa + (fr.frame_at(a.clock_time()).clamp(wa, wb) - wa).div_euclid(step) * step) } /// The last cached frame after the current one and before `target`, in play order: what a diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 960aa71e2..e0d6b4d1b 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -10,7 +10,7 @@ //! **A/V sync:** while audio plays, the audio clock drives playback. The clock is the number of //! sample frames the device has actually consumed (underruns do not advance it) minus the //! device's output latency, mapped through the work-area loop ([`clock_sample`]); the viewer -//! shows the comp frame at that sample ([`frame_of_sample`]), dropping video frames rather +//! shows the comp frame at that time ([`AudioPlayback::clock_time`]), dropping video frames rather //! than drifting. //! //! The feed also tracks per-channel peaks for the Audio panel's VU meters ([`Meter`]). @@ -28,6 +28,9 @@ use effectcraft_engine::time::{FrameRate, Tick}; use crate::frames::RenderSource; +mod preview_rate; +use preview_rate::PreviewRate; + /// An audio output (implemented by the native app with cpal). pub trait AudioDevice: Send { /// Device sample rate in Hz. @@ -141,7 +144,7 @@ impl AudioFeed { } } -/// What a preview plays, in comp sample indices at the device rate. +/// What a preview plays, in output sample coordinates on its playback timeline. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PlaySpan { pub start: i64, @@ -159,15 +162,23 @@ impl PlaySpan { } } -/// The comp sample index audible now: `consumed` frames played by the device minus its +/// The output sample coordinate audible now: `consumed` frames played by the device minus its /// `latency`, from the span start, wrapped into the work area when looping (clamped at its end /// otherwise). pub fn clock_sample(span: &PlaySpan, consumed: u64, latency: u64) -> i64 { - let s = span.start + consumed.saturating_sub(latency) as i64; - if s < span.wb { - return s; + // Slow previews can span more output samples than fit in a signed difference. Keep the + // elapsed count and loop width wide until the result is back inside the span. + let s = i128::from(span.start) + i128::from(consumed.saturating_sub(latency)); + if s < i128::from(span.wb) { + return s as i64; + } + if span.looping { + let width = (i128::from(span.wb) - i128::from(span.wa)).max(1); + let wrapped = i128::from(span.wa) + (s - i128::from(span.wb)).rem_euclid(width); + wrapped.clamp(i64::MIN as i128, i64::MAX as i128) as i64 + } else { + span.wb.saturating_sub(1) } - if span.looping { span.wa + (s - span.wb).rem_euclid((span.wb - span.wa).max(1)) } else { span.wb - 1 } } /// Comp time of sample index `s` at `rate` Hz. @@ -196,12 +207,13 @@ pub struct AudioPlayback { mix: u32, pub rate: u32, pub span: PlaySpan, + timeline: PreviewRate, } /// The mixer's position and its view of the project. struct Feeder { src: RenderSource, - /// The next comp sample index to mix. + /// The next output sample index to mix (mapped to composition time by the preview rate). cursor: i64, /// Bumped when the project changes: a block mixed from an older project is dropped. generation: u64, @@ -219,7 +231,7 @@ impl AudioPlayback { /// at (resampled to the device's rate when they differ; 0 = the device's rate). #[allow(clippy::too_many_arguments)] pub fn start( - mut device: Box, + device: Box, src: RenderSource, comp: ItemId, start: Tick, @@ -227,16 +239,34 @@ impl AudioPlayback { wb: Tick, looping: bool, mix_rate: u32, + ) -> Result { + Self::start_at_speed(device, src, comp, start, wa, wb, looping, mix_rate, 1.0) + } + + /// Start at a preview speed relative to composition time. Both the sound (including pitch) + /// and its composition clock follow this speed; the physical device rate stays unchanged. + #[allow(clippy::too_many_arguments)] + pub fn start_at_speed( + mut device: Box, + src: RenderSource, + comp: ItemId, + start: Tick, + wa: Tick, + wb: Tick, + looping: bool, + mix_rate: u32, + speed: f64, ) -> Result { let rate = device.sample_rate().max(8000); let mix = if mix_rate == 0 { rate } else { mix_rate.clamp(8000, 192_000) }; - let span = PlaySpan::new(start, wa, wb, looping, rate); + let timeline = PreviewRate::new(rate, speed, start)?; + let span = timeline.span(start, wa, wb, looping); let feed = Arc::new(AudioFeed::default()); let stop = Arc::new(AtomicBool::new(false)); let state = Arc::new(Mutex::new(Feeder { src, cursor: span.start, generation: 0 })); if cfg!(target_arch = "wasm32") { // The device takes samples only in `pump`, which fills the queue first. - let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, state, comp, mix, rate, span }; + let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, state, comp, mix, rate, span, timeline }; p.device.start(feed)?; p.pump(); return Ok(p); @@ -253,7 +283,7 @@ impl AudioPlayback { let target = queue_target(rate); while !stop.load(Ordering::Relaxed) { if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) { - feed_block(&state, comp, &feed, &span, rate, mix); + feed_block(&state, comp, &feed, &span, timeline, mix); } else { std::thread::sleep(std::time::Duration::from_millis(4)); } @@ -262,7 +292,7 @@ impl AudioPlayback { .map_err(|e| e.to_string())? }; device.start(feed.clone())?; - Ok(AudioPlayback { feed, device, stop, thread: Some(thread), state, comp, mix, rate, span }) + Ok(AudioPlayback { feed, device, stop, thread: Some(thread), state, comp, mix, rate, span, timeline }) } /// Every UI frame while playing: on wasm32 mix ahead into the feed (what the feeder thread @@ -271,7 +301,7 @@ impl AudioPlayback { if cfg!(target_arch = "wasm32") { let target = queue_target(self.rate); while self.feed.queued_frames() < target && !self.feed.finished.load(Ordering::Relaxed) { - feed_block(&self.state, self.comp, &self.feed, &self.span, self.rate, self.mix); + feed_block(&self.state, self.comp, &self.feed, &self.span, self.timeline, self.mix); } } self.device.pump(); @@ -310,9 +340,15 @@ impl AudioPlayback { } } - /// The comp sample index audible now. + /// The comp sample index audible now, expressed at the device's rate. pub fn clock(&self) -> i64 { - clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames()) + let sample = clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames()); + if self.timeline.normal() { sample } else { self.timeline.time(sample).to_units_floor(i64::from(self.rate)) } + } + + /// Composition time under the audible output sample, including preview speed and latency. + pub fn clock_time(&self) -> Tick { + self.timeline.time(clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames())) } pub fn finished(&self) -> bool { @@ -399,7 +435,7 @@ fn audio_changed_inner(old: &Project, new: &Project, comp: ItemId, seen: &mut Ha /// Mix one block at the feeder's cursor into the feed and move the cursor on (wrapping or /// finishing at the work-area end). The block is mixed without holding the lock, so a project /// change never waits for a slow mix; a block mixed from the old project is dropped. -fn feed_block(state: &Mutex, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, rate: u32, mix: u32) { +fn feed_block(state: &Mutex, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, timeline: PreviewRate, mix: u32) { let Ok((src, mut cursor, generation)) = state.lock().map(|s| (s.src.clone(), s.cursor, s.generation)) else { // Poisoned: nothing more can be mixed. feed.finished.store(true, Ordering::Relaxed); @@ -410,8 +446,14 @@ fn feed_block(state: &Mutex, comp: ItemId, feed: &AudioFeed, span: &Play cursor = span.wa; } if cursor < span.wb { - let n = ((span.wb - cursor) as usize).min(BLOCK); - block = Some(mix_block(&src, comp, sample_time(cursor, rate), n, rate, mix)); + let n = span.wb.saturating_sub(cursor).clamp(0, BLOCK as i64) as usize; + block = Some(if timeline.normal() { + mix_block(&src, comp, timeline.time(cursor), n, timeline.rate(), mix) + } else { + timeline.mix(cursor, n, mix, |t, frames| { + effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, frames, mix) + }) + }); cursor += n as i64; } let Ok(mut s) = state.lock() else { return }; @@ -587,6 +629,13 @@ mod tests { assert_eq!(clock_sample(&once, 100_000, 0), 47999); } + #[test] + fn slow_preview_clocks_do_not_overflow_a_wide_output_span() { + let span = PlaySpan { start: 0, wa: i64::MIN, wb: i64::MAX, looping: true }; + assert_eq!(clock_sample(&span, u64::MAX, 0), 0); + assert_eq!(clock_sample(&PlaySpan { looping: false, ..span }, u64::MAX, 0), i64::MAX - 1); + } + #[test] fn frame_mapping_matches_comp_frames() { let fps = FrameRate::new(30, 1); diff --git a/crates/ui-egui/src/audio/live_tests.rs b/crates/ui-egui/src/audio/live_tests.rs index 6ca45caac..a7b845c3b 100644 --- a/crates/ui-egui/src/audio/live_tests.rs +++ b/crates/ui-egui/src/audio/live_tests.rs @@ -1,6 +1,6 @@ //! #2104: exercise the real mixer and queue without relying on thread timing or an audio device. -use super::{AudioDevice, AudioFeed, AudioPlayback, Feeder, PlaySpan, feed_block}; +use super::{AudioDevice, AudioFeed, AudioPlayback, Feeder, PlaySpan, PreviewRate, feed_block}; use crate::frames::RenderSource; use effectcraft_engine::color::Label; use effectcraft_engine::keyframe::Value; @@ -62,12 +62,13 @@ fn playback(footage: Arc) -> AudioPlayback { mix: 48000, rate: 48000, span: PlaySpan { start: 24, wa: 16, wb: 64, looping: false }, + timeline: PreviewRate::new(48000, 1.0, Tick::ZERO).unwrap(), } } /// Run exactly one production feeder block, without a background refill racing assertions. fn mix(p: &AudioPlayback) { - feed_block(&p.state, p.comp, &p.feed, &p.span, p.rate, p.mix); + feed_block(&p.state, p.comp, &p.feed, &p.span, p.timeline, p.mix); } /// Copy the active project and apply a layer edit through the same snapshot boundary as the UI. @@ -133,7 +134,7 @@ fn a_block_in_flight_cannot_publish_after_a_mute() { let (resume, gate) = mpsc::channel(); let mut p = playback(Arc::new(Gated { entered, resume: Mutex::new(gate) })); let (state, feed, span, comp, rate) = (p.state.clone(), p.feed.clone(), p.span, p.comp, p.rate); - let worker = std::thread::spawn(move || feed_block(&state, comp, &feed, &span, rate, rate)); + let worker = std::thread::spawn(move || feed_block(&state, comp, &feed, &span, PreviewRate::new(rate, 1.0, Tick::ZERO).unwrap(), rate)); waiting.recv_timeout(Duration::from_secs(10)).unwrap(); // The edit completes while the mixer is blocked, without waiting on its work. p.follow_project(edit(&p, |l| l.switches.audio = false)); diff --git a/crates/ui-egui/src/audio/preview_rate.rs b/crates/ui-egui/src/audio/preview_rate.rs new file mode 100644 index 000000000..06124b4c9 --- /dev/null +++ b/crates/ui-egui/src/audio/preview_rate.rs @@ -0,0 +1,199 @@ +//! Preview speed changes composition time, not the output device's sample rate. +//! Keep the clock and the resampler on the same rational output-sample grid. + +use effectcraft_engine::time::{FrameRate, TICKS_PER_SECOND, Tick}; + +use super::PlaySpan; + +/// At most this many composition samples are mixed at once. At very high preview speeds, +/// skip samples that no output frame needs instead of allocating the entire source interval. +const SOURCE_PAGE: usize = 4096; + +#[derive(Clone, Copy)] +pub(super) struct PreviewRate { + rate: u32, + speed: FrameRate, + origin: Tick, +} + +impl PreviewRate { + pub(super) fn new(rate: u32, speed: f64, start: Tick) -> Result { + // A billionth keeps the ratio of decimal preview rates and rational comp rates without + // rounding it to an integer sample rate. Reject unrepresentable values, never mute them. + let num = (speed * 1_000_000_000.0).round(); + if rate == 0 || !speed.is_finite() || speed <= 0.0 || num < 1.0 || num >= i64::MAX as f64 { + return Err("The preview playback rate must be finite, positive and representable".into()); + } + let speed = FrameRate::new(num as i64, 1_000_000_000); + // Keep the existing 1x sample grid. At another speed, anchor the grid to the exact + // requested frame rather than rounding a nonzero start backwards by an output sample. + let origin = if speed.num == speed.den { Tick::ZERO } else { start }; + Ok(Self { rate, speed, origin }) + } + + pub(super) fn rate(self) -> u32 { + self.rate + } + + pub(super) fn normal(self) -> bool { + self.speed.num == self.speed.den + } + + pub(super) fn time(self, sample: i64) -> Tick { + if self.normal() { + // Preserve the legacy grid, including negative samples at unusual device rates. + return super::sample_time(sample, self.rate); + } + self.origin + Tick::from_rational(sample, self.speed.num, i64::from(self.rate) * self.speed.den) + } + + /// First output sample at or after a composition time. Ceiling keeps loop endpoints inside + /// the work area. A fractional loop boundary is quantized by less than one output sample. + fn sample_ceil(self, time: Tick) -> i64 { + let n = i128::from((time - self.origin).0) * i128::from(self.rate) * i128::from(self.speed.den); + let d = i128::from(TICKS_PER_SECOND) * i128::from(self.speed.num); + let sample = n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0); + sample.clamp(i64::MIN as i128, i64::MAX as i128) as i64 + } + + pub(super) fn span(self, start: Tick, wa: Tick, wb: Tick, looping: bool) -> PlaySpan { + if self.normal() { + return PlaySpan::new(start, wa, wb, looping, self.rate); + } + let wa = self.sample_ceil(wa); + PlaySpan { start: 0, wa, wb: self.sample_ceil(wb).max(wa.saturating_add(1)), looping } + } + + /// Source sample and interpolation phase at an absolute output cursor. Computing this + /// from the timeline (not by accumulating block lengths) makes partitioning immaterial. + fn position(self, sample: i64, mix: u32) -> (i64, f32) { + let n = i128::from(self.time(sample).0) * i128::from(mix); + let d = i128::from(TICKS_PER_SECOND); + (n.div_euclid(d) as i64, n.rem_euclid(d) as f32 / d as f32) + } + + pub(super) fn mix(self, cursor: i64, n: usize, mix: u32, mut render: impl FnMut(Tick, usize) -> Vec) -> Vec { + let mut out = Vec::with_capacity(n.saturating_mul(2)); + let mut page = Vec::new(); + let mut first = 0; + for i in 0..n { + let sample = cursor.saturating_add(i as i64); + let (at, fraction) = self.position(sample, mix); + let offset = usize::try_from(at.saturating_sub(first)).ok(); + if page.is_empty() || offset.is_none_or(|i| i >= (page.len() / 2).saturating_sub(1)) { + first = at; + let last = self.position(cursor.saturating_add(n.saturating_sub(1) as i64), mix).0; + let next = self.position(sample.saturating_add(1), mix).0; + let frames = if next.saturating_sub(first) >= SOURCE_PAGE as i64 - 1 { + // No following output sample can reuse this page. Read only this sample + // and its interpolation neighbor, rather than mixing an unused gap. + 2 + } else { + last.saturating_sub(first).saturating_add(2).clamp(2, SOURCE_PAGE as i64) as usize + }; + // mix_comp floors its starting sample. Add the fractional tick back so exotic + // source rates start on `first`, not the preceding composition sample. + let mut t = Tick::from_units(first, i64::from(mix)); + if t.to_units_floor(i64::from(mix)) < first { + t.0 = t.0.saturating_add(1); + } + page = render(t, frames); + } + let index = usize::try_from(at.saturating_sub(first)).unwrap_or(usize::MAX).saturating_mul(2); + for channel in 0..2 { + let a = page.get(index.saturating_add(channel)).copied().unwrap_or(0.0); + let b = page.get(index.saturating_add(2 + channel)).copied().unwrap_or(a); + out.push(a + (b - a) * fraction); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audio::clock_sample; + + #[test] + fn normal_speed_preserves_the_legacy_sample_grid() { + for rate in [44_100, 48_000, 48_001] { + let timeline = PreviewRate::new(rate, 1.0, Tick::from_seconds_f64(-0.3)).unwrap(); + for sample in [-48_001, -1, 0, 1, 48_001] { + assert_eq!(timeline.time(sample), crate::audio::sample_time(sample, rate)); + } + } + } + + #[test] + fn speed_clock_matches_slow_fast_and_fractional_previews() { + for (comp, preview) in [(FrameRate::FPS_60, 30.0), (FrameRate::FPS_30, 60.0), (FrameRate::FPS_59_94, 29.97)] { + let speed = preview / comp.as_f64(); + let start = comp.tick_of(17); + let rate = PreviewRate::new(48_000, speed, start).unwrap(); + assert_eq!(rate.time(0), start, "the first frame stays exact"); + let time = rate.time(48_000); + assert!(((time - start).seconds() - speed).abs() < 1e-9); + assert_eq!(comp.frame_at(time), comp.frame_at(start + Tick::from_seconds_f64(speed))); + } + } + + #[test] + fn speed_clock_subtracts_output_latency_and_wraps_inside_the_work_area() { + for speed in [0.5, 2.0, 0.73] { + let start = FrameRate::FPS_29_97.tick_of(19); + let wa = FrameRate::FPS_29_97.tick_of(13); + let wb = FrameRate::FPS_29_97.tick_of(43); + let rate = PreviewRate::new(48_000, speed, start).unwrap(); + let span = rate.span(start, wa, wb, true); + assert_eq!(rate.time(clock_sample(&span, 512, 1024)), start); + assert!(rate.time(span.wb - 1) < wb); + let wrapped = rate.time(clock_sample(&span, span.wb as u64 + 1024, 1024)); + assert!(wrapped >= wa && (wrapped - wa).seconds() < speed / 48_000.0); + let once = PlaySpan { looping: false, ..span }; + assert!(rate.time(clock_sample(&once, u32::MAX as u64, 0)) < wb); + } + } + + fn ramp(t: Tick, n: usize, mix: u32) -> Vec { + let first = t.to_units_floor(i64::from(mix)); + (0..n).flat_map(|i| [(first + i as i64) as f32 / 100_000.0, -(first + i as i64) as f32 / 100_000.0]).collect() + } + + #[test] + fn speed_mix_keeps_fractional_phase_across_uneven_blocks() { + for speed in [0.5, 2.0, 0.73, 29.97 / FrameRate::FPS_59_94.as_f64()] { + let start = FrameRate::FPS_29_97.tick_of(17); + let rate = PreviewRate::new(48_000, speed, start).unwrap(); + for mix in [44_100, 48_000, 96_000] { + let whole = rate.mix(0, 2048, mix, |t, n| ramp(t, n, mix)); + let split: Vec<_> = [(0, 321), (321, 107), (428, 1620)].into_iter().flat_map(|(at, n)| rate.mix(at, n, mix, |t, n| ramp(t, n, mix))).collect(); + assert_eq!(whole, split, "speed {speed}, source rate {mix}"); + for (i, frame) in whole.as_chunks::<2>().0.iter().enumerate() { + let expected = (start.seconds() + i as f64 * speed / 48_000.0) * mix as f64 / 100_000.0; + assert!((f64::from(frame[0]) - expected).abs() < 1e-6); + assert_eq!(frame[1], -frame[0]); + } + } + } + } + + #[test] + fn extreme_speeds_keep_source_reads_bounded_without_changing_speed() { + for speed in [0.00001, 99_900.0] { + let rate = PreviewRate::new(8_000, speed, Tick::ZERO).unwrap(); + let mut rendered = 0; + let out = rate.mix(0, 1024, 192_000, |_, n| { + assert!(n <= SOURCE_PAGE); + rendered += n; + vec![0.25; n * 2] + }); + assert!(rendered <= SOURCE_PAGE, "unused source intervals were mixed: {rendered} samples"); + assert_eq!(out, vec![0.25; 2048]); + assert!((rate.time(8000).seconds() - speed).abs() < 1e-9); + } + for speed in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::MAX] { + assert!(PreviewRate::new(48_000, speed, Tick::ZERO).is_err()); + } + } +} diff --git a/crates/ui-egui/tests/it/ui_audio_scrub.rs b/crates/ui-egui/tests/it/ui_audio_scrub.rs index ae724c9ad..f6baa0443 100644 --- a/crates/ui-egui/tests/it/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/it/ui_audio_scrub.rs @@ -783,3 +783,162 @@ fn unmuting_a_silent_preview_starts_audio_at_its_current_frame() { h.state_mut().stop(); } } + +/// Pull only queued frames: underruns are deliberately excluded so the test measures the +/// feeder's composition mapping, not how quickly a loaded test host schedules its thread. +fn take_preview_samples(app: &EffectcraftApp, frames: usize) -> Vec { + let audio = app.audio.as_ref().expect("preview audio opened"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let mut samples = Vec::with_capacity(frames * 2); + while samples.len() < frames * 2 { + let n = audio.feed.queued_frames().min(frames - samples.len() / 2); + if n == 0 { + assert!(!audio.finished(), "the preview ended before the requested samples"); + assert!(std::time::Instant::now() < deadline, "audio feeder timed out"); + std::thread::sleep(std::time::Duration::from_millis(1)); + continue; + } + let mut block = vec![0.0; n * 2]; + audio.feed.pull(&mut block); + samples.extend(block); + } + samples +} + +#[test] +fn preview_rate_audio_sounds_at_the_selected_speed_and_finishes_at_the_end() { + use effectcraft_engine::preview::{PlayFrom, PreviewRange}; + for (comp_fps, preview_fps, duration_frames) in [(60.0, 30.0, 96_000), (30.0, 60.0, 24_000)] { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 1.0); + app.session.execute("comp.settings", json!({"comp": cid.0, "frameRate": comp_fps})).unwrap(); + let p = &mut app.session.prefs.preview.spacebar; + p.include_video = false; + p.frame_rate = Some(preview_fps); + p.loop_ = false; + p.range = PreviewRange::EntireDuration; + p.play_from = PlayFrom::RangeStart; + app.play(0.0); + assert!(app.audio.is_some(), "a frame-rate override must not mute the preview"); + let samples = take_preview_samples(&app, 12_000); + assert!(samples.iter().any(|v| v.abs() > 0.01)); + let audio = app.audio.as_ref().unwrap(); + assert_eq!(audio.rate, 48_000, "the physical output rate is unchanged"); + assert!((audio.clock_time().seconds() - 0.25 * preview_fps / comp_fps).abs() < 1e-9); + take_preview_samples(&app, duration_frames - 12_000); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !app.audio.as_ref().unwrap().finished() { + assert!(std::time::Instant::now() < deadline, "one-shot audio did not finish"); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(app.audio.as_ref().unwrap().clock_time().seconds() < 1.0); + app.stop(); + } +} + +#[test] +fn preview_rate_audio_resamples_the_sound_instead_of_only_changing_the_clock() { + use effectcraft_engine::preview::{PlayFrom, PreviewRange}; + let render = |preview_fps, frames| { + let fake = Fake::default(); + let (mut app, _) = setup(true, &fake); + let p = &mut app.session.prefs.preview.spacebar; + p.include_video = false; + p.frame_rate = Some(preview_fps); + p.range = PreviewRange::EntireDuration; + p.play_from = PlayFrom::RangeStart; + app.session.prefs.audio.preview_sample_rate = 48_000; + app.play(0.0); + let samples = take_preview_samples(&app, frames); + app.stop(); + samples + }; + let normal = render(30.0, 2048); + let slow = render(15.0, 4096); + for (original, slowed) in normal.as_chunks::<2>().0.iter().zip(slow.as_chunks::<4>().0) { + assert!((original[0] - slowed[0]).abs() < 1e-5); + assert!((original[1] - slowed[1]).abs() < 1e-5); + } +} + +#[test] +fn preview_rate_audio_drives_video_at_half_speed_and_loops() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 1.0); + app.session.execute("comp.settings", json!({"comp": cid.0, "frameRate": 60})).unwrap(); + app.session.prefs.preview.spacebar.frame_rate = Some(30.0); + app.session.prefs.preview.spacebar.loop_ = true; + let gate = GatedFrames::default(); + app.frames.set_remote(Some(Arc::new(gate.clone()))); + let ahead = (app.frames.parallelism() * 2).max(4) as i64; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + gate.discard(&mut h); + h.state_mut().play(0.0); + assert!(h.state().playback.starting, "mismatched-rate audio waits with the first picture"); + let frames: Vec = (0..ahead.min(60)).collect(); + gate.render(&mut h, cid, &frames); + step(&mut h); + assert!(h.state().audio.is_some()); + take_preview_samples(h.state(), 4800); + step(&mut h); + assert_eq!(frame(&h, cid), 3, "0.1 output seconds is 0.05 composition seconds"); + take_preview_samples(h.state(), 96_000 - 4800); + step(&mut h); + assert_eq!(frame(&h, cid), 0, "the audio and picture wrap together"); + assert!(!h.state().audio.as_ref().unwrap().finished()); + h.state_mut().stop(); +} + +#[test] +fn preview_rate_audio_keeps_the_include_audio_switch_and_skip_independent() { + let fake = Fake::default(); + let (mut app, _) = setup(true, &fake); + app.session.prefs.preview.spacebar.include_audio = false; + app.session.prefs.preview.spacebar.frame_rate = Some(60.0); + app.play(0.0); + assert!(app.audio.is_none() && !app.playback.audio_held && !app.playback.starting); + app.stop(); + let p = &mut app.session.prefs.preview.spacebar; + p.include_audio = true; + p.include_video = false; + p.skip = 2; + app.play(0.0); + take_preview_samples(&app, 12_000); + assert_eq!(app.audio.as_ref().unwrap().clock_time(), Tick::from_seconds_f64(0.5), "Skip drops pictures, not audio or time"); + app.stop(); +} + +/// Live edits keep the consumed output cursor at either speed, including a nonzero start. +#[test] +fn preview_rate_audio_keeps_its_position_through_live_mute_and_unmute() { + use effectcraft_engine::preview::{PlayFrom, PreviewRange}; + for preview_fps in [15.0, 60.0] { + let fake = Fake::default(); + let (mut app, cid) = setup(true, &fake); + let comp = app.session.project.comp(cid).unwrap(); + let layer = comp.layers[0].id.0; + let start = comp.frame_rate.tick_of(17); + app.session.set_time(start); + let p = &mut app.session.prefs.preview.spacebar; + p.include_video = false; + p.frame_rate = Some(preview_fps); + p.range = PreviewRange::EntireDuration; + p.play_from = PlayFrom::CurrentTime; + app.play(0.0); + assert_eq!(app.audio.as_ref().unwrap().clock_time(), start); + for (edit, audible) in [(None, true), (Some(false), false), (Some(true), true)] { + if let Some(value) = edit { + app.session.execute("layer.setSwitch", json!({"layers": [layer], "switch": "audio", "value": value})).unwrap(); + app.audio.as_mut().unwrap().follow_project(app.session.project.clone()); + } + let before = app.audio.as_ref().unwrap().clock_time(); + let samples = take_preview_samples(&app, 2400); + assert_eq!(samples.iter().any(|v| v.abs() > 0.01), audible); + let elapsed = (app.audio.as_ref().unwrap().clock_time() - before).seconds(); + assert!((elapsed - 0.05 * preview_fps / 30.0).abs() < 1e-9, "live edit changed the preview cursor"); + } + app.stop(); + } +} From c96a9b1f6e2435f2b6446f8ee3c20ef0726fa9f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 14:36:12 +0000 Subject: [PATCH 060/227] Cylinder Wrap: Radius goes up to 30000 % (#3368) The Radius parameter was limited to 400 %, so a larger value was clamped and drew a smaller cylinder than After Effects' CC Cylinder, whose range the report gives as 0 to 30000 %. Raise the hard maximum; the slider's visible range is unchanged. --- crates/effects/src/perspective.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 69c63caf9..2aa9fe69d 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -394,7 +394,7 @@ pub fn specs() -> Vec { }; let render = || popup(&["Full", "Outside", "Inside"]); let mut cyl = vec![ - p("radius", "Radius", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("radius", "Radius", num(100.0), slider(0.0, 30000.0, 0.0, 200.0, 1)), p("position", "Position", pt(0.5, 0.5), ParamUi::Point), p("rotation", "Rotation", num(0.0), ParamUi::Angle), p("rotationX", "Rotation X", num(0.0), ParamUi::Angle), @@ -565,6 +565,19 @@ mod tests { assert_eq!(out.get(50 + 18, 10)[3], 0.0); } + /// #3368: Radius takes the range After Effects' CC Cylinder has, up to 30000 %, and a cylinder + /// that large still renders (nearly flat, fully opaque) instead of clamping to 400 %. + #[test] + fn cylinder_radius_goes_up_to_thirty_thousand_percent() { + let spec = find("ec.perspective.cccylinder").unwrap(); + let radius = spec.params.iter().find(|p| p.id == "radius").unwrap(); + assert!(matches!(radius.ui, ParamUi::Slider { min, max, .. } if min == 0.0 && max == 30000.0), "{:?}", radius.ui); + let img = Image::filled(100, 20, [0.5, 0.5, 0.5, 1.0]); + let out = run("ec.perspective.cccylinder", &img, &[("radius", num(30000.0))]).img; + let px = out.get(50 + 40, 10); + assert!(px.iter().all(|v| v.is_finite()) && px[3] > 0.99, "{px:?}"); + } + #[test] fn cylinder_axis_rotations_affect_geometry_and_texture() { let img = Image::filled(100, 100, [0.5, 0.5, 0.5, 1.0]); From 22b04320a486f6d03746cc60ae9e14bb8dd9060b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 14:48:14 +0000 Subject: [PATCH 061/227] Viewer: clicking the current-time display opens Go to Time (#3373) The time display in the Composition panel's bottom bar only reacted to Ctrl/Cmd+click, which switches timecode and frames. A plain click now opens the Go to Time dialog (View > Go to Time), as in After Effects. The Ctrl/Cmd+click test follows: its plain click now opens the dialog, which it closes. --- crates/ui-egui/src/panels/viewer_tools.rs | 9 +++++++++ crates/ui-egui/tests/it/ui_inline_edit.rs | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 563657e91..162b40037 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -981,6 +981,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z } app.auto.add_response("viewer.timecode", &resp, "Current time"); crate::panels::toggle_time_display(app, &resp); + go_to_time_on_click(app, &resp); // Right: render time. let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0); if tr.max.x + 50.0 < bar.max.x { @@ -989,6 +990,14 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z (tr.right() + 8.0 - bar.left()).max(bar.width()) } +/// A plain click on the time display opens Go to Time, as it does in After Effects; Ctrl/Cmd+click +/// is [`crate::panels::toggle_time_display`] (#3373). +fn go_to_time_on_click(app: &mut EffectcraftApp, resp: &egui::Response) { + if resp.clicked() && !resp.ctx.input(|i| i.modifiers.command) { + crate::panels::forms::open_form(app, "time.set", &json!({})); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/tests/it/ui_inline_edit.rs b/crates/ui-egui/tests/it/ui_inline_edit.rs index 6c29045bb..ee0b3d35a 100644 --- a/crates/ui-egui/tests/it/ui_inline_edit.rs +++ b/crates/ui-egui/tests/it/ui_inline_edit.rs @@ -438,8 +438,11 @@ fn ctrl_click_on_the_time_display_toggles_timecode_and_frames() { click_with(&mut h, at, Modifiers::COMMAND); assert_eq!(style(&h), TimeDisplayStyle::Timecode); assert_eq!(shown(&h, "timeline.timecode"), "0:00:01:06"); - // A plain click changes nothing. + // A plain click leaves the display alone: it opens Go to Time (#3373), closed here. click_with(&mut h, at, Modifiers::NONE); + assert_eq!(style(&h), TimeDisplayStyle::Timecode); + key(&mut h, Key::Escape); + assert!(h.query_by_label("Go to Time").is_none(), "Go to Time closed"); let at = center(&h, "timeline.timecode"); click_with(&mut h, at, Modifiers::NONE); assert_eq!(style(&h), TimeDisplayStyle::Timecode); @@ -746,3 +749,17 @@ fn typing_one_component_with_several_layers_selected_keeps_their_other_component assert_eq!(pos(&h, a), [40.0, 50.0, -300.0]); assert_eq!(pos(&h, b), [200.0, 120.0, -300.0], "B keeps its own X and Y"); } + +/// #3373: a click on the Composition panel's time display opens Go to Time, as in After Effects; +/// Ctrl/Cmd+click still switches between timecode and frames. +#[test] +fn clicking_the_viewer_time_display_opens_go_to_time() { + let (mut h, _) = harness(); + h.state_mut().session.execute("time.set", json!({"frame": 30})).unwrap(); + h.run_steps(2); + assert!(h.query_by_label("Go to Time").is_none(), "set up: no dialog yet"); + let at = center(&h, "viewer.timecode"); + click(&mut h, at, 1); + h.run_steps(2); + assert!(h.query_by_label("Go to Time").is_some(), "the dialog is open"); +} From 25ce8aff211200c9f0aa0bced0d242e076f6770a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 14:53:27 +0000 Subject: [PATCH 062/227] AEP import: name every unmapped effect in the report, with its instance count (#3365) The import report keeps only the first 20 example lines per category, and each unmapped effect instance was one line in the Effects category. With other losses in that category, an effect used on many layers could be counted without being named. Each effect with no counterpart now gets one line, with how many instances there were ('... and 68 more like it'), placed before the other examples. The category's count is unchanged. --- crates/aep/src/import/effects.rs | 4 +-- crates/aep/src/import/report.rs | 59 +++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index e53d6a9aa..1ab4b208a 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -83,11 +83,11 @@ pub(crate) fn import_effects<'f, 'a>( } let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string()); let Some(map) = EFFECTS.iter().find(|m| m.match_name == fx.match_name()) else { - col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); + col.lose_unmapped_effect(fx.match_name(), format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); continue; }; let Some(spec) = effectcraft_effects::find(map.effect) else { - col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); + col.lose_unmapped_effect(fx.match_name(), format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); continue; }; if defs.source(&fx) == DefinitionSource::Missing { diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index 129fcf143..133f5d367 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -269,8 +269,15 @@ pub(crate) struct Collector { losses: BTreeMap, pub missing_footage: Vec, pub missing_fonts: Vec, + /// The effects with no counterpart, by match name: the first instance's report line and how + /// many instances there were. They get a line each in [`Collector::finish`], so one that + /// comes after the example lines are used up is still named (#3365). + unmapped_effects: BTreeMap, } +/// How many different unmapped effects get a line of their own (a file names at most this many). +const MAX_UNMAPPED_EFFECTS: usize = 100; + impl Collector { /// Records one loss. pub fn lose(&mut self, category: LossCategory, detail: impl Into) { @@ -281,7 +288,29 @@ impl Collector { } } - pub fn finish(self) -> ImportReport { + /// Records one instance of an effect with no counterpart. The loss is counted now; its line is + /// made in [`Collector::finish`], one per effect with the number of instances, so that many + /// other losses cannot hide which effects were dropped. + pub fn lose_unmapped_effect(&mut self, match_name: &str, detail: String) { + let loss = self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new() }); + loss.count = loss.count.saturating_add(1); + if let Some(seen) = self.unmapped_effects.get_mut(match_name) { + seen.1 = seen.1.saturating_add(1); + } else if self.unmapped_effects.len() < MAX_UNMAPPED_EFFECTS { + self.unmapped_effects.insert(match_name.to_string(), (detail, 1)); + } else if loss.details.len() < MAX_DETAILS { + loss.details.push(detail); + } + } + + pub fn finish(mut self) -> ImportReport { + if let Some(loss) = self.losses.get_mut(&LossCategory::Effects) { + let named = std::mem::take(&mut self.unmapped_effects).into_values().map(|(first, n)| match n { + 1 => first, + _ => format!("{first}, and {} more like it", n - 1), + }); + loss.details.splice(0..0, named); + } ImportReport { imported: self.imported, losses: self.losses.into_values().collect(), @@ -301,3 +330,31 @@ pub const LIMITATIONS: &[&str] = &[ "Text: per-character 3D, text on a path and several style slots of unknown meaning are not decoded (stored values that differ from the default are reported as text layer features).", "Footage that is not a solid is linked by its stored path with its stored frame rate and duration; the importer never opens the files (use Reload Footage after the files are found).", ]; + +#[cfg(test)] +mod tests { + use super::*; + + /// #3365: with other losses filling the 20 example lines, an effect with no counterpart still + /// gets its own line, with how many instances were lost. The category's count is unchanged. + #[test] + fn every_unmapped_effect_is_named_however_many_other_losses_there_are() { + let mut col = Collector::default(); + for i in 0..30 { + col.lose(LossCategory::Effects, format!("C: 'L{i}': parameter P not imported")); + } + for i in 0..25 { + col.lose_unmapped_effect("CC Cylinder", format!("C: 'Cyl{i}' has the effect 'CC Cylinder' (CC Cylinder)")); + } + col.lose_unmapped_effect("Pseudo/", "C: 'D' has the effect 'Dropdown' (Pseudo/)".to_string()); + let report = col.finish(); + let effects = report.losses.iter().find(|l| l.category == LossCategory::Effects).unwrap(); + assert_eq!(effects.count, 56, "30 parameters, 25 cylinders and 1 dropdown"); + let named = |what: &str| effects.details.iter().filter(|d| d.contains(what)).collect::>(); + assert_eq!(named("(CC Cylinder)").len(), 1, "{:?}", effects.details); + assert!(named("(CC Cylinder)")[0].contains("24 more"), "{:?}", named("(CC Cylinder)")); + // One instance stays the single line it was. + assert_eq!(named("(Pseudo/)"), ["C: 'D' has the effect 'Dropdown' (Pseudo/)"]); + assert!(effects.details.len() <= MAX_DETAILS + 2, "the examples stay bounded"); + } +} From e356e4adf5dbdf59def7fcbd9cd34a6888d307d6 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 15:58:42 +0100 Subject: [PATCH 063/227] Preview: a stall after playing at full rate still steps down a resolution The step-down was skipped while the achieved frame rate read as keeping up, and that rate comes from the frames shown in the last second they were shown: through a stall it kept the rate from before it, so a preview that played a cached stretch and then stalled never stepped down. A rate whose last frame is more than a second old no longer counts. --- crates/ui-egui/src/app/prefetch.rs | 3 ++- crates/ui-egui/tests/it/ui_preview_align.rs | 24 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 7f6494d3f..a80ae98a9 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -553,7 +553,8 @@ impl EffectcraftApp { || pb.res.is_some() || !self.viewer_res().is_auto() || now - since < 2.0 - || pb.achieved_fps().is_some_and(|f| f * step >= pb.fps * 0.8) + // (a rate from before a stall: nothing shown for a second isn't keeping up) + || (pb.shown_at.back().is_some_and(|t| now - t <= 1.0) && pb.achieved_fps().is_some_and(|f| f * step >= pb.fps * 0.8)) || lower < self.session.prefs.adaptive_limit() - 1e-9 { return; diff --git a/crates/ui-egui/tests/it/ui_preview_align.rs b/crates/ui-egui/tests/it/ui_preview_align.rs index 9d3119b21..a9622d458 100644 --- a/crates/ui-egui/tests/it/ui_preview_align.rs +++ b/crates/ui-egui/tests/it/ui_preview_align.rs @@ -320,6 +320,30 @@ fn cache_frames_when_idle_survives_an_inverted_work_area() { assert!(!h.state().playback.playing); } +/// A preview that played at full rate and then stalls on frames that aren't cached still steps +/// down: the rate from before the stall doesn't count once nothing was shown for a second. (The +/// last second shown kept reading as full rate through the stall, and it never stepped down.) +#[test] +fn playback_that_stalls_after_keeping_up_steps_down() { + let mut h = harness(); + h.state_mut().session.prefs.previews.adaptive_playback = true; + h.state().frames.set_budget(0); + h.state_mut().use_manual_clock(); + invoke(&mut h, "playback.toggle", json!({})); + h.step(); + assert!(h.state().playback.playing); + // A second shown at the comp's rate, just before the stall. + let t0 = h.state().now(); + for i in 0..30 { + h.state_mut().playback.frame_shown(t0 - 1.0 + f64::from(i) / 30.0); + } + for _ in 0..25 { + h.state_mut().clock_mut().advance(0.1); + h.step(); + } + assert_eq!(h.state().viewer_scale(1.0, 1.0), 0.5); +} + /// Settings ▸ Previews ▸ Lower Resolution When Playback Can't Keep Up: a preview at Auto that /// stalls for two seconds steps down to half its scale, two more to a quarter; an explicit Resolution and /// the pref turned off (its default) keep the scale, and stopping goes back to the viewer's. From c330262ef2999cc2ce19f86c76a2d395152d5c2b Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 20:48:05 +0530 Subject: [PATCH 064/227] xtask: run the non-UTF-8 changed-file test off macOS only (#1224) APFS refuses file names that aren't UTF-8 (EILSEQ, "Illegal byte sequence"), so the test's fixture write panicked on the macOS gate before reaching changed_files. Such a file can't exist on macOS; Linux keeps running the test. Co-Authored-By: Claude Opus 5.5 --- xtask/src/affected.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/xtask/src/affected.rs b/xtask/src/affected.rs index 2ce407d27..af56ad0cb 100644 --- a/xtask/src/affected.rs +++ b/xtask/src/affected.rs @@ -601,7 +601,9 @@ let out = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out"); assert_eq!(changed_files(root, "HEAD").unwrap(), files(&[tracked, untracked])); } - #[cfg(unix)] + // macOS (APFS) refuses file names that aren't UTF-8 ("Illegal byte sequence"), so there the + // fixture can't be made and such a change can't happen. + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn changed_files_reject_non_utf8_instead_of_silently_omitting_it() { use std::os::unix::ffi::OsStringExt; From ddb8c958a0d0eeee846f3069bcb2f8f26e868e81 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 15:49:40 +0000 Subject: [PATCH 065/227] Go to Time: show and take the time as the project displays it (#3373) The dialog's Frame field held the engine's zero-based frame, so with frames shown from 1 the viewer's 00013 opened as 12, and typing 13 went to the frame displayed 00014. It now holds the time in the project's display style (timecode, frames from Start Numbering Frames At, or feet + frames), which time.set already reads that way. The now-unused 'Frame' translation rows go. --- crates/ui-egui/src/i18n/ui.rs | 3 --- crates/ui-egui/src/panels/forms.rs | 26 ++++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index a16b879d7..1a162dab3 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -3651,7 +3651,6 @@ const SWEDISH: &[(&str, &str)] = &[ ("Smoothing", "Utjämning"), ("Top", "Överkant"), ("Mode", "Läge"), - ("Frame", "Bildruta"), ("Orientation", "Orientering"), ("Create", "Skapa"), ("Overlap", "Överlappning"), @@ -8866,7 +8865,6 @@ const GEORGIAN_PANELS: &[(&str, &str)] = &[ ("Height", "სიმაღლე"), ("Shape", "ფიგურა"), ("Mode", "რეჟიმი"), - ("Frame", "კადრი"), ("Orientation", "ორიენტაცია"), ("Position (pixels)", "პოზიცია (პიქსელი)"), ("Position (percent of the composition)", "პოზიცია (კომპოზიციის პროცენტი)"), @@ -9849,7 +9847,6 @@ const FRENCH: &[(&str, &str)] = &[ ("Height", "Hauteur"), ("Shape", "Forme"), ("Mode", "Mode"), - ("Frame", "Image"), ("Orientation", "Orientation"), ("Position (pixels)", "Position (pixels)"), ("Position (percent of the composition)", "Position (pourcentage de la composition)"), diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index e83530fa4..9b94360a5 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -630,9 +630,11 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { )], ) } + // The time as the project displays it (timecode, or frames from Start Numbering Frames At, + // or feet + frames), which `time.set` reads back the same way. "time.set" if !has(p, &["time", "frame", "timecode"]) => { - let frame = comp.map(|c| c.frame_rate.frame_at(t)).unwrap_or(0); - ("Go to Time".into(), vec![Field::num("frame", "Frame", frame as f64)]) + let shown = comp.map(|c| crate::panels::timecode(s, c, t)).unwrap_or_default(); + ("Go to Time".into(), vec![Field::text("timecode", "Time", &shown)]) } "view.addGuide" if !has(p, &["position"]) => { let (w, _) = comp.map(|c| (c.width, c.height)).unwrap_or((1920, 1080)); @@ -1108,6 +1110,26 @@ mod tests { assert!(app.dialog.is_none(), "one Enter confirms"); } + /// Go to Time shows the time as the project displays it, so with Frames numbered from 1 the + /// viewer's frame 00013 opens as 00013 and 00020 goes to the frame displayed 00020 (the + /// engine's frame 19). The dialog used to show and take the engine's own frame number (#3373). + #[test] + fn go_to_time_uses_the_projects_frame_numbering() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("comp.new", json!({"name": "Main", "width": 64, "height": 36, "frameRate": 24, "duration": 4})).unwrap(); + app.session.execute("file.projectSettings", json!({"timeDisplay": "frames", "frameStart": 1})).unwrap(); + app.session.execute("time.set", json!({"frame": 12})).unwrap(); + assert!(open_form(&mut app, "time.set", &json!({}))); + let f = &app.dialog_state.form; + assert_eq!(f.command, "time.set"); + assert!(matches!(&f.fields[0].kind, FieldKind::Text(t) if t == "00013"), "{:?}", f.fields[0]); + app.dialog_state.form.fields[0].kind = FieldKind::Text("00020".into()); + let (command, params) = (app.dialog_state.form.command.clone(), app.dialog_state.form.params()); + app.session.execute(&command, params).unwrap(); + let at = app.session.active_comp().unwrap().frame_rate.frame_at(app.session.time()); + assert_eq!(at, 19, "displayed 00020 with frames starting at 1"); + } + /// File ▸ Export ▸ Adobe Premiere Pro Project… opens a form that says it writes Final Cut Pro /// XML; its fields register automation ids and OK runs `file.exportTimeline`. #[test] From bd473e0fd58f311d0a6253b9d88b75a241b65716 Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 10 Nov 2026 12:50:05 -0300 Subject: [PATCH 066/227] #1615: document EFFECTCRAFT_CPU_INFLIGHT in docs/preferences.md --- docs/preferences.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/preferences.md b/docs/preferences.md index 927b66788..b1ae0eda9 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -89,7 +89,11 @@ Commands (CLI, MCP, control channel): one; `EFFECTCRAFT_GPU_MEMCAP_MB` sets it), keeping room for the upload cache to fill. It renders one frame at a time where the memory can't be measured (Metal, WebGPU, GL, or the cap turned off) and once frames have had to wait for memory. The render log names the count. - `EFFECTCRAFT_GPU_INFLIGHT` overrides it. + `EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting: they keep one + frame per thread in flight, so their memory grows with the core count. Set + `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the frames in + flight of every render, `effectcraft-cli render` included + ([#1615](https://github.com/storytold/effectcraft/issues/1615)). - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, the timeline, project panel, render queue and viewer handles From 2226af65f2672586bc6db3313bbc03c62c883893 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 15:51:52 +0000 Subject: [PATCH 067/227] Cylinder Wrap test: make the 30000 % render check fail on a 400 % clamp (#3368) The pixel it read was inside the cylinder at 400 % too. Put the axis on the left edge and read a pixel past the 400 % radius, with a 400 % run showing it is empty. --- crates/effects/src/perspective.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 2aa9fe69d..8f55bc408 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -572,9 +572,13 @@ mod tests { let spec = find("ec.perspective.cccylinder").unwrap(); let radius = spec.params.iter().find(|p| p.id == "radius").unwrap(); assert!(matches!(radius.ui, ParamUi::Slider { min, max, .. } if min == 0.0 && max == 30000.0), "{:?}", radius.ui); + // The cylinder's axis on the left edge: at 400 % its radius is 400 / 100 * 100 / 2π ≈ 63.7 px, + // so a pixel 70 px along is past it there and inside the 30000 % one. let img = Image::filled(100, 20, [0.5, 0.5, 0.5, 1.0]); - let out = run("ec.perspective.cccylinder", &img, &[("radius", num(30000.0))]).img; - let px = out.get(50 + 40, 10); + let at_edge = |radius: f64| run("ec.perspective.cccylinder", &img, &[("radius", num(radius)), ("position", Value::Vec2([0.0, 10.0]))]).img; + let clamped = at_edge(400.0).get(70, 10); + assert_eq!(clamped[3], 0.0, "beyond a 400 % cylinder: {clamped:?}"); + let px = at_edge(30000.0).get(70, 10); assert!(px.iter().all(|v| v.is_finite()) && px[3] > 0.99, "{px:?}"); } From 4ca93da78fbb256027f867804d3476193c743303 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 15:54:19 +0000 Subject: [PATCH 068/227] AEP import report: name at most 100 different unmapped effects (#3365) An effect past the limit was still added to the example lines when they had room, so a report could name up to 120. Past the limit an effect is only counted. --- crates/aep/src/import/report.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index 133f5d367..2825b61b3 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -298,9 +298,8 @@ impl Collector { seen.1 = seen.1.saturating_add(1); } else if self.unmapped_effects.len() < MAX_UNMAPPED_EFFECTS { self.unmapped_effects.insert(match_name.to_string(), (detail, 1)); - } else if loss.details.len() < MAX_DETAILS { - loss.details.push(detail); } + // (Past the limit an effect is only counted: its name is not worth a line of its own.) } pub fn finish(mut self) -> ImportReport { @@ -357,4 +356,18 @@ mod tests { assert_eq!(named("(Pseudo/)"), ["C: 'D' has the effect 'Dropdown' (Pseudo/)"]); assert!(effects.details.len() <= MAX_DETAILS + 2, "the examples stay bounded"); } + + /// A file with more than [`MAX_UNMAPPED_EFFECTS`] different unmapped effects names that many + /// and counts the rest, whatever room the example lines have. + #[test] + fn at_most_the_limit_of_unmapped_effects_are_named() { + let mut col = Collector::default(); + for i in 0..=MAX_UNMAPPED_EFFECTS { + col.lose_unmapped_effect(&format!("Effect {i}"), format!("C: 'L' has the effect 'Effect {i}' (Effect {i})")); + } + let report = col.finish(); + let effects = report.losses.iter().find(|l| l.category == LossCategory::Effects).unwrap(); + assert_eq!(effects.count, MAX_UNMAPPED_EFFECTS + 1, "every instance is counted"); + assert_eq!(effects.details.len(), MAX_UNMAPPED_EFFECTS, "{} lines", effects.details.len()); + } } From 114fbaed6335394be86e073badaede6c410e6bdb Mon Sep 17 00:00:00 2001 From: FredApps Date: Tue, 10 Nov 2026 17:22:44 +0100 Subject: [PATCH 069/227] Preview: the pace measures its lead over no more frames than the RAM preview holds The pace counted cached frames among the next ahead x 2, but the prefetch never requests more than the RAM preview holds (offsets 0..fit-1 from the current frame). With room for only a few frames the lead could never fill, so a preview that rendered every frame at once was slowed to a fraction of its frame rate, with Lower Resolution When Playback Can't Keep Up off (the default). The lead is now counted over at most the frames the prefetch keeps after the current one, and with none of those (room for at most two frames) the clock runs in real time as before pacing. --- crates/ui-egui/src/app/prefetch.rs | 48 ++++++++++++++++++--- crates/ui-egui/tests/it/ui_preview_align.rs | 41 ++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index a80ae98a9..114f05b4c 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -54,6 +54,17 @@ fn pace_for(prev: f64, lead: i64, target: i64, fps: f64, dt: f64) -> f64 { prev + (raw - prev) * k } +/// How many frames after the current one the pace measures its lead over: twice the prefetch +/// depth `ahead`, but only frames the RAM preview can keep cached together with the current one. +/// `prefetch_playback` requests offsets `0..fit_ahead` from the current frame (the current frame +/// and `fit_ahead - 1` after it), so a lead counted over more frames than that could never fill: +/// a preview that renders every frame at once would still be slowed down to a fraction of its +/// frame rate. 0 when not even one frame after the current one is prefetched: the pace can't +/// measure anything, and the clock runs in real time (holding on frames that aren't cached). +fn pace_horizon(ahead: i64, fit_ahead: i64) -> i64 { + (ahead * 2).max(1).min(fit_ahead - 1).max(0) +} + /// Cached frames (`cached`, sorted) among the next `max` frames `after(1..=max)` plays, and how /// many frames it plays (`after` is `None` past the end). Counted, not the consecutive run: one /// late frame (a GPU warm-up frame, a heavy one) with frames cached beyond it holds the clock @@ -145,7 +156,7 @@ impl EffectcraftApp { if self.advance_audio_clock(&pass, step) { return; } - self.advance_frame_clock(&pass, step); + self.advance_frame_clock(&pass, step, fit_ahead); } /// While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few @@ -428,7 +439,7 @@ impl EffectcraftApp { /// frame (or holds when it is not cached yet). While frames arrive slower than the frame /// rate it runs at their pace ([`pace_for`]): every frame shows, evenly, as After Effects /// plays a preview it hasn't cached yet; real time again once the frames ahead are cached. - fn advance_frame_clock(&mut self, pass: &PlaybackPass<'_>, step: i64) { + fn advance_frame_clock(&mut self, pass: &PlaybackPass<'_>, step: i64, fit_ahead: i64) { let PlaybackPass { ctx, now, plan, video, frame_rate: fr, cur, series, .. } = *pass; let FrameWindow { last: wb, .. } = pass.window; // The plan carries the rate the preview was started with (and `play_with` copies it to @@ -439,10 +450,16 @@ impl EffectcraftApp { self.playback.paced_at = Some(now); if video { // The lead counts out of twice the prefetch depth: the prefetch keeps that many - // frames coming (or the frames left before the end), so a full lead means rendering keeps up. - let (lead, target) = self.cached_lead(pass, (pass.ahead * 2).max(1)); + // frames coming (or the frames left before the end), so a full lead means rendering + // keeps up. No more than the RAM preview holds ([`pace_horizon`]). + let horizon = pace_horizon(pass.ahead, fit_ahead); let prev = if self.playback.pace > 0.0 { self.playback.pace } else { fps }; - let pace = pace_for(prev, lead, target, fps, dt); + let pace = if horizon > 0 { + let (lead, target) = self.cached_lead(pass, horizon); + pace_for(prev, lead, target, fps, dt) + } else { + fps + }; self.playback.pace = pace; if pace < fps * 0.8 { self.step_down(now, pass.scale); @@ -662,7 +679,26 @@ impl EffectcraftApp { #[cfg(test)] mod pace_tests { - use super::{lead_of, pace_for}; + use super::{lead_of, pace_for, pace_horizon}; + + /// The lead is measured over no more frames than the RAM preview keeps ahead of the current + /// one, so a full cache plays in real time. (It counted out of twice the prefetch depth: with + /// room for 4 frames, at most 2 after the current one were ever cached, and a preview that + /// rendered at once played at a quarter of its frame rate.) + #[test] + fn the_pace_horizon_fits_in_the_ram_preview() { + // A roomy budget: twice the prefetch depth. + assert_eq!(pace_horizon(6, 400), 12); + // Room for 4 frames: the prefetch requests offsets 0..3, so 2 after the current one. + assert_eq!(pace_horizon(6, 3), 2); + let (lead, target) = lead_of(|k| Some(10 + k), &[10, 11, 12], pace_horizon(6, 3)); + assert!((pace_for(30.0, lead, target, 30.0, 10.0) - 30.0).abs() < 1e-6, "a full cache plays in real time"); + // Room for the current frame only (or nothing): no lead to measure. + assert_eq!(pace_horizon(6, 1), 0); + assert_eq!(pace_horizon(6, 0), 0); + // No prefetch depth: still the next frame. + assert_eq!(pace_horizon(0, 400), 1); + } /// The pace follows the cached lead: real time with a full lead, a third of it with a third, /// never under an eighth, unchanged with nothing ahead, and smoothed over time. diff --git a/crates/ui-egui/tests/it/ui_preview_align.rs b/crates/ui-egui/tests/it/ui_preview_align.rs index a9622d458..0baafc55c 100644 --- a/crates/ui-egui/tests/it/ui_preview_align.rs +++ b/crates/ui-egui/tests/it/ui_preview_align.rs @@ -395,3 +395,44 @@ fn playback_that_cannot_keep_up_steps_down_a_resolution() { stall(&mut h, 2.5); assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); } + +/// A preview that renders every frame at once plays in real time even when the RAM preview holds +/// only a few frames. (The pace measured its lead over twice the prefetch depth, more frames than +/// a small RAM preview keeps ahead, so such a preview was slowed to a fraction of its frame rate.) +#[test] +fn a_preview_that_keeps_up_plays_in_real_time_with_a_small_ram_preview() { + let mut h = harness(); + let cid = h.state().session.active_comp_id().unwrap(); + let series = h.state().shown_series(cid); + let s = series.scale as f64 / 1000.0; + let frame = (320.0 * s).ceil() * (180.0 * s).ceil() * 4.0; + // Room for 4 frames (90 % of 5): the prefetch keeps the current frame and 2 after it. + h.state().frames.set_budget((frame * 5.0) as usize); + h.state_mut().use_manual_clock(); + h.state_mut().session.set_time(Tick::ZERO); + invoke(&mut h, "playback.toggle", json!({})); + let fr = h.state().session.active_comp().unwrap().frame_rate; + // Rendering keeps up: the frames ahead are rendered before the clock moves on. + let settle = |h: &mut Harness<'_, EffectcraftApp>| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline { + h.step(); + let cur = fr.frame_at(h.state().session.time()); + let cached = h.state().frames.cached_frames(&series); + if (1..=2).all(|k| cached.contains(&((cur + k) % 240))) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + panic!("frames ahead never cached"); + }; + for _ in 0..48 { + settle(&mut h); + h.state_mut().clock_mut().advance(1.0 / 24.0); + h.step(); + } + let pb = &h.state().playback; + assert!(pb.playing); + assert!(pb.pace >= 24.0 * 0.98, "plays at {} fps of 24", pb.pace); + assert!(fr.frame_at(h.state().session.time()) >= 40, "the clock kept real time: {:?}", h.state().session.time()); +} \ No newline at end of file From 9eb9ac003b2060fc433dc176a42d9dfccdfe7609 Mon Sep 17 00:00:00 2001 From: FredApps Date: Tue, 10 Nov 2026 17:56:26 +0100 Subject: [PATCH 070/227] Preview: end the small-RAM-preview pacing test with a newline (cargo fmt) --- crates/ui-egui/tests/it/ui_preview_align.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/ui_preview_align.rs b/crates/ui-egui/tests/it/ui_preview_align.rs index 0baafc55c..afafa8a45 100644 --- a/crates/ui-egui/tests/it/ui_preview_align.rs +++ b/crates/ui-egui/tests/it/ui_preview_align.rs @@ -435,4 +435,4 @@ fn a_preview_that_keeps_up_plays_in_real_time_with_a_small_ram_preview() { assert!(pb.playing); assert!(pb.pace >= 24.0 * 0.98, "plays at {} fps of 24", pb.pace); assert!(fr.frame_at(h.state().session.time()) >= 40, "the clock kept real time: {:?}", h.state().session.time()); -} \ No newline at end of file +} From aa4adf1c382f35474241afd6d2f80c8e7bdce5ca Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 10 Nov 2026 14:18:54 -0300 Subject: [PATCH 071/227] #1615: note the frames the renderer measurement keeps in EFFECTCRAFT_CPU_INFLIGHT docs --- docs/preferences.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/preferences.md b/docs/preferences.md index b1ae0eda9..ce628cde1 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -93,7 +93,9 @@ Commands (CLI, MCP, control channel): frame per thread in flight, so their memory grows with the core count. Set `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the frames in flight of every render, `effectcraft-cli render` included - ([#1615](https://github.com/storytold/effectcraft/issues/1615)). + ([#1615](https://github.com/storytold/effectcraft/issues/1615)). One exception: while a job + on GPU Acceleration times both compositors on its first frames, it renders them under the + cap but keeps them all (up to 12) until it has chosen. - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, the timeline, project panel, render queue and viewer handles From 5d1b6ce3f2e06b5d51cacba13c43e9da2d19dd87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Tue, 10 Nov 2026 04:58:07 +0100 Subject: [PATCH 072/227] gpu: match CPU rounding in box blur rows Horizontal GPU box passes restarted the running sum every 256 pixels, while the CPU keeps one sum per row; use the full row width as the block so f32 rounding matches. The regression test requires exact equality on llvmpipe (CI); other adapters only get an absolute 2e-6 sanity bound. --- crates/gpu/src/effects.rs | 4 +++- crates/gpu/src/shaders/kernels.wgsl | 2 +- crates/gpu/src/tests.rs | 34 +++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index b33c15e99..fd5a97c38 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -274,7 +274,9 @@ pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize] let mut cur = img.clone(); let passes = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0); for (vertical, r) in passes { - let block = if vertical { (4 * r).clamp(32, 256) } else { 256 } as u32; + // Vertical sums restart every `block` rows as the CPU's do; a row is one running sum, as + // on the CPU (restarting it rounds differently). + let block = if vertical { (4 * r).clamp(32, 256) as u32 } else { cur.width.max(1) }; let (lines, n) = if vertical { (cur.width, cur.height) } else { (cur.height, cur.width) }; let mut p = Params::default(); p.u[0] = [r as u32, repeat as u32, block, 0]; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index bb4a5e63a..a123b70ca 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -270,7 +270,7 @@ fn line_get(horizontal: bool, line: i32, i: i32, n: i32, repeat: bool) -> vec4, horizontal: bool) { let dims = out_dims(); let r = i32(P.u[0].x); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 7b85bfc85..b0587b2e2 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -731,6 +731,40 @@ fn small_blurs_fall_back_to_the_cpu() { } } +/// Horizontal GPU box passes used to restart the running sum every 256 px; the CPU keeps one sum +/// per row, so images wider than 256 disagreed in f32 rounding. Width 320 forces that path. +/// Exact on llvmpipe (CI lavapipe), which fails without the fix; other adapters (Metal measured +/// ~1e-6 either way) only get a 2e-6 sanity bound and do not catch the regression. Metal still +/// differs by ~1e-6 with matching blocks. +#[test] +fn box_blur_horizontal_rows_match_cpu_beyond_256() { + let Some(g) = gpu() else { return }; + let img = pattern(11, 320, 48); + let up = g.ctx.upload_image(&img).expect("upload"); + let tol = if on_llvmpipe() { 0.0 } else { 2e-6 }; + let check_case = |name: &str, want: Image, got: Image| { + assert_eq!((got.width, got.height), (want.width, want.height), "{name}: size"); + let mut max_abs = 0.0f32; + for (a, b) in want.data.iter().zip(&got.data) { + for c in 0..4 { + max_abs = max_abs.max((a[c] - b[c]).abs()); + } + } + assert!(max_abs <= tol, "{name}: max abs channel diff {max_abs} exceeds {tol} on {}", g.ctx.name); + }; + for (name, r) in [("box r5×3", 5usize), ("box r25×3", 25)] { + let mut e = crate::context::Enc::new(&g.ctx); + let rx = [r, r, r]; + let out = crate::effects::box_passes(&mut e, &up, &rx, &rx, false); + check_case(name, effectcraft_raster::box_blur(&img, r, r, 3, false), e.download(&out).expect("download")); + } + for (name, sigma) in [("gauss σ10", 10.0f64), ("gauss σ40", 40.0)] { + let mut e = crate::context::Enc::new(&g.ctx); + let out = crate::effects::gaussian_blur(&mut e, &up, sigma, sigma, false); + check_case(name, effectcraft_raster::gaussian_blur(&img, sigma, sigma, false), e.download(&out).expect("download")); + } +} + #[test] fn gpu_effects_match_cpu() { // Sigma 3 px and up and even radii, so the half-scale pass stays on the GPU too (#1939). From 513db8f42140fa4030e8fcf511b1c28a966f82d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Tue, 10 Nov 2026 09:33:06 +0100 Subject: [PATCH 073/227] gpu: assert box-blur row parity with diff(), exact only on llvmpipe Reuse the existing diff() helper so a non-finite pixel fails the test (f32::max ignored NaN). Keep exact equality on llvmpipe, which still fails without the row-block fix; other adapters use the usual Bpc32 tolerance, because a 2e-6 bound can fail on unmeasured hardware and does not catch the regression there. --- crates/gpu/src/tests.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index b0587b2e2..1d2782262 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -733,24 +733,19 @@ fn small_blurs_fall_back_to_the_cpu() { /// Horizontal GPU box passes used to restart the running sum every 256 px; the CPU keeps one sum /// per row, so images wider than 256 disagreed in f32 rounding. Width 320 forces that path. -/// Exact on llvmpipe (CI lavapipe), which fails without the fix; other adapters (Metal measured -/// ~1e-6 either way) only get a 2e-6 sanity bound and do not catch the regression. Metal still -/// differs by ~1e-6 with matching blocks. +/// Exact on llvmpipe (CI lavapipe), which fails without the fix; other adapters use the usual +/// Bpc32 tolerance because a tighter bound can fail on unmeasured hardware and does not catch +/// the regression (Metal still differs by ~1e-6 with or without the fix). #[test] fn box_blur_horizontal_rows_match_cpu_beyond_256() { let Some(g) = gpu() else { return }; let img = pattern(11, 320, 48); let up = g.ctx.upload_image(&img).expect("upload"); - let tol = if on_llvmpipe() { 0.0 } else { 2e-6 }; + let tol = if on_llvmpipe() { 0.0 } else { tolerance(BitDepth::Bpc32) }; let check_case = |name: &str, want: Image, got: Image| { assert_eq!((got.width, got.height), (want.width, want.height), "{name}: size"); - let mut max_abs = 0.0f32; - for (a, b) in want.data.iter().zip(&got.data) { - for c in 0..4 { - max_abs = max_abs.max((a[c] - b[c]).abs()); - } - } - assert!(max_abs <= tol, "{name}: max abs channel diff {max_abs} exceeds {tol} on {}", g.ctx.name); + let d = diff(&want, &got, tol); + assert_eq!(d.over, 0, "{name}: max {} at {:?} on {}", d.max, d.worst, g.ctx.name); }; for (name, r) in [("box r5×3", 5usize), ("box r25×3", 25)] { let mut e = crate::context::Enc::new(&g.ctx); From f541597d512e6ca803d8dd487044076461582ba0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:55:32 -0500 Subject: [PATCH 074/227] fix(export): encode WebM audio packets in place instead of draining each from the front (#2982) --- crates/export/src/webm.rs | 8 +++++--- crates/export/src/webm_av1.rs | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/export/src/webm.rs b/crates/export/src/webm.rs index a12bd4e1a..976eaf6e0 100644 --- a/crates/export/src/webm.rs +++ b/crates/export/src/webm.rs @@ -244,12 +244,14 @@ pub(crate) async fn webm(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Sta let pad = (fs - pcm_left.len() % fs) % fs + fs; pcm_left.extend(std::iter::repeat_n(0.0, pad)); } - while pcm_left.len() >= fs { - let pkt = enc.encode_float(&pcm_left[..fs]); - pcm_left.drain(..fs); + // Packets are read in place and the encoded samples dropped once: draining each packet + // from the front would shift the rest of the batch's audio every 20 ms. + for packet in pcm_left.chunks_exact(fs) { + let pkt = enc.encode_float(packet); frames.push(Frame { ms: packets as i64 * 20, track: 2, key: true, prev_ms: None, data: pkt, alpha: None }); packets += 1; } + pcm_left.drain(..pcm_left.len() / fs * fs); }; let batch = batch_size(); diff --git a/crates/export/src/webm_av1.rs b/crates/export/src/webm_av1.rs index a85a5c9ec..2e75257f7 100644 --- a/crates/export/src/webm_av1.rs +++ b/crates/export/src/webm_av1.rs @@ -125,12 +125,14 @@ pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut let pad = (fs - pcm_left.len() % fs) % fs + fs; pcm_left.extend(std::iter::repeat_n(0.0, pad)); } - while pcm_left.len() >= fs { - let pkt = enc.encode_float(&pcm_left[..fs]); - pcm_left.drain(..fs); + // Packets are read in place and the encoded samples dropped once: draining each packet + // from the front would shift the rest of the batch's audio every 20 ms. + for packet in pcm_left.chunks_exact(fs) { + let pkt = enc.encode_float(packet); blocks.push(Block { ms: packets as i64 * 20, track: 2, key: true, data: pkt }); packets += 1; } + pcm_left.drain(..pcm_left.len() / fs * fs); }; let batch = batch_size(); From 48694df7156bec9ea7e3d55335d6752b04881c5e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:56:16 -0500 Subject: [PATCH 075/227] Lottie export: embed only files that are PNG, JPEG, GIF or WebP images (#3008) * fix(lottie): export embeds only files that are PNG, JPEG, GIF or WebP images * address review: match full PNG and GIF signatures --- crates/lottie/src/export.rs | 28 ++++++++++++++++++---------- crates/lottie/src/tests.rs | 32 +++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/crates/lottie/src/export.rs b/crates/lottie/src/export.rs index eeb6e2737..88dcf4ed4 100644 --- a/crates/lottie/src/export.rs +++ b/crates/lottie/src/export.rs @@ -145,16 +145,18 @@ fn hex(c: [f32; 3]) -> String { format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2])) } -fn mime_of(path: &str) -> &'static str { - let lower = path.to_ascii_lowercase(); - if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { - "image/jpeg" - } else if lower.ends_with(".webp") { - "image/webp" - } else if lower.ends_with(".gif") { - "image/gif" +/// The image type `bytes` hold, by their signature; `None` for anything else. +pub(crate) fn image_mime(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png") + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + Some("image/jpeg") + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + Some("image/gif") + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + Some("image/webp") } else { - "image/png" + None } } @@ -286,7 +288,13 @@ impl Doc<'_> { self.ex.warn(format!("{}: cannot read image `{}` (layer exported as a null)", l.name, f.path)); return None; }; - let uri = format!("data:{};base64,{}", mime_of(&f.path), crate::base64::encode(&bytes)); + // What a project calls a still is embedded only when it is one: the path can name any + // file (an imported animation's asset), and the export would carry its bytes verbatim. + let Some(mime) = image_mime(&bytes) else { + self.ex.warn(format!("{}: `{}` is not a PNG, JPEG, GIF or WebP image (layer exported as a null)", l.name, f.path)); + return None; + }; + let uri = format!("data:{mime};base64,{}", crate::base64::encode(&bytes)); self.assets.push(json!({"id": id, "nm": it.name, "w": f.width, "h": f.height, "u": "", "p": uri, "e": 1})); self.done.insert(id.clone()); Some(id) diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 414c875f0..b941b4f61 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -725,7 +725,7 @@ fn effects_expressions_and_warnings() { #[test] fn images_embed_as_base64_and_come_back() { let (mut p, cid) = setup(); - let png: Vec = vec![0x89, b'P', b'N', b'G', 1, 2, 3, 4, 5]; + let png: Vec = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, 5]; let fid = p.add_item( "pic.png", Label::Lavender, @@ -775,6 +775,17 @@ fn images_embed_as_base64_and_come_back() { assert!(f.path.starts_with("/tmp/image_") && f.path.ends_with(".png"), "{}", f.path); } +#[test] +fn image_signatures_are_matched_in_full() { + use crate::export::image_mime; + assert_eq!(image_mime(b"\x89PNG\r\n\x1a\n\0"), Some("image/png")); + assert_eq!(image_mime(b"GIF89a\0"), Some("image/gif")); + assert_eq!(image_mime(b"GIF87a\0"), Some("image/gif")); + // Near matches (a prefix of the signature, or text that starts with it) are not images. + assert_eq!(image_mime(b"\x89PNG\x01\x02\x03\x04"), None); + assert_eq!(image_mime(b"GIF8 is a text file"), None); +} + #[test] fn dotlottie_archive_roundtrip() { let (mut p, cid) = setup(); @@ -985,3 +996,22 @@ fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() { assert_eq!(rc(&json)["r"]["k"], json!(5.0)); assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); } + +/// A footage item called a still whose file is not an image is not embedded. +#[test] +fn only_image_bytes_are_embedded() { + let (mut p, cid) = setup(); + let fid = p.add_item( + "secret.png", + Label::Lavender, + None, + ItemKind::Footage(Footage { path: "/virtual/secret.png".into(), kind: FootageKind::Still, width: 8, height: 8, has_video: true, ..Default::default() }), + ); + let c = comp(&p, cid); + let l = build::layer(&mut p, &c, "secret.png", LayerSource::Footage { item: fid }, (8, 8), None); + push(&mut p, cid, l); + let read = |path: &str| (path == "/virtual/secret.png").then(|| b"root:x:0:0:root:/root:/bin/sh\n".to_vec()); + let res = export_comp(&p, cid, &ExportOptions::default(), &read).unwrap(); + assert!(res.json["assets"].as_array().is_none_or(|a| a.is_empty()), "{}", res.json["assets"]); + assert!(res.warnings.iter().any(|w| w.contains("not a PNG")), "{:?}", res.warnings); +} From 684c53f1b1d254d32e7858c7a92cfb361275d586 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:56:41 -0500 Subject: [PATCH 076/227] Segment checkpoints: charge memo copies and string bytes; refuse impossible zip sizes; bounded inflate errors (#3006) * Segment checkpoints: charge memo copies and string bytes; refuse impossible zip sizes; bounded inflate errors The pickle reader charged memo reads but not memo writes, and counted a string as one object whatever its length, so memoizing one long string many times kept unbounded copies; writes are charged too and strings count one node per 64 bytes. A zip entry stating more than deflate can expand its data to is refused before inflating, and inflate errors keep only the status, not the partial output. * address review: charge every owned field of Global, Storage and Tensor to the pickle clone budget --- crates/segment/src/pt.rs | 60 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/crates/segment/src/pt.rs b/crates/segment/src/pt.rs index 35ed57297..728cc84f7 100644 --- a/crates/segment/src/pt.rs +++ b/crates/segment/src/pt.rs @@ -108,10 +108,14 @@ fn read_entry<'a>(b: &'a [u8], e: &Entry) -> Result> match e.method { 0 => Ok(std::borrow::Cow::Borrowed(raw)), 8 => { - if e.size > MAX_ENTRY_BYTES { + // Deflate expands at most about 1032:1, so a stated size past that is not this entry's. + if e.size > MAX_ENTRY_BYTES || e.size > e.stored.saturating_mul(1032).saturating_add(64) { return Err("zip entry exceeds the size limit".into()); } - miniz_oxide::inflate::decompress_to_vec_with_limit(raw, e.size.max(1)).map(std::borrow::Cow::Owned).map_err(|e| format!("bad deflate data: {e:?}")) + // The status only: the error's partial output can be as large as the entry. + miniz_oxide::inflate::decompress_to_vec_with_limit(raw, e.size.max(1)) + .map(std::borrow::Cow::Owned) + .map_err(|e| format!("bad deflate data: {:?}", e.status)) } m => Err(format!("unsupported zip compression {m}")), } @@ -162,6 +166,11 @@ fn nodes(o: &Obj, budget: usize) -> usize { let kids: Vec<&Obj> = match o { Obj::Tuple(v) | Obj::List(v) => v.iter().collect(), Obj::Dict(v) => v.iter().flat_map(|(k, x)| [k, x]).collect(), + // A string counts one node per 64 bytes it holds (a vector one per 8 elements): copies + // are charged for everything they own, not as one object. + Obj::Str(s) => return 1 + s.len() / 64, + Obj::Global(a, b) | Obj::Storage(a, b) => return 1 + (a.len() + b.len()) / 64, + Obj::Tensor { dtype, key, size, stride, .. } => return 1 + (dtype.len() + key.len()) / 64 + (size.len() + stride.len()) / 8, _ => return 1, }; let mut n = 1; @@ -209,6 +218,17 @@ fn memo_get(memo: &HashMap, k: u64, cloned: &mut usize) -> Result Ok(o.clone()) } +/// A copy of the stack top to memoize, charged against the clone budget like a memo read: a +/// program memoizing one large object many times keeps a copy each time. +fn memo_put(stack: &[Obj], cloned: &mut usize) -> Result { + let o = stack.last().ok_or("pickle stack underflow")?; + *cloned = cloned.saturating_add(nodes(o, MAX_PICKLE_NODES)); + if *cloned > MAX_PICKLE_NODES { + return Err("pickle exceeds the object limit".into()); + } + Ok(o.clone()) +} + fn unpickle(p: &[u8]) -> Result { let mut stack: Vec = Vec::new(); let mut memo: HashMap = HashMap::new(); @@ -310,15 +330,15 @@ fn unpickle(p: &[u8]) -> Result { } b'q' => { let k = take(&mut i, 1)?[0] as u64; - memo.insert(k, stack.last().cloned().ok_or("pickle stack underflow")?); + memo.insert(k, memo_put(&stack, &mut cloned)?); } b'r' => { let s = take(&mut i, 4)?; - memo.insert(u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64, stack.last().cloned().ok_or("pickle stack underflow")?); + memo.insert(u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64, memo_put(&stack, &mut cloned)?); } 0x94 => { let k = memo.len() as u64; - memo.insert(k, stack.last().cloned().ok_or("pickle stack underflow")?); + memo.insert(k, memo_put(&stack, &mut cloned)?); } b'h' => { let k = take(&mut i, 1)?[0] as u64; @@ -609,4 +629,34 @@ mod tests { assert_eq!(half_to_f32(0x3c00), 1.0); assert_eq!(half_to_f32(0xc000), -2.0); } + + #[test] + fn memoizing_a_long_string_again_and_again_runs_out_of_the_budget() { + // A 1 MiB string memoized 300 times would keep 300 MiB of copies. + let mut p = vec![0x80, 2, b'X']; + p.extend_from_slice(&(1u32 << 20).to_le_bytes()); + p.extend(std::iter::repeat_n(b'a', 1 << 20)); + p.extend(std::iter::repeat_n(0x94, 300)); + p.push(b'.'); + assert!(unpickle(&p).is_err_and(|e| e.contains("object limit"))); + } + + #[test] + fn every_owned_field_is_charged_to_the_clone_budget() { + let long = || "a".repeat(1 << 20); + let budget = MAX_PICKLE_NODES; + // A one-byte module with a 1 MiB name (and the same for storages and tensors). + assert!(nodes(&Obj::Global("m".into(), long()), budget) >= (1 << 20) / 64); + assert!(nodes(&Obj::Storage("m".into(), long()), budget) >= (1 << 20) / 64); + let t = Obj::Tensor { dtype: "m".into(), key: long(), offset: 0, size: vec![1; 4096], stride: vec![1; 4096] }; + assert!(nodes(&t, budget) >= (1 << 20) / 64 + 1024); + // Small ones stay cheap. + assert_eq!(nodes(&Obj::Global("a".into(), "b".into()), budget), 1); + } + + #[test] + fn an_entry_stating_more_than_deflate_can_expand_to_is_refused() { + let e = Entry { data: 0, size: 1 << 29, stored: 4, method: 8 }; + assert!(read_entry(&[0u8; 4], &e).is_err_and(|m| m.contains("size limit"))); + } } From 4020edb8cd4fe3772c0ef95b2d5d3c1ca2fb4ff5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:57:27 -0500 Subject: [PATCH 077/227] PDF fonts and fax: bound charstring work, ToUnicode ranges and reference search (#2964) * PDF fonts and fax: bound charstring work, ToUnicode ranges and reference search Type 1 and CFF charstrings bounded subroutine depth but not total work: subroutines that call each other several times multiplied a small font into unbounded instructions and geometry. Each glyph now has a shared budget of 100,000 instructions. The Type 1 Subrs parser looked up /CharStrings again for every entry; it is found once. A ToUnicode bfrange expanded a long destination string for each of up to 65,536 codes; destinations past 256 bytes are skipped and a CMap keeps at most 2^20 mappings. CCITT 2-D decoding scanned the reference row from its start for every code; it bisects. * address review: cap bfchar and array bfrange unicode mappings * address review: seac components share the glyph instruction budget (CFF and Type 1) --- crates/pdf/src/ccitt.rs | 26 ++++++++++++- crates/pdf/src/cff.rs | 84 +++++++++++++++++++++++++++++++++++++---- crates/pdf/src/font.rs | 17 ++++++++- crates/pdf/src/type1.rs | 41 +++++++++++++++++--- 4 files changed, 153 insertions(+), 15 deletions(-) diff --git a/crates/pdf/src/ccitt.rs b/crates/pdf/src/ccitt.rs index fa013161f..ca6a40d21 100644 --- a/crates/pdf/src/ccitt.rs +++ b/crates/pdf/src/ccitt.rs @@ -401,8 +401,10 @@ fn mode(r: &mut Reader) -> Option { /// (index parity: even indices start black runs), and the one after it. fn b1b2(refl: &[usize], a0: i64, white: bool, columns: usize) -> (usize, usize) { let want = if white { 0 } else { 1 }; - let mut i = 0; - while i < refl.len() && (refl[i] as i64 <= a0 || i % 2 != want) { + // The changing elements are in order, so the first past `a0` is found by bisection (a scan + // from the start made every row quadratic in its transitions). + let mut i = refl.partition_point(|&c| c as i64 <= a0); + if i % 2 != want { i += 1; } let b1 = refl.get(i).copied().unwrap_or(columns); @@ -804,4 +806,24 @@ pub(crate) mod tests { let _ = decode(&garbage, &Params { k, columns: 1, rows: 0, ..Params::default() }, 1 << 20); } } + + /// `b1b2` by bisection agrees with the scan it replaced, for every position and colour + /// over a dense reference row. + #[test] + fn b1b2_by_bisection_matches_the_scan() { + let scan = |refl: &[usize], a0: i64, white: bool, columns: usize| { + let want = if white { 0 } else { 1 }; + let mut i = 0; + while i < refl.len() && (refl[i] as i64 <= a0 || i % 2 != want) { + i += 1; + } + (refl.get(i).copied().unwrap_or(columns), refl.get(i + 1).copied().unwrap_or(columns)) + }; + let refl: Vec = (0..64).map(|i| i * 3 / 2).collect(); + for a0 in -1..100 { + for white in [true, false] { + assert_eq!(b1b2(&refl, a0, white, 100), scan(&refl, a0, white, 100), "a0 {a0} white {white}"); + } + } + } } diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs index 61bb7f9eb..cb7214cc2 100644 --- a/crates/pdf/src/cff.rs +++ b/crates/pdf/src/cff.rs @@ -538,16 +538,31 @@ impl Cff { /// A glyph's outline in glyph space (`font_matrix` maps it to text space) and its advance. pub fn outline(&self, gid: u16) -> Option<(BezPath, f64)> { - self.outline_depth(gid, 0) + self.outline_depth(gid, 0, &mut { MAX_GLYPH_OPS }) } - fn outline_depth(&self, gid: u16, depth: usize) -> Option<(BezPath, f64)> { + /// `ops` is the glyph's instruction budget, shared by the base glyph and its seac components. + fn outline_depth(&self, gid: u16, depth: usize, ops: &mut usize) -> Option<(BezPath, f64)> { let r = self.charstrings.get(gid as usize)?.clone(); let fd = self.fdselect.get(gid as usize).copied().unwrap_or(0) as usize; let fd = fd.min(self.lsubrs.len() - 1); - let mut st = - T2 { cff: self, fd, path: BezPath::new(), x: 0.0, y: 0.0, stack: vec![], stems: 0, width: None, open: false, done: false, depth: 0, seac: None }; + let mut st = T2 { + cff: self, + fd, + path: BezPath::new(), + x: 0.0, + y: 0.0, + stack: vec![], + stems: 0, + width: None, + open: false, + done: false, + depth: 0, + ops: *ops, + seac: None, + }; st.run(&self.data[r]); + *ops = st.ops; if st.open { st.path.close_path(); } @@ -563,10 +578,10 @@ impl Cff { && depth < 2 { let find = |code: u8| crate::encoding::Base::Standard.name(code).and_then(|n| self.gid_by_name(&n)); - if let Some((bp, _)) = find(b).and_then(|g| self.outline_depth(g, depth + 1)) { + if let Some((bp, _)) = find(b).and_then(|g| self.outline_depth(g, depth + 1, ops)) { path.extend(bp); } - if let Some((ap, _)) = find(a).and_then(|g| self.outline_depth(g, depth + 1)) { + if let Some((ap, _)) = find(a).and_then(|g| self.outline_depth(g, depth + 1, ops)) { path.extend(kurbo::Affine::translate((adx, ady)) * ap); } } @@ -646,9 +661,16 @@ struct T2<'a> { open: bool, done: bool, depth: usize, + /// Instructions this glyph may still run, across its subroutine calls. + ops: usize, seac: Option<(f64, f64, u8, u8)>, } +/// The most charstring instructions one glyph runs: subroutines that branch (two calls each, +/// ten levels deep) would otherwise multiply a small font into unbounded work and geometry. +/// A real glyph runs a few hundred. +pub(crate) const MAX_GLYPH_OPS: usize = 100_000; + impl T2<'_> { fn move_to(&mut self, dx: f64, dy: f64) { if self.open { @@ -698,6 +720,11 @@ impl T2<'_> { } let mut i = 0; while i < cs.len() && !self.done { + let Some(left) = self.ops.checked_sub(1) else { + self.done = true; + return; + }; + self.ops = left; let b0 = cs[i]; i += 1; match b0 { @@ -958,6 +985,12 @@ impl T2<'_> { /// named by custom strings. #[cfg(test)] pub(crate) fn write_test_cff(glyphs: &[(&str, Vec)]) -> Vec { + write_test_cff_with_gsubrs(glyphs, &[]) +} + +/// [`write_test_cff`] with global subroutines. +#[cfg(test)] +pub(crate) fn write_test_cff_with_gsubrs(glyphs: &[(&str, Vec)], gsubrs: &[Vec]) -> Vec { fn idx(items: &[Vec]) -> Vec { let mut out = (items.len() as u16).to_be_bytes().to_vec(); if items.is_empty() { @@ -983,7 +1016,7 @@ pub(crate) fn write_test_cff(glyphs: &[(&str, Vec)]) -> Vec { let names = idx(&[b"Test".to_vec()]); let strings: Vec> = glyphs.iter().skip(1).map(|(n, _)| n.as_bytes().to_vec()).collect(); let strings_idx = idx(&strings); - let gsubrs = idx(&[]); + let gsubrs = idx(gsubrs); let cs = idx(&glyphs.iter().map(|(_, c)| c.clone()).collect::>()); // charset format 0: SIDs of glyphs 1.. (custom strings from 391). let mut charset = vec![0u8]; @@ -1090,6 +1123,43 @@ mod tests { assert!(p.elements().is_empty()); } + /// A global subroutine that calls itself ten times: 10^10 calls within the depth limit of + /// ten. The glyph stops at its instruction budget instead. + #[test] + fn branching_subroutines_stop_at_the_instruction_budget() { + // `-107 callgsubr` (bias 107) is subroutine 0. + let call = [32u8, 29]; + let mut subr = call.repeat(10); + subr.push(11); + let mut cs = call.to_vec(); + cs.push(14); + let font = write_test_cff_with_gsubrs(&[(".notdef", vec![14]), ("A", cs)], &[subr]); + let cff = Cff::parse(&font).unwrap(); + let (p, _) = cff.outline(cff.gid_by_name("A").unwrap()).unwrap(); + assert!(p.elements().is_empty()); + } + + /// The instruction budget is one per glyph: a seac whose base glyph spends all of it leaves + /// nothing for the accent, instead of each component getting a fresh budget. + #[test] + fn seac_components_share_the_instruction_budget() { + let call = [32u8, 29]; + let mut subr = call.repeat(10); + subr.push(11); + // A square, then the branching subroutine that exhausts the budget. + let mut a = vec![139 + 100, 139 + 100, 139 - 100, 6]; + a.extend(call); + a.push(14); + // `adx ady bchar achar endchar` with both components 'A' (standard code 65). + let seac = vec![139 + 10, 139 + 20, 139 + 65, 139 + 65, 14]; + let font = write_test_cff_with_gsubrs(&[(".notdef", vec![14]), ("A", a), ("Aacute", seac)], &[subr]); + let cff = Cff::parse(&font).unwrap(); + let (alone, _) = cff.outline(cff.gid_by_name("A").unwrap()).unwrap(); + let (both, _) = cff.outline(cff.gid_by_name("Aacute").unwrap()).unwrap(); + assert!(!alone.elements().is_empty()); + assert_eq!(both.elements().len(), alone.elements().len(), "the accent gets no budget after the base spent it"); + } + /// A CFF whose top DICT points its Private DICT at offsets near `usize::MAX`: `size` and `off` /// come from the file, and reading the Private DICT must not overflow `off + size`. #[test] diff --git a/crates/pdf/src/font.rs b/crates/pdf/src/font.rs index 64b0a1c3a..8bd91831e 100644 --- a/crates/pdf/src/font.rs +++ b/crates/pdf/src/font.rs @@ -211,6 +211,12 @@ fn utf16(b: &[u8]) -> String { String::from_utf16_lossy(&u) } +/// The most code → text mappings a CMap keeps (more than Unicode has code points), and the +/// longest destination string a `bfrange` is expanded with: a range of 65,536 codes would +/// otherwise multiply a long destination into gigabytes of text. +const MAX_UNICODE_MAPPINGS: usize = 1 << 20; +const MAX_RANGE_DST: usize = 256; + fn parse_cmap(data: &[u8]) -> CMap { let mut m = CMap::default(); let mut lx = Lexer::new(data, 0); @@ -239,6 +245,9 @@ fn parse_cmap(data: &[u8]) -> CMap { } "bfchar" => { for p in a.as_chunks::<2>().0 { + if m.unicode.len() >= MAX_UNICODE_MAPPINGS { + break; + } if let (Obj::Str(src), Obj::Str(dst)) = (&p[0], &p[1]) { m.unicode.insert(be(src), utf16(dst)); } @@ -252,9 +261,12 @@ fn parse_cmap(data: &[u8]) -> CMap { continue; } match &p[2] { - Obj::Str(dst) => { + Obj::Str(dst) if dst.len() <= MAX_RANGE_DST => { let mut d = dst.clone(); for c in lo..=hi { + if m.unicode.len() >= MAX_UNICODE_MAPPINGS { + break; + } m.unicode.insert(c, utf16(&d)); if let Some(last) = d.last_mut() { *last = last.wrapping_add(1); @@ -263,6 +275,9 @@ fn parse_cmap(data: &[u8]) -> CMap { } Obj::Array(list) => { for (k, s) in list.iter().enumerate() { + if m.unicode.len() >= MAX_UNICODE_MAPPINGS { + break; + } if let (Obj::Str(s), Some(c)) = (s, lo.checked_add(k as u32)) { m.unicode.insert(c, utf16(s)); } diff --git a/crates/pdf/src/type1.rs b/crates/pdf/src/type1.rs index 4b3e05756..399905722 100644 --- a/crates/pdf/src/type1.rs +++ b/crates/pdf/src/type1.rs @@ -148,8 +148,9 @@ impl Type1 { }; if let Some(p) = find(&priv_, b"/Subrs", 0) { let mut q = p + 6; + // The section ends at `/CharStrings`, found once rather than for every entry. + let lim = find(&priv_, b"/CharStrings", q).unwrap_or(priv_.len()); while let Some(d) = find(&priv_, b"dup", q) { - let lim = find(&priv_, b"/CharStrings", q).unwrap_or(priv_.len()); if d > lim { break; } @@ -205,10 +206,11 @@ impl Type1 { /// A glyph's outline in glyph space and its advance width. pub fn outline(&self, name: &str) -> Option<(BezPath, f64)> { - self.outline_depth(name, 0) + self.outline_depth(name, 0, &mut { crate::cff::MAX_GLYPH_OPS }) } - fn outline_depth(&self, name: &str, depth: usize) -> Option<(BezPath, f64)> { + /// `ops` is the glyph's instruction budget, shared by the base glyph and its seac components. + fn outline_depth(&self, name: &str, depth: usize, ops: &mut usize) -> Option<(BezPath, f64)> { let cs = self.charstrings.get(name)?; let mut st = T1 { font: self, @@ -223,9 +225,11 @@ impl Type1 { flex: None, done: false, depth: 0, + ops: *ops, seac: None, }; st.run(cs); + *ops = st.ops; if st.open { st.path.close_path(); } @@ -234,10 +238,10 @@ impl Type1 { && depth < 2 { let name_of = |c: u8| crate::encoding::Base::Standard.name(c); - if let Some((bp, _)) = name_of(b).and_then(|n| self.outline_depth(&n, depth + 1)) { + if let Some((bp, _)) = name_of(b).and_then(|n| self.outline_depth(&n, depth + 1, ops)) { path.extend(bp); } - if let Some((ap, _)) = name_of(a).and_then(|n| self.outline_depth(&n, depth + 1)) { + if let Some((ap, _)) = name_of(a).and_then(|n| self.outline_depth(&n, depth + 1, ops)) { // The accent's origin: its own sidebearing is replaced by `asb`. path.extend(kurbo::Affine::translate((adx - asb + st.sbx, ady)) * ap); } @@ -270,6 +274,8 @@ struct T1<'a> { flex: Option>, done: bool, depth: usize, + /// Instructions this glyph may still run, across its subroutine calls. + ops: usize, seac: Option<(f64, f64, f64, u8, u8)>, } @@ -314,6 +320,12 @@ impl T1<'_> { } let mut i = 0; while i < cs.len() && !self.done { + // Subroutines that branch would multiply the work: the budget is shared. + let Some(left) = self.ops.checked_sub(1) else { + self.done = true; + return; + }; + self.ops = left; let b0 = cs[i]; i += 1; let s = |st: &Self, k: usize| st.stack.get(k).copied().unwrap_or(0.0); @@ -533,6 +545,25 @@ mod tests { assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0)); } + /// A subroutine that calls itself ten times: 10^10 calls within the depth limit of ten. The + /// glyph stops at its instruction budget instead. + #[test] + fn branching_subroutines_stop_at_the_instruction_budget() { + let mut out = b"%!PS-AdobeFont-1.0: Test 001\n/FontMatrix [0.001 0 0 0.001 0 0] readonly def\ncurrentfile eexec\n".to_vec(); + // `0 callsubr` is `139 10`; `return` is 11, `endchar` 14 (lenIV -1: charstrings in clear). + let mut subr = [139u8, 10].repeat(10); + subr.push(11); + let mut priv_ = b"dup /Private 8 dict dup begin /lenIV -1 def\n/Subrs 1 array\n".to_vec(); + priv_.extend_from_slice(format!("dup 0 {} RD ", subr.len()).as_bytes()); + priv_.extend_from_slice(&subr); + priv_.extend_from_slice(b" NP\nend\n/CharStrings 1 dict dup begin\n/A 3 RD \x8b\x0a\x0e ND\nend\nend\nmark currentfile closefile\n"); + out.extend(encrypt(&priv_, EEXEC_R, 4)); + out.extend_from_slice(b"\n0000000000000000\ncleartomark\n"); + let t = Type1::parse(&out).unwrap(); + let (p, _) = t.outline("A").unwrap(); + assert!(p.elements().is_empty()); + } + /// A `/Subrs` entry whose `RD` length is `usize::MAX`: the bytes are not in the file, and /// `q + n` must not overflow. #[test] From c6411266eb76c06b362f5627d2cb60fed0f3fbeb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:57:53 -0500 Subject: [PATCH 078/227] Render Queue: keep Output Module crop padding within the largest output frame (#2984) * fix(render-queue): keep Output Module crop padding within the largest output frame * address review: crop padding is taken back only when the side would pass the cap --- crates/project/src/render_queue.rs | 49 ++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 8635bc9a8..5418e5ac7 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -779,6 +779,27 @@ pub struct Crop { pub right: i32, } +/// The largest side of a frame an output module writes (Resize is capped there too). +pub const MAX_OUTPUT_SIDE: i32 = 30_000; + +/// Crop edges `a`/`b` of one axis of `side` pixels: padding is taken back only as far as keeps +/// the result within [`MAX_OUTPUT_SIDE`], so a mixed pair that stays inside the cap is kept as +/// saved. Each edge is also bounded (what the larger of the side and the cap allows), so the +/// arithmetic on them cannot overflow. +fn bound_edges(a: i32, b: i32, side: i32) -> (i32, i32) { + let hi = side.max(MAX_OUTPUT_SIDE); + let (mut a, mut b) = (a.clamp(-MAX_OUTPUT_SIDE, hi), b.clamp(-MAX_OUTPUT_SIDE, hi)); + let excess = i64::from(side) - i64::from(a) - i64::from(b) - i64::from(MAX_OUTPUT_SIDE); + if excess > 0 { + // Only padding (negative edges) grows the side: take back as much of it as needed. + let excess = i32::try_from(excess).unwrap_or(i32::MAX); + let from_a = excess.min(-a.min(0)); + a += from_a; + b += (excess - from_a).min(-b.min(0)); + } + (a, b) +} + impl Crop { /// Edges `(top, left, bottom, right)` in pixels of a `w`×`h` frame rendered at `scale`. pub fn edges(&self, w: u32, h: u32, scale: f64) -> (i32, i32, i32, i32) { @@ -801,7 +822,12 @@ impl Crop { let b = conv((y + rh) * scale, hi).max(t); return (t, l, hi - b, wi - r); } - (self.top, self.left, self.bottom, self.right) + // Negative edges pad the frame: keep each padded side within the largest frame an + // output module writes, or a few saved integers ask for a canvas of gigapixels. + let (wi, hi) = (i32::try_from(w).unwrap_or(i32::MAX), i32::try_from(h).unwrap_or(i32::MAX)); + let (t, b) = bound_edges(self.top, self.bottom, hi); + let (l, r) = bound_edges(self.left, self.right, wi); + (t, l, b, r) } } @@ -1271,8 +1297,8 @@ impl OutputModule { // Width rules; height follows the cropped frame's aspect. rh = ((rw as f64 * ch as f64 / cw as f64).round() as u32).max(1); } - rw = rw.min(30_000); - rh = rh.min(30_000); + rw = rw.min(MAX_OUTPUT_SIDE as u32); + rh = rh.min(MAX_OUTPUT_SIDE as u32); (rw, rh) } /// The written frame size of an item (Render Settings resolution, Crop, Resize, codec @@ -1692,6 +1718,23 @@ pub fn sanitize(name: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn crop_padding_stays_within_the_largest_output_frame() { + use super::*; + let crop = |top, left, bottom, right| Crop { enabled: true, top, left, bottom, right, ..Default::default() }; + let m = |c: Crop| OutputModule { crop: c, ..Default::default() }; + // Saved edges padding by billions of pixels: the frame stops at the cap. + assert_eq!(m(crop(i32::MIN, i32::MIN, i32::MIN, i32::MIN)).frame_size(100, 50, 1.0), (30_000, 30_000)); + assert_eq!(m(crop(-20_000, 0, -20_000, -1)).frame_size(100, 50, 1.0), (101, 30_000)); + // Ordinary crops and pads are unchanged. + assert_eq!(crop(1, -2, 3, 4).edges(100, 50, 1.0), (1, -2, 3, 4)); + // A mixed pair beyond the side that still ends within the cap is kept as saved (50 px). + assert_eq!(crop(0, 150, 0, -100).edges(100, 50, 1.0), (0, 150, 0, -100)); + assert_eq!(m(crop(0, 150, 0, -100)).frame_size(100, 50, 1.0), (50, 50)); + assert_eq!(m(crop(10, 10, 10, 10)).frame_size(100, 50, 1.0), (80, 30)); + assert_eq!(m(crop(i32::MAX, 0, 0, 0)).frame_size(100, 50, 1.0), (100, 1)); + } + use super::*; #[test] From ed085bd483991d1835698f5d44b9bb21afa6148d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:58:17 -0500 Subject: [PATCH 079/227] ICC: interpolate only the CLUT axes that need it (#2985) * fix(color): interpolate only the CLUT axes that need it * address review: CLUT upper grid points are not interpolated, stride arithmetic saturates --- crates/color/src/icc.rs | 58 +++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/crates/color/src/icc.rs b/crates/color/src/icc.rs index 184c94c28..e35cbafc7 100644 --- a/crates/color/src/icc.rs +++ b/crates/color/src/icc.rs @@ -325,6 +325,9 @@ pub struct Clut { pub data: Vec, } +/// Most interpolating inputs [`Clut::eval`] combines (ICC allows 15). +pub const MAX_CLUT_INPUTS: usize = 16; + impl Clut { /// Multilinear interpolation at `x` (0..1 per input). pub fn eval(&self, x: &[f64]) -> Vec { @@ -334,26 +337,40 @@ impl Clut { let mut s = self.outputs; for i in (0..n).rev() { stride[i] = s; - s *= self.grid[i].max(1); + // Saturating: a table with enough axes to overflow cannot hold its data anyway, and a + // saturated offset reads nothing (zero) instead of panicking. + s = s.saturating_mul(self.grid[i].max(1)); } let mut frac = vec![0.0; n]; let mut step = vec![0usize; n]; for i in 0..n { let g = self.grid[i].max(1); let f = x.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0) * (g - 1) as f64; - let k = (f.floor() as usize).min(g.saturating_sub(2)); + let mut k = (f.floor() as usize).min(g.saturating_sub(2)); frac[i] = if g > 1 { f - k as f64 } else { 0.0 }; step[i] = if g > 1 { stride[i] } else { 0 }; - base += k * stride[i]; + // The last grid point is a corner of its own, not an interpolation toward the next. + if frac[i] >= 1.0 { + k += 1; + frac[i] = 0.0; + } + base = base.saturating_add(k.saturating_mul(stride[i])); } let mut out = vec![0.0; self.outputs]; - for corner in 0..(1usize << n) { + // Only the axes that interpolate have two corners: a singleton axis, or one sampled on a + // grid point, contributes its lower corner alone. ICC tables have at most 15 inputs; a + // table built with more interpolating axes than that evaluates to zero. + let active: Vec = (0..n).filter(|&i| step[i] != 0 && frac[i] != 0.0).collect(); + if active.len() > MAX_CLUT_INPUTS { + return out; + } + for corner in 0..(1usize << active.len()) { let mut w = 1.0; let mut off = base; - for i in 0..n { - if corner >> i & 1 == 1 { + for (bit, &i) in active.iter().enumerate() { + if corner >> bit & 1 == 1 { w *= frac[i]; - off += step[i]; + off = off.saturating_add(step[i]); } else { w *= 1.0 - frac[i]; } @@ -362,7 +379,7 @@ impl Clut { continue; } for (o, v) in out.iter_mut().enumerate() { - *v += w * self.data.get(off + o).copied().unwrap_or(0.0) as f64; + *v += w * self.data.get(off.saturating_add(o)).copied().unwrap_or(0.0) as f64; } } out @@ -1052,6 +1069,31 @@ mod tests { assert!(matches!(parse_profile(&write_matrix_profile("m", P709, D65, 2.2)).unwrap(), Profile::Matrix(_))); } + #[test] + fn clut_corners_count_only_interpolating_axes() { + // 64 singleton axes and one interpolating one: two corners, not 2^65. + let mut grid = vec![1; 64]; + grid.push(2); + let c = Clut { grid, outputs: 1, data: vec![0.0, 1.0] }; + let mut x = vec![0.5; 64]; + x.push(0.25); + assert!((c.eval(&x)[0] - 0.25).abs() < 1e-9); + // On grid points nothing interpolates. + let c = Clut { grid: vec![2; 40], outputs: 1, data: vec![] }; + assert_eq!(c.eval(&[1.0; 40]), vec![0.0]); + // More interpolating axes than ICC allows: zero, at once. + assert_eq!(c.eval(&[0.5; 40]), vec![0.0]); + // The last grid point of every axis is that table entry, however many axes there are. + let mut data = vec![0.0; 1 << 17]; + data[(1 << 17) - 1] = 0.75; + let c = Clut { grid: vec![2; 17], outputs: 1, data }; + assert!((c.eval(&[1.0; 17])[0] - 0.75).abs() < 1e-9); + // Strides that overflow usize (64 two-point axes) read nothing instead of panicking. + let c = Clut { grid: vec![2; 64], outputs: 1, data: vec![] }; + assert_eq!(c.eval(&[1.0; 64]), vec![0.0]); + assert_eq!(c.eval(&[0.5; 64]), vec![0.0]); + } + #[test] fn clut_and_lut3d_interpolate() { // A 2-point identity CLUT on 3 inputs is exact for every point. From 497a056c07deebc6709abd49ec5803aa21044055 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:58:43 -0500 Subject: [PATCH 080/227] Box blur: count repeated edge pixels instead of visiting them, and bound the radius (#2944) * Box blur: count repeated edge pixels instead of visiting them, and bound the radius * address review: box blur first mean ignores an unused edge term (inf * 0 gave NaN) --- crates/effects/src/util.rs | 66 +++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 9c81dab10..9c14a6c69 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -115,18 +115,34 @@ fn box_row(src: &[f32], dst: &mut [f32], r: usize) { dst.copy_from_slice(src); return; } - let get = |i: isize| src[i.clamp(0, n as isize - 1) as usize]; - let norm = 1.0 / (2 * r + 1) as f32; - let mut acc = 0.0f64; - for i in -(r as isize)..=(r as isize) { - acc += get(i) as f64; + // Past the row the window only adds more copies of the edge pixels; the radius is clamped + // so the index arithmetic below cannot overflow (the means are unchanged in practice). + let r = r.min(MAX_BOX_RADIUS); + let (last, ri) = (n - 1, r as i64); + let get = |i: i64| src[i.clamp(0, last as i64) as usize]; + let norm = 1.0 / (2.0 * r as f64 + 1.0); + // The first window, -r..=r: the edge pixels it repeats are counted, not visited, so the + // work depends on the row length and not on the radius. + let inside = r.min(last); + let mut acc = (r as f64 + 1.0) * src[0] as f64 + src[1..=inside].iter().map(|v| *v as f64).sum::(); + // Only when the window actually repeats the last pixel: 0 * inf would be NaN otherwise. + if r > inside { + acc += (r - inside) as f64 * src[last] as f64; } for x in 0..n { - dst[x] = acc as f32 * norm; - acc += get(x as isize + r as isize + 1) as f64 - get(x as isize - r as isize) as f64; + dst[x] = (acc * norm) as f32; + let xi = x as i64; + acc += get(xi + ri + 1) as f64 - get(xi - ri) as f64; } } +/// Largest box radius [`box_row`] uses (larger radii come from absurd blur amounts in a +/// project, such as a Mocha shape's feather). +const MAX_BOX_RADIUS: usize = 1 << 30; + +/// Largest sigma [`box_radii`] spreads into boxes (see [`MAX_BOX_RADIUS`]). +const MAX_BOX_SIGMA: f64 = 1e8; + /// Separable box mean with radii `rx`, `ry` (edges repeated). pub fn box_plane(p: &Plane, rx: usize, ry: usize) -> Plane { let mut cur = if rx > 0 { p.map_rows(|s, d| box_row(s, d, rx)) } else { p.clone() }; @@ -138,9 +154,10 @@ pub fn box_plane(p: &Plane, rx: usize, ry: usize) -> Plane { /// Box radii for `n` passes approximating a Gaussian of `sigma`. pub fn box_radii(sigma: f64, n: usize) -> Vec { - if sigma <= 0.0 { + if sigma.is_nan() || sigma <= 0.0 || n == 0 { return vec![0; n]; } + let sigma = sigma.min(MAX_BOX_SIGMA); let w_ideal = (12.0 * sigma * sigma / n as f64 + 1.0).sqrt(); let mut wl = w_ideal.floor() as i64; if wl % 2 == 0 { @@ -841,4 +858,37 @@ mod tests { let o = guided_filter(&g, &p, 2, 1e-3); assert!(o.data.iter().all(|v| (v - 0.7).abs() < 1e-3)); } + + /// The box mean counts repeated edge pixels instead of visiting them: the same means as a + /// direct sum, and a huge radius (a Mocha feather of 1e300) costs a row's worth of work. + #[test] + fn box_row_matches_a_direct_sum_and_bounds_the_radius() { + let src = [1.0f32, 2.0, 3.0, 5.0, 8.0]; + for r in [0usize, 1, 2, 4, 5, 9, 40] { + let mut dst = [0.0f32; 5]; + box_row(&src, &mut dst, r); + for (x, d) in dst.iter().enumerate() { + let ri = r as i64; + let sum: f64 = (x as i64 - ri..=x as i64 + ri).map(|i| src[i.clamp(0, 4) as usize] as f64).sum(); + let want = (sum / (2 * r + 1) as f64) as f32; + assert!((d - want).abs() < 1e-5, "r={r} x={x}: {d} vs {want}"); + } + } + // An infinite last pixel inside the window still yields a finite first mean. + let mut inf = [0.0f32; 3]; + box_row(&[1.0, 2.0, f32::INFINITY], &mut inf, 1); + assert!((inf[0] - 4.0 / 3.0).abs() < 1e-6, "{inf:?}"); + let mut one = [0.0f32]; + box_row(&[0.25], &mut one, usize::MAX); + assert!((one[0] - 0.25).abs() < 1e-6); + let mut dst = [0.0f32; 5]; + box_row(&src, &mut dst, usize::MAX); + assert!(dst.iter().all(|v| v.is_finite() && (1.0..=8.0).contains(v)), "{dst:?}"); + for sigma in [1e300, f64::MAX, f64::INFINITY, f64::NAN, -1.0] { + assert_eq!(box_radii(sigma, 3).len(), 3); + } + let p = Plane { w: 4, h: 3, data: vec![0.5; 12] }; + let g = gauss_plane(&p, 1e300, 1e300); + assert!(g.data.iter().all(|v| (v - 0.5).abs() < 1e-4), "{:?}", g.data); + } } From 2c9540877701588afb9108f905333cd3ef14ecf7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:59:07 -0500 Subject: [PATCH 081/227] Auto-trace: bound the nesting, simplification and fitting work (#2945) * Auto-trace: bound the nesting, simplification and fitting work Many separate regions made the nesting depth test every pair of contours, Douglas-Peucker could rescan a span once per point (and recurse as deep as the loop is long), and the curve fit searched the raw contour from its start for every simplified vertex. - Depth: each loop's bounding box goes into a coarse grid; a loop's first point is tested only against loops whose box holds it. Same depths as testing every pair. - Douglas-Peucker: spans wait on an explicit stack, with a budget of 64 distance evaluations per point; spans still waiting when it runs out keep all their points, so the outline stays exact. - Fitting: uses the raw indices the simplifier kept instead of searching. * cargo fmt; clippy manual_is_multiple_of * address review: keep wide contour boxes out of the nesting grid --- crates/path/src/trace.rs | 214 ++++++++++++++++++++++++++++++++------- 1 file changed, 176 insertions(+), 38 deletions(-) diff --git a/crates/path/src/trace.rs b/crates/path/src/trace.rs index 01508bcaf..7b15b55c2 100644 --- a/crates/path/src/trace.rs +++ b/crates/path/src/trace.rs @@ -55,37 +55,85 @@ pub fn trace(field: &[f32], w: usize, h: usize, o: &TraceOpts) -> Vec { return vec![]; } let loops = contours(field, w, h, o.threshold); - let mut polys: Vec<(Vec<[f64; 2]>, Vec<[f64; 2]>, f64)> = loops + // (raw loop, kept raw indices, simplified loop, area) + let mut polys: Vec<(Vec<[f64; 2]>, Vec, Vec<[f64; 2]>, f64)> = loops .into_iter() .filter_map(|raw| { let a = signed_area(&raw); if a.abs() < o.min_area.max(0.0) || raw.len() < 3 { return None; } - let simp = simplify_closed(&raw, o.tolerance.max(0.01)); - if simp.len() < 3 { + let idx = simplify_idx(&raw, o.tolerance.max(0.01)); + if idx.len() < 3 { return None; } - Some((raw, simp, a)) - }) - .collect(); - // Depth: how many other loops contain a point of this loop. - let depths: Vec = (0..polys.len()) - .map(|i| { - let p = polys[i].1[0]; - (0..polys.len()).filter(|&j| j != i && polys[j].2.abs() > polys[i].2.abs() && point_in_poly(p, &polys[j].1)).count() + let simp = idx.iter().map(|&i| raw[i]).collect(); + Some((raw, idx, simp, a)) }) .collect(); + let depths = nesting_depths(&polys.iter().map(|p| (p.2.as_slice(), p.3.abs())).collect::>(), w, h); let mut out: Vec = polys .drain(..) .zip(depths) - .map(|((raw, simp, a), depth)| Traced { path: fit_loop(&raw, &simp, o.corner_roundness.clamp(0.0, 1.0)), depth, area: a }) + .map(|((raw, idx, simp, a), depth)| Traced { path: fit_loop(&raw, &idx, &simp, o.corner_roundness.clamp(0.0, 1.0)), depth, area: a }) .collect(); // Outer outlines first, then holes, islands…; larger first within a depth. out.sort_by(|a, b| a.depth.cmp(&b.depth).then(b.area.abs().total_cmp(&a.area.abs()))); out } +/// Grid cell (pixels) of the index [`nesting_depths`] finds candidate containers with. +const DEPTH_CELL: f64 = 32.0; + +/// Most grid cells one loop's box is entered into; a box covering more is kept in a short list +/// every point is tested against, so nested full-image contours can't fill the grid. +const MAX_BOX_CELLS: usize = 64; + +/// Per loop (simplified points, |area|): how many other, larger loops contain its first point. +/// Only loops whose bounding box holds that point can contain it, so each loop's box is entered +/// into a coarse grid over the `w × h` field and a point is tested against the loops of its +/// cell alone: many separate regions no longer test every pair. +fn nesting_depths(polys: &[(&[[f64; 2]], f64)], w: usize, h: usize) -> Vec { + // Loops lie within the field padded by half a pixel each side. + let (gw, gh) = (((w as f64 + 1.0) / DEPTH_CELL).ceil() as usize + 1, ((h as f64 + 1.0) / DEPTH_CELL).ceil() as usize + 1); + let cell = |v: f64, n: usize| (((v + 0.5) / DEPTH_CELL).floor().max(0.0) as usize).min(n - 1); + let boxes: Vec<[f64; 4]> = polys + .iter() + .map(|(p, _)| { + p.iter().fold([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY], |b, q| { + [b[0].min(q[0]), b[1].min(q[1]), b[2].max(q[0]), b[3].max(q[1])] + }) + }) + .collect(); + let mut grid: Vec> = vec![Vec::new(); gw * gh]; + let mut wide: Vec = Vec::new(); + for (j, b) in boxes.iter().enumerate() { + let (x0, x1, y0, y1) = (cell(b[0], gw), cell(b[2], gw), cell(b[1], gh), cell(b[3], gh)); + if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > MAX_BOX_CELLS { + wide.push(j); + continue; + } + for cy in y0..=y1 { + for cx in x0..=x1 { + grid[cy * gw + cx].push(j); + } + } + } + (0..polys.len()) + .map(|i| { + let p = polys[i].0[0]; + grid[cell(p[1], gh) * gw + cell(p[0], gw)] + .iter() + .chain(&wide) + .filter(|&&j| { + let b = boxes[j]; + j != i && polys[j].1 > polys[i].1 && p[0] >= b[0] && p[0] <= b[2] && p[1] >= b[1] && p[1] <= b[3] && point_in_poly(p, polys[j].0) + }) + .count() + }) + .collect() +} + /// Marching squares: closed polylines (pixel coordinates). fn contours(field: &[f32], w: usize, h: usize, thr: f32) -> Vec> { // Padded grid: sample (i, j) of the padded grid is pixel (i - 1, j - 1), outside = -inf. @@ -233,23 +281,37 @@ fn seg_dist(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { ((p[0] - a[0] - t * dx).powi(2) + (p[1] - a[1] - t * dy).powi(2)).sqrt() } -/// Douglas–Peucker on an open polyline: kept indices. -fn dp(p: &[[f64; 2]], lo: usize, hi: usize, tol: f64, keep: &mut Vec) { - if hi <= lo + 1 { - return; - } - let (mut best, mut bi) = (0.0, lo); - for (k, q) in p.iter().enumerate().take(hi).skip(lo + 1) { - let d = seg_dist(*q, p[lo], p[hi]); - if d > best { - best = d; - bi = k; +/// Distance evaluations Douglas–Peucker may spend per point of a loop. Balanced splits need +/// about log2(n); splits that keep landing next to an end would need up to n. +const DP_WORK_PER_POINT: usize = 64; + +/// Douglas–Peucker on an open polyline: kept indices (unordered). Spans wait on an explicit +/// stack (no recursion depth to overflow), and once `budget` distance evaluations are spent the +/// spans still waiting keep all their points: the outline stays exact, just less simplified. +fn dp(p: &[[f64; 2]], lo: usize, hi: usize, tol: f64, budget: &mut usize, keep: &mut Vec) { + let mut spans = vec![(lo, hi)]; + while let Some((lo, hi)) = spans.pop() { + if hi <= lo + 1 { + continue; + } + if *budget < hi - lo - 1 { + keep.extend(lo + 1..hi); + continue; + } + *budget -= hi - lo - 1; + let (mut best, mut bi) = (0.0, lo); + for (k, q) in p.iter().enumerate().take(hi).skip(lo + 1) { + let d = seg_dist(*q, p[lo], p[hi]); + if d > best { + best = d; + bi = k; + } + } + if best > tol { + keep.push(bi); + spans.push((lo, bi)); + spans.push((bi, hi)); } - } - if best > tol { - dp(p, lo, bi, tol, keep); - keep.push(bi); - dp(p, bi, hi, tol, keep); } } @@ -265,27 +327,23 @@ fn simplify_idx(p: &[[f64; 2]], tol: f64) -> Vec { let mut ext: Vec<[f64; 2]> = p.to_vec(); ext.push(p[0]); let mut keep = vec![0]; - dp(&ext, 0, far, tol, &mut keep); + let mut budget = n.saturating_mul(DP_WORK_PER_POINT); + dp(&ext, 0, far, tol, &mut budget, &mut keep); keep.push(far); - dp(&ext, far, n, tol, &mut keep); + dp(&ext, far, n, tol, &mut budget, &mut keep); keep.sort_unstable(); keep.dedup(); keep.retain(|&i| i < n); keep } -fn simplify_closed(p: &[[f64; 2]], tol: f64) -> Vec<[f64; 2]> { - simplify_idx(p, tol).into_iter().map(|i| p[i]).collect() -} - -/// Fit cubics through the simplified vertices of a loop (`raw` holds the full contour). -fn fit_loop(raw: &[[f64; 2]], simp: &[[f64; 2]], round: f64) -> ShapePath { +/// Fit cubics through the simplified vertices of a loop (`raw` holds the full contour, `idx` +/// the raw index of each simplified vertex, as [`simplify_idx`] kept them). +fn fit_loop(raw: &[[f64; 2]], idx: &[usize], simp: &[[f64; 2]], round: f64) -> ShapePath { let n = simp.len(); if round <= 1e-6 { return ShapePath::polygon(simp, true); } - // Raw index of each simplified vertex (they are raw points). - let idx: Vec = simp.iter().map(|s| raw.iter().position(|r| r == s).unwrap_or(0)).collect(); let pt = |a: [f64; 2]| Point::new(a[0], a[1]); let v = |a: [f64; 2], b: [f64; 2]| Vec2::new(b[0] - a[0], b[1] - a[1]); // Per vertex: (direction leaving, direction arriving), blended between chord and smooth. @@ -349,6 +407,86 @@ mod tests { fill_coverage(&paths, &Mat3::IDENTITY, w as u32, h as u32, FillRule::EvenOdd).data } + #[test] + fn nesting_depths_match_every_pair() { + // Rings within rings beside many separate dots: the grid lookup gives the same depths + // as testing every pair. + let (w, h) = (200, 120); + let mut field = vec![0.0f32; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f64 + 0.5 - 60.0, y as f64 + 0.5 - 60.0); + let r = (fx * fx + fy * fy).sqrt(); + let ring = r < 56.0 && ((r / 8.0) as usize).is_multiple_of(2); + let dot = x >= 130 && x % 6 < 3 && y % 6 < 3; + field[y * w + x] = if ring || dot { 1.0 } else { 0.0 }; + } + } + let opts = TraceOpts { min_area: 1.0, ..Default::default() }; + let t = trace(&field, w, h, &opts); + assert!(t.len() > 200, "{}", t.len()); + let polys: Vec<(Vec<[f64; 2]>, f64)> = t.iter().map(|t| (t.path.vertices.clone(), t.area.abs())).collect(); + let refs: Vec<(&[[f64; 2]], f64)> = polys.iter().map(|(p, a)| (p.as_slice(), *a)).collect(); + let fast = nesting_depths(&refs, w, h); + let pairs: Vec = + (0..refs.len()).map(|i| (0..refs.len()).filter(|&j| j != i && refs[j].1 > refs[i].1 && point_in_poly(refs[i].0[0], refs[j].0)).count()).collect(); + assert_eq!(fast, pairs); + assert_eq!(fast, t.iter().map(|t| t.depth).collect::>()); + assert!(fast.iter().max() >= Some(&5)); + } + + #[test] + fn nesting_depths_count_wide_boxes_kept_out_of_the_grid() { + // Concentric squares whose boxes each cover far more than MAX_BOX_CELLS cells. + let (w, h) = (4000, 4000); + let squares: Vec> = (0..40) + .map(|k| { + let (a, b) = (10.0 + 40.0 * k as f64, 3990.0 - 40.0 * k as f64); + vec![[a, a], [b, a], [b, b], [a, b]] + }) + .collect(); + let refs: Vec<(&[[f64; 2]], f64)> = squares.iter().map(|p| (p.as_slice(), (p[1][0] - p[0][0]).powi(2))).collect(); + assert_eq!(nesting_depths(&refs, w, h), (0..40).collect::>()); + } + + #[test] + fn simplification_work_is_bounded_and_keeps_the_outline() { + // Out of budget, the spans still waiting keep every point: nothing is lost, the outline + // is just less simplified. + let p: Vec<[f64; 2]> = (0..12).map(|i| [i as f64, if i % 2 == 0 { 0.0 } else { 5.0 }]).collect(); + let (mut budget, mut keep) = (0, vec![]); + dp(&p, 0, 11, 0.01, &mut budget, &mut keep); + keep.sort_unstable(); + assert_eq!(keep, (1..11).collect::>()); + // With a budget, the result matches the plain recursion's on an ordinary loop. + let (mut budget, mut keep) = (usize::MAX, vec![]); + dp(&p, 0, 11, 0.01, &mut budget, &mut keep); + keep.sort_unstable(); + assert_eq!(keep, (1..11).collect::>()); + // A long, finely zig-zagging loop: simplified within a budget linear in its length. + let n = 200_000; + let ring: Vec<[f64; 2]> = (0..n) + .map(|i| { + let a = i as f64 / n as f64 * std::f64::consts::TAU; + let r = 1000.0 + if i % 2 == 0 { 0.0 } else { 3.0 }; + [r * a.cos(), r * a.sin()] + }) + .collect(); + let idx = simplify_idx(&ring, 0.5); + assert!(idx.windows(2).all(|w| w[0] < w[1]) && idx.len() >= 3 && *idx.last().unwrap() < n); + } + + #[test] + fn fit_uses_the_kept_indices() { + // A contour that passes the same point twice: the fit follows the simplifier's index, + // not the first raw point with those coordinates. + let raw = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 0.0], [-10.0, 10.0], [-10.0, 0.0]]; + let idx = vec![0, 2, 3, 4]; + let simp: Vec<[f64; 2]> = idx.iter().map(|&i| raw[i]).collect(); + let path = fit_loop(&raw, &idx, &simp, 0.5); + assert_eq!(path.vertices, simp); + } + #[test] fn traces_a_disc_and_a_square_with_a_hole() { let (w, h) = (120, 80); From e1ea71f54069d5846e111341d8fb0ecde7f14c03 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:59:33 -0500 Subject: [PATCH 082/227] Text: merge variable font axes by tag and cap their number (#2949) * Text: merge variable font axes by tag and cap their number Each tag in a variations object scanned the style's growing axis list, so a large object cost quadratic time, twice (validation probe and apply). The object is now merged in one keyed pass, refused when it names more than 64 axes, and a merge that would leave a style with more than 64 is refused without changing the style. * cargo fmt * address review: refuse variation over every run before changing any * address review: empty-document range cannot overflow in set_attr --- crates/keyframe/src/text_doc.rs | 77 ++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index 96a8b6be3..f91d8be4d 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -977,7 +977,19 @@ impl TextDoc { if r.start >= r.end && n > 0 { return Ok(true); } - self.apply_style(r.start..r.end.max(r.start + usize::from(n == 0)), |s| { + // Empty text: apply_style ignores the bounds, so a fixed range avoids `start + 1` overflow. + let range = if n == 0 { 0..1 } else { r.start..r.end }; + // Check every affected run first, so a value one run refuses changes none of them. + let mut err = None; + self.apply_style(range.clone(), |s| { + if let Err(e) = apply_char_attr(&mut s.clone(), key, v) { + err.get_or_insert(e); + } + }); + if let Some(e) = err { + return Err(e); + } + self.apply_style(range, |s| { let _ = apply_char_attr(s, key, v); }); return Ok(true); @@ -1057,6 +1069,11 @@ pub const CHAR_ATTRS: &[&str] = &[ pub const PARA_ATTRS: &[&str] = &["justify", "indentLeft", "indentRight", "indentFirst", "spaceBefore", "spaceAfter", "direction", "composer", "hangingPunctuation"]; +/// Most variable font axes one style can set. Real fonts have a handful (OpenType registers +/// five; custom axes rarely bring the total past twenty); a `variations` object with more is +/// rejected before any of it is applied. +pub const MAX_VARIATION_AXES: usize = 64; + /// Set one character attribute; Ok(false) for keys that aren't character attributes. pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result { match key { @@ -1173,20 +1190,28 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result match v { J::Null => s.variations.clear(), J::Object(m) => { - if m.is_empty() { - s.variations.clear(); + if m.len() > MAX_VARIATION_AXES { + return Err(format!("variations: at most {MAX_VARIATION_AXES} axes")); } + // One keyed merge (the result sorted by tag), applied only when every entry is valid. + let mut axes: std::collections::BTreeMap = if m.is_empty() { Default::default() } else { s.variations.iter().cloned().collect() }; for (tag, val) in m { if tag.is_empty() || tag.len() > 4 { return Err(format!("variations: `{tag}` is not an axis tag")); } - s.variations.retain(|(t, _)| t != tag); match val { - J::Null => {} - _ => s.variations.push((tag.clone(), val.as_f64().ok_or("variations: expected {tag: number}")? as f32)), + J::Null => { + axes.remove(tag); + } + _ => { + axes.insert(tag.clone(), val.as_f64().ok_or("variations: expected {tag: number}")? as f32); + } } } - s.variations.sort_by(|a, b| a.0.cmp(&b.0)); + if axes.len() > MAX_VARIATION_AXES { + return Err(format!("variations: at most {MAX_VARIATION_AXES} axes")); + } + s.variations = axes.into_iter().collect(); } _ => return Err("variations: expected {tag: number, …}".into()), }, @@ -1321,6 +1346,44 @@ pub fn word_at(text: &str, ci: usize) -> Range { mod tests { use super::*; + #[test] + fn variations_merge_by_tag_and_reject_too_many_axes() { + let mut doc = TextDoc::plain("ab"); + assert!(doc.set_attr("variations", &serde_json::json!({"wght": 650, "wdth": 90}), None).unwrap()); + assert!(doc.set_attr("variations", &serde_json::json!({"wght": 700, "wdth": null, "opsz": 12}), None).unwrap()); + assert_eq!(doc.style_at(0).variations, vec![("opsz".to_string(), 12.0), ("wght".to_string(), 700.0)]); + // More axes than any font has: refused up front, the style untouched. + let many: serde_json::Map = (0..MAX_VARIATION_AXES + 1).map(|i| (format!("a{i:03}"), J::from(1.0))).collect(); + assert!(doc.set_attr("variations", &J::Object(many), None).is_err()); + assert_eq!(doc.style_at(0).variations.len(), 2); + // A merge that would grow past the limit is refused too. + let fill: serde_json::Map = (0..MAX_VARIATION_AXES - 2).map(|i| (format!("b{i:03}"), J::from(1.0))).collect(); + assert!(doc.set_attr("variations", &J::Object(fill), None).unwrap()); + assert_eq!(doc.style_at(0).variations.len(), MAX_VARIATION_AXES); + // (The probe on an empty style passes; the styles already at the limit keep their axes.) + let _ = doc.set_attr("variations", &serde_json::json!({"zzzz": 1}), None); + assert_eq!(doc.style_at(0).variations.len(), MAX_VARIATION_AXES); + assert!(!doc.style_at(0).variations.iter().any(|(t, _)| t == "zzzz")); + } + + #[test] + fn refused_variation_leaves_every_run_unchanged() { + // "ab" is at the axis limit; "cd" has none. Three new axes over both runs must be refused + // for both, not applied to "cd" only. + let mut doc = TextDoc::plain("abcd"); + let fill: serde_json::Map = (0..MAX_VARIATION_AXES).map(|i| (format!("b{i:03}"), J::from(1.0))).collect(); + assert!(doc.set_attr("variations", &J::Object(fill), Some(0..2)).unwrap()); + assert!(doc.set_attr("variations", &serde_json::json!({"x1": 1, "x2": 1, "x3": 1}), None).is_err()); + assert_eq!(doc.style_at(3).variations.len(), 0); + assert_eq!(doc.style_at(0).variations.len(), MAX_VARIATION_AXES); + } + + #[test] + fn empty_document_accepts_an_extreme_range_without_overflow() { + let mut doc = TextDoc::plain(""); + assert!(doc.set_attr("size", &J::from(20), Some(usize::MAX..usize::MAX)).unwrap()); + } + #[test] fn mixed_line_breaks_keep_original_byte_and_character_ranges() { let text = "é\r\nβ\rx\n\n"; From 8d46e1df7f57541b45612b998a54e46e7da19ba3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:59:59 -0500 Subject: [PATCH 083/227] Gradients: bound the stops sampled per pixel (#2950) * Gradients: bound the stops sampled per pixel Gradient sampling scans the stops linearly and shape painting samples once per pixel, so a gradient with a huge stop list multiplied every frame's work. Sampling now looks at the first 256 colour and opacity stops, and the plain JSON form (coerce_json) refuses gradients with more than 256. * address review: bound tagged gradients to the stop limit --- crates/keyframe/src/tests.rs | 21 +++++++++++++++++++++ crates/keyframe/src/value.rs | 16 ++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/keyframe/src/tests.rs b/crates/keyframe/src/tests.rs index 88f9f3249..68823b76b 100644 --- a/crates/keyframe/src/tests.rs +++ b/crates/keyframe/src/tests.rs @@ -165,6 +165,27 @@ fn gradient_tracks_sample_separately() { assert_eq!((empty.color_at(0.3), empty.opacity_at(0.3)), ([1.0; 4], 1.0)); } +/// A gradient's stop count multiplies every painted pixel's sampling: plain JSON with more than +/// `MAX_GRADIENT_STOPS` is refused, and a longer stored list is sampled through its first ones. +#[test] +fn gradient_stop_count_is_bounded() { + use crate::value::MAX_GRADIENT_STOPS; + let g = Value::Gradient(Gradient::default()); + let many: Vec = (0..=MAX_GRADIENT_STOPS).map(|_| serde_json::json!("#ff0000")).collect(); + assert_eq!(g.coerce_json(&serde_json::Value::Array(many.clone())), None); + assert!(g.coerce_json(&serde_json::Value::Array(many[..MAX_GRADIENT_STOPS].to_vec())).is_some()); + let ops: Vec = (0..=MAX_GRADIENT_STOPS).map(|i| serde_json::json!([i as f64 / MAX_GRADIENT_STOPS as f64, 1.0])).collect(); + assert_eq!(g.coerce_json(&serde_json::json!({"colors": ["#000000", "#ffffff"], "opacities": ops})), None); + // The tagged (typed) form is bounded the same way. + let tagged = Value::Gradient(Gradient { colors: (0..=MAX_GRADIENT_STOPS).map(|i| (i as f64, [0.0; 4])).collect(), opacities: vec![(0.0, 1.0)] }); + assert_eq!(g.coerce_json(&tagged.to_json()), None); + // A stored gradient with a huge tail: stops past the limit don't take part in sampling. + let mut colors: Vec<(f64, [f32; 4])> = (0..MAX_GRADIENT_STOPS).map(|i| (i as f64 / MAX_GRADIENT_STOPS as f64 * 0.5, [0.0, 0.0, 0.0, 1.0])).collect(); + colors.extend((0..100_000).map(|_| (1.0, [1.0, 1.0, 1.0, 1.0]))); + let big = Gradient { colors, opacities: vec![(0.0, 1.0)] }; + assert_eq!(big.sample(1.0), [0.0, 0.0, 0.0, 1.0]); +} + /// #612: the editor saves the typed form, so one colour stop (which the plain JSON parser /// rejects) and each stop's RGBA come back exactly. #[test] diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index 6609aed68..6e87f8933 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -210,7 +210,13 @@ impl Gradient { } } +/// Most stops (colour or opacity) a gradient is sampled through. Gradients are sampled per +/// pixel, so the stop count multiplies every painted pixel's work: stops past this are ignored +/// when sampling, and a gradient written as JSON with more is refused. +pub const MAX_GRADIENT_STOPS: usize = 256; + fn sample_stops(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option { + let stops = &stops[..stops.len().min(MAX_GRADIENT_STOPS)]; let first = stops.first()?; if t <= first.0 { return Some(first.1); @@ -243,7 +249,7 @@ fn gradient_json(j: &serde_json::Value) -> Option { } fn stops(a: &[J]) -> Option> { let n = a.len(); - if n < 2 { + if !(2..=MAX_GRADIENT_STOPS).contains(&n) { return None; } a.iter() @@ -259,6 +265,7 @@ fn gradient_json(j: &serde_json::Value) -> Option { J::Object(o) => { let colors = stops(o.get("colors")?.as_array()?)?; let op = match o.get("opacities") { + Some(J::Array(a)) if a.len() > MAX_GRADIENT_STOPS => return None, Some(J::Array(a)) => Some( a.iter() .map(|s| { @@ -469,9 +476,10 @@ impl Value { } Value::Layer(_) => Some(Value::Layer(j.as_u64())), Value::Path(_) => serde_json::from_value::(j.clone()).ok().map(Value::Path).or_else(|| serde_json::from_value::(j.clone()).ok()), - Value::Gradient(_) => { - serde_json::from_value::(j.clone()).ok().filter(|v| matches!(v, Value::Gradient(_))).or_else(|| gradient_json(j).map(Value::Gradient)) - } + Value::Gradient(_) => serde_json::from_value::(j.clone()) + .ok() + .filter(|v| matches!(v, Value::Gradient(g) if g.colors.len() <= MAX_GRADIENT_STOPS && g.opacities.len() <= MAX_GRADIENT_STOPS)) + .or_else(|| gradient_json(j).map(Value::Gradient)), } } /// JSON for display/automation (numbers/arrays for numeric kinds). From e2afb4b17c2577f2fecc74de82488030c90203c5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 18:00:27 -0500 Subject: [PATCH 084/227] Timewarp: hold Block Size to its declared range and the matching window to a cap (#3024) --- crates/effects/src/time_fx.rs | 3 ++- crates/raster/src/flow.rs | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index b9d20e2a7..74b392db6 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -565,7 +565,8 @@ impl PixelMotion { let px = |id: &str| (g(id, 0.0).max(0.0) * scale).round() as u32; PixelMotion { tile, - window: ((g("tuning/blockSize", 8.0).max(4.0)) * scale).round().max(4.0) as u32, + // Block Size is declared 4..64; a project can store any number. + window: ((g("tuning/blockSize", 8.0).clamp(4.0, 64.0)) * scale).round().max(4.0) as u32, global_smoothness: g("tuning/smoothing/globalSmoothness", 0.5).clamp(0.0, 1.0) as f32, local_smoothness: g("tuning/smoothing/localSmoothness", 0.5).clamp(0.0, 1.0) as f32, iterations: g("tuning/smoothing/smoothingIterations", 20.0).clamp(0.0, 200.0) as u32, diff --git a/crates/raster/src/flow.rs b/crates/raster/src/flow.rs index 180b235bf..4fdf0e060 100644 --- a/crates/raster/src/flow.rs +++ b/crates/raster/src/flow.rs @@ -118,7 +118,8 @@ pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow { /// (Timewarp's Vector Detail and Block Size). pub fn block_flow_window(a: &Image, b: &Image, tile: u32, window: u32, radius: i32) -> Flow { let bs = tile.max(2) as usize; - let win = window.max(4) as i64; + // The matching cost per vector grows with the window's area; 512 is far above any block size the effects offer. + let win = i64::from(window.clamp(4, 512)); // Window origin relative to the tile origin. let wo = bs as i64 / 2 - win / 2; let (w, h) = (a.width as usize, a.height as usize); @@ -295,6 +296,13 @@ mod tests { s / n as f32 } + #[test] + fn a_huge_matching_window_is_held_to_the_cap() { + let (a, b) = (texture(520, 520, 0.0), texture(520, 520, 2.0)); + let capped = block_flow_window(&a, &b, 260, 512, 2); + assert_eq!(block_flow_window(&a, &b, 260, u32::MAX, 2).v, capped.v); + } + #[test] fn flow_finds_translation() { let a = texture(128, 96, 0.0); From 27fa43fd52bc8aad12ff63f21beaba0cbb61caf7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 18:00:52 -0500 Subject: [PATCH 085/227] text: caret and line lookups by bisection (#3029) --- crates/text/src/layout.rs | 23 +++++++++++++++-------- crates/text/src/layout/tests.rs | 11 +++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 4c0b5a436..25cdb27f5 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -278,19 +278,26 @@ pub struct Layout { impl Layout { /// Line index for a caret at byte `pos`. pub fn line_of(&self, pos: usize) -> usize { - let mut li = 0; - for (i, l) in self.lines.iter().enumerate() { - if l.range.start <= pos { - li = i; - } - } - li + // Lines are in text order: the last one starting at or before `pos`. + self.lines.partition_point(|l| l.range.start <= pos).saturating_sub(1) } /// Caret position `(x, baseline, line)` for byte offset `pos`. pub fn caret(&self, pos: usize) -> (f32, f32, usize) { let li = self.line_of(pos); let Some(l) = self.lines.get(li) else { return (0.0, 0.0, 0) }; - let x = l.carets.iter().min_by_key(|(b, _)| b.abs_diff(pos)).map_or(l.x, |c| c.1); + // Carets are sorted by byte offset and unique: the nearest is next to the insertion point (the lower on a tie). + let i = l.carets.partition_point(|c| c.0 < pos); + let x = match (i.checked_sub(1).and_then(|k| l.carets.get(k)), l.carets.get(i)) { + (Some(p), Some(n)) => { + if pos - p.0 <= n.0 - pos { + p.1 + } else { + n.1 + } + } + (Some(c), None) | (None, Some(c)) => c.1, + (None, None) => l.x, + }; (x, l.baseline, li) } /// Nearest caret byte offset to point `(x, y)`. diff --git a/crates/text/src/layout/tests.rs b/crates/text/src/layout/tests.rs index 5792c1c16..099680148 100644 --- a/crates/text/src/layout/tests.rs +++ b/crates/text/src/layout/tests.rs @@ -171,6 +171,17 @@ fn caps_and_small_caps() { assert_eq!(l.underlines.len(), 1); } +#[test] +fn indexed_caret_lookup_matches_a_linear_scan() { + let l = layout("fi abc\n\nde fg hi", &st(40.0), &ParagraphStyle::default()); + for pos in 0..=l.lines.iter().map(|x| x.range.end).max().unwrap_or(0) + 2 { + let li = l.lines.iter().enumerate().filter(|(_, x)| x.range.start <= pos).map(|(i, _)| i).next_back().unwrap_or(0); + let line = &l.lines[li]; + let want = line.carets.iter().min_by_key(|(b, _)| b.abs_diff(pos)).map_or(line.x, |c| c.1); + assert_eq!(l.caret(pos), (want, line.baseline, li), "pos {pos}"); + } +} + #[test] fn ligature_caret_interpolates() { // Inter has an "fi"-like ligature only via calt in some versions; use "ffi" which most From c30fb9aeedc9697a7f3cc08ce75d6d055c7ed75d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 19:04:34 -0500 Subject: [PATCH 086/227] Sonar: [[ in the plugin-catalog check, Number.* in the boa runtimes, no Invoke-Expression (S7688, S7773, S8659) (#3427) Clears all 29 open SonarCloud issues. - `examples/plugin-catalog/scripts/validate.sh`: `[[ ]]` in place of `[ ]` for bash tests (SonarCloud shell:S7688). Every script has a bash shebang; no test uses `-a`/`-o`, escaped parentheses or an unquoted pattern on the right of `=`. - `crates/expr/src/prelude.js`, `crates/script/src/prelude.js`, `crates/script/src/scriptui.js`: `Number.isFinite` / `Number.isNaN` / `Number.parseInt` (javascript:S7773). Every call already has a number (a `typeof` check or `Number()` before it), and `Number.parseInt === parseInt`, so nothing changes in boa. - `packaging/windows/test-legacy-fixture.ps1`: dot-sources `[scriptblock]::Create($block)` in place of `Invoke-Expression $block` (powershelldre:S8659). It runs in the same scope, so `$LegacyWxs` is still set after it. - `sonar-project.properties`: S7773 and S8786 are not raised on `*.jsx`. Those are After Effects ExtendScript (ES3), which also runs in After Effects and has no `Number.isFinite` or `String.prototype.trim`. The ignore covers 7 issues in `Ease Presets.jsx` and `Rename Layers.jsx`. --- crates/expr/src/prelude.js | 8 ++++---- crates/script/src/prelude.js | 8 ++++---- crates/script/src/scriptui.js | 2 +- examples/plugin-catalog/scripts/validate.sh | 12 ++++++------ packaging/windows/test-legacy-fixture.ps1 | 2 +- sonar-project.properties | 9 ++++++++- 6 files changed, 24 insertions(+), 17 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index cc0405251..9307973d8 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -234,7 +234,7 @@ function __cubicBezier(curve) { x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3]; } if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' || - !isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) { + !Number.isFinite(x1) || !Number.isFinite(y1) || !Number.isFinite(x2) || !Number.isFinite(y2)) { return __lin; } x1 = Math.max(0, Math.min(1, x1)); @@ -418,8 +418,8 @@ function hslToRgb(c) { function hexToRgb(hex) { var s = String(hex).replace(/^#|^0x/i, ''); if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2]; - var n = parseInt(s.slice(0, 6), 16); - var a = s.length >= 8 ? parseInt(s.slice(6, 8), 16) / 255 : 1; + var n = Number.parseInt(s.slice(0, 6), 16); + var a = s.length >= 8 ? Number.parseInt(s.slice(6, 8), 16) / 255 : 1; return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, a]; } @@ -502,7 +502,7 @@ function __child(c, l, group, key) { // it (default 1), the layer at the top (#1619). function __ancestor(c, l, path, countUp) { var n = countUp === undefined ? 1 : __v(countUp); - if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more'); + if (typeof n !== 'number' || !(n >= 1) || !Number.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 __layer(c, l); diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index b9108b2a1..0c39c4dfe 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -26,7 +26,7 @@ function __err(msg) { } function __num(v, what) { var n = Number(v); - if (typeof v === "boolean" || isNaN(n)) throw __err((what || "value") + " must be a number"); + if (typeof v === "boolean" || Number.isNaN(n)) throw __err((what || "value") + " must be a number"); return n; } function __str(v) { @@ -43,11 +43,11 @@ if (typeof String.prototype.substr !== "function") { if (this === null || this === undefined) throw new TypeError("String.substr called on null or undefined"); var text = String(this), n = text.length; var from = Number(start); - if (isNaN(from)) from = 0; + if (Number.isNaN(from)) from = 0; // ToIntegerOrInfinity, then clamp positive or negative offsets. from = from < 0 ? Math.max(n + Math.ceil(from), 0) : Math.min(Math.floor(from), n); var count = length === undefined ? n - from : Number(length); - if (isNaN(count) || count <= 0) return ""; + if (Number.isNaN(count) || count <= 0) return ""; return text.slice(from, from + count); }, writable: true, configurable: true @@ -436,7 +436,7 @@ function __callable(o) { // 1-based collections with `[n]` access. var __coll = { get: function (t, k, r) { - if (typeof k === "string" && /^[0-9]+$/.test(k)) return t.__at(parseInt(k, 10)); + if (typeof k === "string" && /^[0-9]+$/.test(k)) return t.__at(Number.parseInt(k, 10)); return Reflect.get(t, k, r); }, }; diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index ddfbc1f3e..9c5000962 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -750,7 +750,7 @@ function __uiMouseWanted(c, inherited) { function __uiMouseEvent(type, target, v) { var num = function (x, d) { x = Number(x); - return isFinite(x) ? x : d; + return Number.isFinite(x) ? x : d; }; var keys = { alt: !!v.altKey, control: !!v.ctrlKey, ctrl: !!v.ctrlKey, meta: !!v.metaKey, shift: !!v.shiftKey }; return { diff --git a/examples/plugin-catalog/scripts/validate.sh b/examples/plugin-catalog/scripts/validate.sh index 40e37de7f..688ffdc26 100644 --- a/examples/plugin-catalog/scripts/validate.sh +++ b/examples/plugin-catalog/scripts/validate.sh @@ -26,12 +26,12 @@ file_url() { } echo "== 1. schema" -[ "$(wc -c <"$catalog")" -le $((4 << 20)) ] || fail "the catalog is larger than 4 MB (the app's limit)" +[[ "$(wc -c <"$catalog")" -le $((4 << 20)) ]] || fail "the catalog is larger than 4 MB (the app's limit)" # Exactly one object of format 1 with a packages array (an empty file or a second value fails too), # checked even when the schema step is skipped. jq -e -s 'length == 1 and (.[0] | type == "object" and .schema == 1 and (.packages | type == "array"))' "$catalog" >/dev/null \ || fail "$catalog isn't one JSON object with \"schema\": 1 and a \"packages\" array" -if [ "${SKIP_SCHEMA:-}" = 1 ]; then +if [[ "${SKIP_SCHEMA:-}" = 1 ]]; then echo "skipped (SKIP_SCHEMA=1)" else npx --yes ajv-cli@5.0.0 validate --spec=draft2020 --strict=false -s "$here/schema.json" -d "$catalog" @@ -39,7 +39,7 @@ fi echo "== 2. unique ids" dups=$(jq -r '.packages[].id' "$catalog" | sort | uniq -d) -[ -z "$dups" ] || fail "duplicate package ids: $dups" +[[ -z "$dups" ]] || fail "duplicate package ids: $dups" echo "ok" echo "== 3. downloads" @@ -51,15 +51,15 @@ while IFS=$'\t' read -r p r id version url sha size; do curl -fsSL --retry 2 --max-filesize $((64 << 20)) -o "$file" "$url" || fail "$id $version: download failed" got_size=$(wc -c <"$file" | tr -d ' ') got_sha=$(sha256sum "$file" | cut -d' ' -f1) - [ -z "$size" ] || [ "$size" = "$got_size" ] || fail "$id $version: size is $got_size, the catalog says $size" - [ "$got_sha" = "$(tr 'A-F' 'a-f' <<<"$sha")" ] || fail "$id $version: SHA-256 is $got_sha, the catalog says $sha" + [[ -z "$size" ]] || [[ "$size" = "$got_size" ]] || fail "$id $version: size is $got_size, the catalog says $size" + [[ "$got_sha" = "$(tr 'A-F' 'a-f' <<<"$sha")" ]] || fail "$id $version: SHA-256 is $got_sha, the catalog says $sha" local_catalog=$(jq --argjson p "$p" --argjson r "$r" --arg u "$(file_url "$file")" '.packages[$p].versions[$r].url = $u' <<<"$local_catalog") done < <(jq -r '.packages | to_entries[] | .key as $p | .value as $pkg | $pkg.versions | to_entries[] | [$p, .key, $pkg.id, .value.version, .value.url, .value.sha256, (.value.size // "")] | @tsv' "$catalog") echo "ok" echo "== 4. load with EffectCraft" -if [ -z "${EFFECTCRAFT_CLI:-}" ]; then +if [[ -z "${EFFECTCRAFT_CLI:-}" ]]; then echo "skipped (set EFFECTCRAFT_CLI to an effectcraft-cli build)" exit 0 fi diff --git a/packaging/windows/test-legacy-fixture.ps1 b/packaging/windows/test-legacy-fixture.ps1 index 44336dca9..764ec41c3 100644 --- a/packaging/windows/test-legacy-fixture.ps1 +++ b/packaging/windows/test-legacy-fixture.ps1 @@ -8,7 +8,7 @@ $end = $source.IndexOf('$Msi = @{}', $start) if ($start -lt 0 -or $end -le $start) { throw 'Could not locate the legacy source transformation.' } # Run the test harness's exact source-producing block, with only scratch outputs. $block = $source.Substring($start, $end - $start).Replace('$PSScriptRoot', ("'" + $PSScriptRoot.Replace("'", "''") + "'")) -Invoke-Expression $block +. ([scriptblock]::Create($block)) [xml]$legacy = Get-Content -Raw $LegacyWxs $ns = New-Object Xml.XmlNamespaceManager($legacy.NameTable) $ns.AddNamespace('w', 'http://wixtoolset.org/schemas/v4/wxs') diff --git a/sonar-project.properties b/sonar-project.properties index 7d13a08c4..eee8ac4bb 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -9,8 +9,15 @@ sonar.exclusions=crates/aep/tests/fixtures/tools/** # S1523 / S2137 (eval, Function): the expression runtime in crates/expr/src/prelude.js replaces # `eval` and `Function` on purpose, with wrappers that check the code they are given before it # runs (After Effects expressions may call them). The rules flag exactly that wrapping. -sonar.issue.ignore.multicriteria=prelude_eval,prelude_redefine +# +# S7773 / S8786 (Number.isFinite and friends, String.trim): the .jsx scripts are After Effects +# ExtendScript, which is ES3. They also run in After Effects, which has neither. +sonar.issue.ignore.multicriteria=prelude_eval,prelude_redefine,jsx_es3_globals,jsx_es3_trim sonar.issue.ignore.multicriteria.prelude_eval.ruleKey=javascript:S1523 sonar.issue.ignore.multicriteria.prelude_eval.resourceKey=crates/expr/src/prelude.js sonar.issue.ignore.multicriteria.prelude_redefine.ruleKey=javascript:S2137 sonar.issue.ignore.multicriteria.prelude_redefine.resourceKey=crates/expr/src/prelude.js +sonar.issue.ignore.multicriteria.jsx_es3_globals.ruleKey=javascript:S7773 +sonar.issue.ignore.multicriteria.jsx_es3_globals.resourceKey=**/*.jsx +sonar.issue.ignore.multicriteria.jsx_es3_trim.ruleKey=javascript:S8786 +sonar.issue.ignore.multicriteria.jsx_es3_trim.resourceKey=**/*.jsx From 3694f0760097857f3410a7474600976ea22b490e Mon Sep 17 00:00:00 2001 From: lisn0 <33394991+lisn0@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:37:34 +0000 Subject: [PATCH 087/227] Preserve frozen Object Mattes with pre-0.23 footage keys (#3430) --- crates/engine/src/analysis/roto.rs | 14 ++++++++-- crates/engine/src/analysis/warp.rs | 14 ++++++++++ crates/engine/src/tests/roto.rs | 41 ++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index 3fe049e20..16cbb08cd 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -219,7 +219,15 @@ fn fnv(s: &str) -> u64 { /// What the effect's input frames are made of: the layer's source and timing, its masks and the /// effects above the Roto Brush. pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { - let mut s = crate::warp::signature(p, comp, layer); + input_key_with_signature(crate::warp::signature(p, comp, layer), layer, uid) +} + +pub(crate) fn input_key_before_placed_page(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> Option { + let signature = crate::warp::signature_before_placed_page(p, comp, layer)?; + Some(input_key_with_signature(signature, layer, uid)) +} + +fn input_key_with_signature(mut s: String, layer: &Layer, uid: Uid) -> String { if let Some(m) = layer.masks() { s.push_str(&format!("{m:?}")); } @@ -247,7 +255,9 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI for g in fxg.groups().filter(|g| is_roto(g) || is_object_matte(g)) { let key = input_key(after, *cid, l, g.uid); let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid)); - if str_of(g, INPUT_KEY) != key { + let saved_key = str_of(g, INPUT_KEY); + // Keep a matching old key: the frozen cache itself also hashes that key. + if saved_key != key && input_key_before_placed_page(after, *cid, l, g.uid).as_deref() != Some(saved_key.as_str()) { set.push((*cid, l.id, g.uid, key)); todo.push((*cid, l.id, g.uid)); } else if old.is_none_or(|o| guard_of(o) != guard_of(g)) { diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 71ee45ee6..0ab576d2c 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -119,6 +119,20 @@ pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String { }, _ => String::new(), }; + signature_with_source(p, comp, layer, &src) +} + +/// Before `Footage::placed_page` was added in 0.23, the persisted signature hashed the +/// same Debug representation without that last field. Only ordinary footage can match it. +pub(crate) fn signature_before_placed_page(p: &Project, comp: ItemId, layer: &Layer) -> Option { + let LayerSource::Footage { item } = &layer.source else { return None }; + let ItemKind::Footage(footage) = &p.item(*item)?.kind else { return None }; + let src = format!("{footage:?}"); + let old = src.strip_suffix(", placed_page: false }")?; + Some(signature_with_source(p, comp, layer, &format!("{old} }}"))) +} + +fn signature_with_source(p: &Project, comp: ItemId, layer: &Layer, src: &str) -> String { let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default(); let s = format!( "{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}", diff --git a/crates/engine/src/tests/roto.rs b/crates/engine/src/tests/roto.rs index 99c155265..8fc8fb479 100644 --- a/crates/engine/src/tests/roto.rs +++ b/crates/engine/src/tests/roto.rs @@ -603,6 +603,47 @@ fn object_matte_background_job_cancel_and_freeze() { assert_eq!(status(&mut s)["frozen"], true); } +/// A queue edit preserves a frozen matte made before the footage signature gained placed_page. +#[test] +fn render_queue_keeps_frozen_object_matte_with_legacy_footage_key() { + use effectcraft_effects::{object_matte, roto::{FROZEN, FrozenCache, INPUT_KEY}}; + use effectcraft_keyframe::Value::Str; + use std::sync::Arc; + + hold_roto_cache_test_lock(); + let (mut s, clip, _) = setup(|f| draw(center(f))); + let cid = s.active_comp_id().unwrap(); + let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": center(0), "before": 0, "after": 2})).unwrap(); + let uid = r["effect"].as_u64().unwrap(); + s.execute_checked("objectMatte.freeze", json!({"layer": clip.0, "wait": true})).unwrap(); + + let layer = s.project.comp(cid).unwrap().layer(clip).unwrap(); + let legacy = crate::roto::input_key_before_placed_page(&s.project, cid, layer, uid).unwrap(); + assert_ne!(legacy, crate::roto::input_key(&s.project, cid, layer, uid)); + let fps = s.project.comp(cid).unwrap().frame_rate.as_f64(); + // Recreate the two keys serialized by 0.22, leaving the real frozen alpha data alone. + let p = Arc::make_mut(&mut s.project); + let g = p.comp_mut(cid).unwrap().layer_mut(clip).unwrap().props.find_group_mut(uid).unwrap(); + g.get_mut(INPUT_KEY).unwrap().value = Str(legacy.clone()); + let params = crate::roto::params_static(g); + let Some(Str(value)) = params.get(FROZEN) else { panic!("missing frozen matte") }; + let mut frozen: FrozenCache = serde_json::from_str(value).unwrap(); + assert_eq!(frozen.frames.len(), 3); + frozen.key = object_matte::frozen_key(¶ms, [f64::from(W), f64::from(H)], fps, None); + let saved = Str(serde_json::to_string(&frozen).unwrap()); + g.get_mut(FROZEN).unwrap().value = saved.clone(); + + s.execute_checked("renderQueue.add", json!({"comp": cid.0, "format": "png", "output": "matte.png"})).unwrap(); + let params = |s: &Session| { + crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()) + }; + assert_eq!(params(&s).get(INPUT_KEY), Some(&Str(legacy))); + assert_eq!(params(&s).get(FROZEN), Some(&saved)); + + s.execute_checked("layer.timing", json!({"layer": clip.0, "out": 0.5})).unwrap(); + assert_eq!(params(&s).get(FROZEN), Some(&Str(String::new()))); +} + /// Computed Object Matte segmentations go to the persistent disk cache: a second session on the /// same folder and project gets them back without segmenting (its frames cannot even be read /// here), and another prompt (other keys) finds nothing. From 1f04ea571aa90e6eccee6e63c8435c645e1f6ff2 Mon Sep 17 00:00:00 2001 From: lisn0 <33394991+lisn0@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:39:12 +0000 Subject: [PATCH 088/227] Format frozen matte regression test (#3430) --- crates/engine/src/tests/roto.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests/roto.rs b/crates/engine/src/tests/roto.rs index 8fc8fb479..167c157ba 100644 --- a/crates/engine/src/tests/roto.rs +++ b/crates/engine/src/tests/roto.rs @@ -606,7 +606,10 @@ fn object_matte_background_job_cancel_and_freeze() { /// A queue edit preserves a frozen matte made before the footage signature gained placed_page. #[test] fn render_queue_keeps_frozen_object_matte_with_legacy_footage_key() { - use effectcraft_effects::{object_matte, roto::{FROZEN, FrozenCache, INPUT_KEY}}; + use effectcraft_effects::{ + object_matte, + roto::{FROZEN, FrozenCache, INPUT_KEY}, + }; use effectcraft_keyframe::Value::Str; use std::sync::Arc; @@ -634,9 +637,7 @@ fn render_queue_keeps_frozen_object_matte_with_legacy_footage_key() { g.get_mut(FROZEN).unwrap().value = saved.clone(); s.execute_checked("renderQueue.add", json!({"comp": cid.0, "format": "png", "output": "matte.png"})).unwrap(); - let params = |s: &Session| { - crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()) - }; + let params = |s: &Session| crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()); assert_eq!(params(&s).get(INPUT_KEY), Some(&Str(legacy))); assert_eq!(params(&s).get(FROZEN), Some(&saved)); From b8be50ea4b9ccae84d23614ed0d0ede30edc4190 Mon Sep 17 00:00:00 2001 From: lisn0 <33394991+lisn0@users.noreply.github.com> Date: Wed, 11 Nov 2026 01:25:00 +0000 Subject: [PATCH 089/227] Use a released 0.22 project in the frozen matte regression (#3430) --- ATTRIBUTION.md | 6 + crates/engine/src/analysis/roto.rs | 3 +- .../tests/fixtures/object-matte-0.22.ecproj | 947 ++++++++++++++++++ .../object-matte-0.22.ecproj.attribution | 8 + crates/engine/src/tests/roto.rs | 43 +- 5 files changed, 978 insertions(+), 29 deletions(-) create mode 100644 crates/engine/src/tests/fixtures/object-matte-0.22.ecproj create mode 100644 crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5bff5cdb0..b463e79ea 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -129,6 +129,12 @@ configuration from these Apache-2.0 projects: - `crates/segment/src/mobilesam.rs` follows the MobileSAM, TinyViT and Segment Anything architectures and their Apache-2.0 reference code. +## Frozen matte regression fixture + +| Asset | Author | Source | Licence | +|---|---|---|---| +| `crates/engine/src/tests/fixtures/object-matte-0.22.ecproj` | lisn0 | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 | + ## First-party brand marks The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and are not diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index 16cbb08cd..246c8899c 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -222,7 +222,8 @@ pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { input_key_with_signature(crate::warp::signature(p, comp, layer), layer, uid) } -pub(crate) fn input_key_before_placed_page(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> Option { +/// Recognize the input key serialized before 0.23 without re-keying its frozen matte. +fn input_key_before_placed_page(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> Option { let signature = crate::warp::signature_before_placed_page(p, comp, layer)?; Some(input_key_with_signature(signature, layer, uid)) } diff --git a/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj new file mode 100644 index 000000000..9a85aaa65 --- /dev/null +++ b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj @@ -0,0 +1,947 @@ +{ + "savedBy": "0.22.0", + "activeComp": 1, + "openComps": [ + 1 + ], + "schema": 2, + "settings": { + "bit_depth": "Bpc8", + "linearize": false, + "working_space": null, + "blend_linear": false, + "time_display": "Timecode", + "frame_start": 0, + "audio_sample_rate": 48000, + "gpu_acceleration": true, + "color_engine": "Adobe", + "hdr": "Clip", + "output_space": null, + "working_gamma": 2.2 + }, + "items": { + "1": { + "id": 1, + "name": "Main", + "label": "Sandstone", + "comment": "", + "parent": null, + "kind": { + "type": "Comp", + "width": 64, + "height": 48, + "pixel_aspect": 1.0, + "frame_rate": { + "num": 30, + "den": 1 + }, + "duration": 25401600000, + "display_start": 0, + "background": [ + 0.0, + 0.0, + 0.0 + ], + "work_area": [ + 0, + 25401600000 + ], + "layers": [ + { + "id": 3, + "name": "effectcraft-3430-subject.mp4", + "source": { + "type": "Footage", + "item": 2 + }, + "label": "Aqua", + "comment": "", + "start_time": 0, + "in_point": 0, + "out_point": 25401600000, + "stretch": 100.0, + "switches": { + "video": true, + "audio": true, + "solo": false, + "locked": false, + "shy": false, + "collapse": false, + "quality": "Best", + "sampling": "Bilinear", + "effects": true, + "frame_blend": "Off", + "motion_blur": false, + "adjustment": false, + "three_d": false, + "guide": false + }, + "blend_mode": "Normal", + "preserve_transparency": false, + "track_matte": null, + "parent": null, + "markers": [], + "auto_orient": "Off", + "props": { + "uid": 4, + "match": "layer", + "name": "effectcraft-3430-subject.mp4", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 5, + "match": "masks", + "name": "Masks", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [] + }, + { + "node": "Group", + "uid": 6, + "match": "effects", + "name": "Effects", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 26, + "match": "ec.matte.objectmatte", + "name": "Object Matte", + "kind": { + "kind": "Effect", + "effect": "ec.matte.objectmatte" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 27, + "match": "objectPoint", + "name": "Object Point", + "value": { + "t": "Vec2", + "v": [ + 32.0, + 24.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 28, + "match": "useBox", + "name": "Use Box", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 29, + "match": "boxTopLeft", + "name": "Box Top Left", + "value": { + "t": "Vec2", + "v": [ + 14.0, + 8.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 30, + "match": "boxBottomRight", + "name": "Box Bottom Right", + "value": { + "t": "Vec2", + "v": [ + 50.0, + 40.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Group", + "uid": 32, + "match": "objectTracking", + "name": "Tracking", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 31, + "match": "trackObject", + "name": "Track Object", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 33, + "match": "referenceFrame", + "name": "Reference Frame", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Number" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 34, + "match": "framesBefore", + "name": "Frames Before", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 600.0, + "slider_min": 0.0, + "slider_max": 120.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 35, + "match": "framesAfter", + "name": "Frames After", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 600.0, + "slider_min": 0.0, + "slider_max": 120.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 37, + "match": "objectMatte", + "name": "Matte", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 36, + "match": "feather", + "name": "Feather", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 20.0, + "decimals": 1 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 38, + "match": "contrast", + "name": "Contrast", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 39, + "match": "shiftEdge", + "name": "Shift Edge", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": -100.0, + "max": 100.0, + "slider_min": -100.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Prop", + "uid": 40, + "match": "invert", + "name": "Invert", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 41, + "match": "view", + "name": "View", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Final Output", + "Matte" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 42, + "match": "inputKey", + "name": "Input Key", + "value": { + "t": "Str", + "v": "cbefe0c85977c4aa" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 43, + "match": "frozen", + "name": "Frozen", + "value": { + "t": "Str", + "v": "{\"key\":\"1a097b69366afd7f\",\"size\":[64,48],\"frames\":{\"0\":{\"a\":\"gBgAkAX/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IACQBQ==\",\"b\":\"AA==\"}}}" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + } + ] + }, + { + "node": "Group", + "uid": 15, + "match": "transform", + "name": "Transform", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 7, + "match": "anchor", + "name": "Anchor Point", + "value": { + "t": "Vec3", + "v": [ + 32.0, + 24.0, + 0.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 8, + "match": "position", + "name": "Position", + "value": { + "t": "Vec3", + "v": [ + 32.0, + 24.0, + 0.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 9, + "match": "scale", + "name": "Scale", + "value": { + "t": "Vec3", + "v": [ + 100.0, + 100.0, + 100.0 + ] + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 10, + "match": "orientation", + "name": "Orientation", + "value": { + "t": "Vec3", + "v": [ + 0.0, + 0.0, + 0.0 + ] + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 11, + "match": "rotationX", + "name": "X Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 12, + "match": "rotationY", + "name": "Y Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 13, + "match": "rotation", + "name": "Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 14, + "match": "opacity", + "name": "Opacity", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 16, + "match": "materialOptions", + "name": "Material Options", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 17, + "match": "castsShadows", + "name": "Casts Shadows", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Off", + "On", + "Only" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 18, + "match": "lightTransmission", + "name": "Light Transmission", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 19, + "match": "acceptsShadows", + "name": "Accepts Shadows", + "value": { + "t": "Enum", + "v": 1 + }, + "ui": { + "kind": "Popup", + "options": [ + "Off", + "On", + "Only" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 20, + "match": "acceptsLights", + "name": "Accepts Lights", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 21, + "match": "ambient", + "name": "Ambient", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 22, + "match": "diffuse", + "name": "Diffuse", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 23, + "match": "specularIntensity", + "name": "Specular Intensity", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 24, + "match": "specularShininess", + "name": "Specular Shininess", + "value": { + "t": "Scalar", + "v": 5.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 25, + "match": "metal", + "name": "Metal", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + } + ] + } + } + ], + "markers": [], + "shutter_angle": 180.0, + "shutter_phase": -90.0, + "motion_blur_samples": 16, + "motion_blur_adaptive_limit": 128, + "renderer": "Classic3D", + "hide_shy": false, + "enable_motion_blur": true, + "enable_frame_blending": true, + "draft_3d": false, + "preserve_frame_rate": false, + "preserve_resolution": false, + "poster_time": 0, + "global_light": { + "angle": { + "uid": 0, + "match": "globalLightAngle", + "name": "Global Light Angle", + "value": { + "t": "Scalar", + "v": 120.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + "altitude": { + "uid": 0, + "match": "globalLightAltitude", + "name": "Global Light Altitude", + "value": { + "t": "Scalar", + "v": 30.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + } + } + }, + "2": { + "id": 2, + "name": "effectcraft-3430-subject.mp4", + "label": "Aqua", + "comment": "", + "parent": null, + "kind": { + "type": "Footage", + "path": "/tmp/effectcraft-3430-subject.mp4", + "kind": "Video", + "width": 64, + "height": 48, + "pixel_aspect": 1.0, + "frame_rate": { + "num": 30, + "den": 1 + }, + "native_rate": null, + "duration": 25401600000, + "has_video": true, + "has_audio": false, + "alpha": "Ignore", + "premul_color": [ + 0.0, + 0.0, + 0.0 + ], + "loop_count": 1, + "codec": "H.264 (High)", + "missing": false, + "color_profile": "Rec709" + } + } + }, + "next_id": 44 +} \ No newline at end of file diff --git a/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution new file mode 100644 index 000000000..c20c8b02c --- /dev/null +++ b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution @@ -0,0 +1,8 @@ +asset: object-matte-0.22.ecproj +title: One frozen Object Matte from EffectCraft 0.22.0 +author: lisn0 +source: original work +license: MIT OR Apache-2.0 +license-file: ../../../../../LICENSE-MIT +added: 2026-11-11 by lisn0 +notes: Saved by the released 0.22.0 CLI using a generated 64x48 orange rectangle on a blue background. Contains one frozen alpha plane. The source path is a generic test path, and the source video is unnecessary for the state-preservation regression test. diff --git a/crates/engine/src/tests/roto.rs b/crates/engine/src/tests/roto.rs index 167c157ba..e751681c6 100644 --- a/crates/engine/src/tests/roto.rs +++ b/crates/engine/src/tests/roto.rs @@ -606,42 +606,29 @@ fn object_matte_background_job_cancel_and_freeze() { /// A queue edit preserves a frozen matte made before the footage signature gained placed_page. #[test] fn render_queue_keeps_frozen_object_matte_with_legacy_footage_key() { - use effectcraft_effects::{ - object_matte, - roto::{FROZEN, FrozenCache, INPUT_KEY}, - }; + use effectcraft_effects::roto::{FROZEN, INPUT_KEY}; use effectcraft_keyframe::Value::Str; - use std::sync::Arc; hold_roto_cache_test_lock(); - let (mut s, clip, _) = setup(|f| draw(center(f))); - let cid = s.active_comp_id().unwrap(); - let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": center(0), "before": 0, "after": 2})).unwrap(); - let uid = r["effect"].as_u64().unwrap(); - s.execute_checked("objectMatte.freeze", json!({"layer": clip.0, "wait": true})).unwrap(); - + // Saved by the released 0.22.0 CLI, independently of the compatibility helper. + let project = effectcraft_project::Project::from_json(include_str!("fixtures/object-matte-0.22.ecproj")).unwrap(); + let mut s = Session::default(); + s.replace_project(project, None); + let cid = effectcraft_project::ItemId(1); + let clip = LayerId(3); + let uid = 26; + s.open_comp(cid); + let params = |s: &Session| crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()); + let legacy = params(&s).get(INPUT_KEY).unwrap().clone(); + let saved = params(&s).get(FROZEN).unwrap().clone(); let layer = s.project.comp(cid).unwrap().layer(clip).unwrap(); - let legacy = crate::roto::input_key_before_placed_page(&s.project, cid, layer, uid).unwrap(); - assert_ne!(legacy, crate::roto::input_key(&s.project, cid, layer, uid)); - let fps = s.project.comp(cid).unwrap().frame_rate.as_f64(); - // Recreate the two keys serialized by 0.22, leaving the real frozen alpha data alone. - let p = Arc::make_mut(&mut s.project); - let g = p.comp_mut(cid).unwrap().layer_mut(clip).unwrap().props.find_group_mut(uid).unwrap(); - g.get_mut(INPUT_KEY).unwrap().value = Str(legacy.clone()); - let params = crate::roto::params_static(g); - let Some(Str(value)) = params.get(FROZEN) else { panic!("missing frozen matte") }; - let mut frozen: FrozenCache = serde_json::from_str(value).unwrap(); - assert_eq!(frozen.frames.len(), 3); - frozen.key = object_matte::frozen_key(¶ms, [f64::from(W), f64::from(H)], fps, None); - let saved = Str(serde_json::to_string(&frozen).unwrap()); - g.get_mut(FROZEN).unwrap().value = saved.clone(); + assert_ne!(legacy, Str(crate::roto::input_key(&s.project, cid, layer, uid))); s.execute_checked("renderQueue.add", json!({"comp": cid.0, "format": "png", "output": "matte.png"})).unwrap(); - let params = |s: &Session| crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()); - assert_eq!(params(&s).get(INPUT_KEY), Some(&Str(legacy))); + assert_eq!(params(&s).get(INPUT_KEY), Some(&legacy)); assert_eq!(params(&s).get(FROZEN), Some(&saved)); - s.execute_checked("layer.timing", json!({"layer": clip.0, "out": 0.5})).unwrap(); + s.execute_checked("layer.timing", json!({"layer": clip.0, "out": 0.05})).unwrap(); assert_eq!(params(&s).get(FROZEN), Some(&Str(String::new()))); } From f35392cd3eabe7181a29f092b9e44b4a8fe5b709 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 11:22:09 +0530 Subject: [PATCH 090/227] #1615: say EFFECTCRAFT_CPU_INFLIGHT caps Render Queue renders and exports, not previews --- docs/preferences.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/preferences.md b/docs/preferences.md index e20084c5f..4e379909a 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -102,10 +102,10 @@ Commands (CLI, MCP, control channel): `EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting: they keep one frame per thread in flight, so their memory grows with the core count. Set `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the frames in - flight of every render, `effectcraft-cli render` included - ([#1615](https://github.com/storytold/effectcraft/issues/1615)). One exception: while a job - on GPU Acceleration times both compositors on its first frames, it renders them under the - cap but keeps them all (up to 12) until it has chosen. + flight of every Render Queue render and export, `effectcraft-cli render` included + ([#1615](https://github.com/storytold/effectcraft/issues/1615)); previews don't read it. One + exception: while a job on GPU Acceleration times both compositors on its first frames, it + renders them under the cap but keeps them all (up to 12) until it has chosen. - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, the timeline, project panel, render queue and viewer handles From 861c65ab26fb676894321fda9fefca7b0219325b Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 11:22:09 +0530 Subject: [PATCH 091/227] #3335: document EFFECTCRAFT_RQ_RENDERER and the Render Queue's per-job compositor choice architecture.md and web.md still said every job renders on the project's renderer (#414); since #3335 a GPU job times both compositors on its first frames and keeps the faster. --- docs/architecture.md | 9 ++++++--- docs/preferences.md | 6 +++++- docs/web.md | 6 +++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 195634eb1..f50c38f64 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -340,10 +340,13 @@ whichever compositor measured faster for it (`render::auto::AutoPick`, kept by t per comp, output scale and path (readback or viewer display) it averages CPU and GPU frame times (warm-up on both sides, the fastest warm-up frame starting the average, single stalls capped), picks the GPU unless the CPU is clearly faster, and re-measures the other side every 48 frames. -Render Queue jobs don't use Auto: every frame of a job renders on the project's renderer (the -GPU compositor with GPU Acceleration), so runs of a render don't differ by timing. Light comps (a few small layers, which the CPU composites only within their bounds) then stay on -the CPU instead of paying for full-frame passes and a readback. A GPU frame walks the comp like +the CPU instead of paying for full-frame passes and a readback. Render Queue jobs don't use +Auto per frame: a job renders every frame on one compositor, never a mix (#3335). A GPU +Acceleration job with an accelerator times both on its first frames +(`export::pipeline::Cx::probe`, jobs of 12 frames or more) and keeps the faster, the CPU only +when clearly faster; `EFFECTCRAFT_RQ_RENDERER=gpu|cpu` fixes the choice, and readback-deferred +accelerators (the web) render on the project's renderer. A GPU frame walks the comp like `draw_comp`: cached layer buffers are uploaded once per buffer, then transformed with the CPU's sampling (nearest, bilinear, Catmull-Rom bicubic, the same minification pre-filter), motion-blur sub-samples are accumulated, and track mattes, Preserve Transparency, layer style passes, diff --git a/docs/preferences.md b/docs/preferences.md index 4e379909a..215ada597 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -105,7 +105,11 @@ Commands (CLI, MCP, control channel): flight of every Render Queue render and export, `effectcraft-cli render` included ([#1615](https://github.com/storytold/effectcraft/issues/1615)); previews don't read it. One exception: while a job on GPU Acceleration times both compositors on its first frames, it - renders them under the cap but keeps them all (up to 12) until it has chosen. + renders them under the cap but keeps them all (up to 12) until it has chosen. That timing + picks the compositor that a job of 12 frames or more renders every frame on (the render log + names it and why). `EFFECTCRAFT_RQ_RENDERER` set to `gpu` or `cpu` skips it and renders on + that compositor; `auto` (the default) measures. A queue item's later output modules reuse its + first one's choice, and Software Rendering projects always render on the CPU. - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, the timeline, project panel, render queue and viewer handles diff --git a/docs/web.md b/docs/web.md index f94a687be..f4eb882a8 100644 --- a/docs/web.md +++ b/docs/web.md @@ -173,9 +173,9 @@ the same futures with `passes::block_on`; they never wait there). - **Render Queue**: `effectcraft_export::export_async` renders each frame in passes (frames with deferred readbacks render one by one instead of in parallel batches) and encodes as - before (`Exporter::export_async`). As on the desktop, a job renders every frame on the - project's renderer (the worker's GPU with GPU Acceleration), not on whichever - compositor measured faster, so runs don't differ by timing. + before (`Exporter::export_async`). Unlike the desktop, which times both compositors on a + job's first frames and keeps the faster, a job renders every frame on the project's renderer + (the worker's GPU with GPU Acceleration), so runs don't differ by timing. - **Analyses** (Warp Stabilizer, 3D Camera Tracker, Track Motion, mask and face tracking, Roto Brush freeze and propagation): their work functions are `async`; started with `wait` inside a job they are queued and awaited (`offload::run_or_queue`), and each input frame renders in From a76bbf05f7311b125286593c17f746b3dd53e9a8 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 11:34:00 +0530 Subject: [PATCH 092/227] Effects: re-pin the macOS golden hashes #2944 changed by rounding #2944 (497a056c) normalises each box-blur mean in f64 and rounds once (it was rounded to f32 first, then scaled), and counts repeated edge pixels with one multiply instead of adding them one by one. Its PR states results change only by float rounding, and it added the focused regression test the golden-test rule asks for (box_row matches a direct sum). The golden hashes are only enforced on aarch64 macOS, so Linux PR CI passed, and every completed macOS CI run on main since #2944 has failed (497a056c's own run was cancelled by a newer push). Bisected on an M5: the golden tests pass at ed085bd4 and fail at 497a056c with the same 52 hashes macOS CI reports. All 52 are 32 bpc frames of effects that blur through box_plane/gauss_plane (Lightning, Advanced Lightning, Inner/Outer Key, Caustics, Paint Bucket, Vegas); every 8 bpc frame is unchanged. All 52 frames differ by at most 4.8e-6 per channel, with no new non-finite values. Only the pinned hash values change; LIBM_VARIANTS is untouched. Co-Authored-By: Claude Opus 5.5 --- crates/effects/tests/it/gen_golden.rs | 88 +++++++++++++-------------- crates/effects/tests/it/sim_golden.rs | 16 ++--- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/crates/effects/tests/it/gen_golden.rs b/crates/effects/tests/it/gen_golden.rs index 0f5fced8b..45092841a 100644 --- a/crates/effects/tests/it/gen_golden.rs +++ b/crates/effects/tests/it/gen_golden.rs @@ -413,46 +413,46 @@ fn plan_effects_golden_hashes() { const GOLDEN: &[(&str, u64)] = &[ ("ec.obsolete.lightning/0/8bpc/t0.4", 0xd829bb3a79ec2d61), ("ec.obsolete.lightning/0/8bpc/t1.3", 0xb2221364c0defe9b), - ("ec.obsolete.lightning/0/32bpc/t0.4", 0x828cc4e53d579af7), - ("ec.obsolete.lightning/0/32bpc/t1.3", 0x15770238cde9e766), - ("ec.obsolete.lightning/0/32bpc/half/t0.4", 0xf64f2b87fa9a09d3), - ("ec.obsolete.lightning/0/32bpc/half/t1.3", 0x625a00bd52fde5aa), + ("ec.obsolete.lightning/0/32bpc/t0.4", 0x38c8b3ca5d03eb81), + ("ec.obsolete.lightning/0/32bpc/t1.3", 0x552241c6035fa660), + ("ec.obsolete.lightning/0/32bpc/half/t0.4", 0x6158941311ce44d9), + ("ec.obsolete.lightning/0/32bpc/half/t1.3", 0xc7c2a804de6140a3), ("ec.obsolete.lightning/1/8bpc/t0.4", 0xa7c8cb94beebca84), ("ec.obsolete.lightning/1/8bpc/t1.3", 0x6e583b1d1f455099), - ("ec.obsolete.lightning/1/32bpc/t0.4", 0x2ffd973cc7de98f7), - ("ec.obsolete.lightning/1/32bpc/t1.3", 0x8f35e6018cac57d7), - ("ec.obsolete.lightning/1/32bpc/half/t0.4", 0x96c14e911783f8d6), - ("ec.obsolete.lightning/1/32bpc/half/t1.3", 0x620ee605f56c2068), + ("ec.obsolete.lightning/1/32bpc/t0.4", 0xe34f30bf669e94d5), + ("ec.obsolete.lightning/1/32bpc/t1.3", 0xb1ec8fad75bbbc32), + ("ec.obsolete.lightning/1/32bpc/half/t0.4", 0x9e2934559f25d0d9), + ("ec.obsolete.lightning/1/32bpc/half/t1.3", 0x8d2f6967b73bbf54), ("ec.obsolete.lightning/2/8bpc/t0.4", 0x119e5cedac21cd9b), ("ec.obsolete.lightning/2/8bpc/t1.3", 0x04b775fba837ddce), - ("ec.obsolete.lightning/2/32bpc/t0.4", 0xfee767091adac4ce), - ("ec.obsolete.lightning/2/32bpc/t1.3", 0x2ae26f9670c93751), - ("ec.obsolete.lightning/2/32bpc/half/t0.4", 0x99111f77bf26704f), - ("ec.obsolete.lightning/2/32bpc/half/t1.3", 0x8eb781efc14183c3), + ("ec.obsolete.lightning/2/32bpc/t0.4", 0x551415572abb270a), + ("ec.obsolete.lightning/2/32bpc/t1.3", 0x3e1b68b5b9cf2e8c), + ("ec.obsolete.lightning/2/32bpc/half/t0.4", 0x69b347a75e8ac280), + ("ec.obsolete.lightning/2/32bpc/half/t1.3", 0xe89cc144b611a083), ("ec.generate.advancedlightning/0/8bpc/t0.4", 0x5a53bf1e0236225d), ("ec.generate.advancedlightning/0/8bpc/t1.3", 0x5a53bf1e0236225d), - ("ec.generate.advancedlightning/0/32bpc/t0.4", 0x6817bf5b3583e0a0), - ("ec.generate.advancedlightning/0/32bpc/t1.3", 0x6817bf5b3583e0a0), - ("ec.generate.advancedlightning/0/32bpc/half/t0.4", 0x5dcc055f6cca9922), - ("ec.generate.advancedlightning/0/32bpc/half/t1.3", 0x5dcc055f6cca9922), + ("ec.generate.advancedlightning/0/32bpc/t0.4", 0x8ae323866971eaf4), + ("ec.generate.advancedlightning/0/32bpc/t1.3", 0x8ae323866971eaf4), + ("ec.generate.advancedlightning/0/32bpc/half/t0.4", 0x74b4ba806ab8e78b), + ("ec.generate.advancedlightning/0/32bpc/half/t1.3", 0x74b4ba806ab8e78b), ("ec.generate.advancedlightning/1/8bpc/t0.4", 0xf07f11457fa683d0), ("ec.generate.advancedlightning/1/8bpc/t1.3", 0xf07f11457fa683d0), - ("ec.generate.advancedlightning/1/32bpc/t0.4", 0x1b21531df9187431), - ("ec.generate.advancedlightning/1/32bpc/t1.3", 0x1b21531df9187431), - ("ec.generate.advancedlightning/1/32bpc/half/t0.4", 0xf1032a0a9345edf1), - ("ec.generate.advancedlightning/1/32bpc/half/t1.3", 0xf1032a0a9345edf1), + ("ec.generate.advancedlightning/1/32bpc/t0.4", 0x1474a59cbdbcb6aa), + ("ec.generate.advancedlightning/1/32bpc/t1.3", 0x1474a59cbdbcb6aa), + ("ec.generate.advancedlightning/1/32bpc/half/t0.4", 0x4808561ce9d8a4a6), + ("ec.generate.advancedlightning/1/32bpc/half/t1.3", 0x4808561ce9d8a4a6), ("ec.generate.advancedlightning/2/8bpc/t0.4", 0x62b5d179f05c6a4c), ("ec.generate.advancedlightning/2/8bpc/t1.3", 0x62b5d179f05c6a4c), - ("ec.generate.advancedlightning/2/32bpc/t0.4", 0x192256fe81f73975), - ("ec.generate.advancedlightning/2/32bpc/t1.3", 0x192256fe81f73975), - ("ec.generate.advancedlightning/2/32bpc/half/t0.4", 0x953dd51008c3ce31), - ("ec.generate.advancedlightning/2/32bpc/half/t1.3", 0x953dd51008c3ce31), + ("ec.generate.advancedlightning/2/32bpc/t0.4", 0x73c4b44db599a5c4), + ("ec.generate.advancedlightning/2/32bpc/t1.3", 0x73c4b44db599a5c4), + ("ec.generate.advancedlightning/2/32bpc/half/t0.4", 0x3085cf1b5f6e8311), + ("ec.generate.advancedlightning/2/32bpc/half/t1.3", 0x3085cf1b5f6e8311), ("ec.generate.advancedlightning/3/8bpc/t0.4", 0x57b6ad61bd3d89e0), ("ec.generate.advancedlightning/3/8bpc/t1.3", 0x57b6ad61bd3d89e0), - ("ec.generate.advancedlightning/3/32bpc/t0.4", 0x7e7c9d20e94c8b6c), - ("ec.generate.advancedlightning/3/32bpc/t1.3", 0x7e7c9d20e94c8b6c), - ("ec.generate.advancedlightning/3/32bpc/half/t0.4", 0xa37940f5a490a0fe), - ("ec.generate.advancedlightning/3/32bpc/half/t1.3", 0xa37940f5a490a0fe), + ("ec.generate.advancedlightning/3/32bpc/t0.4", 0x86eee3d36bfd41f2), + ("ec.generate.advancedlightning/3/32bpc/t1.3", 0x86eee3d36bfd41f2), + ("ec.generate.advancedlightning/3/32bpc/half/t0.4", 0xb24444985859d820), + ("ec.generate.advancedlightning/3/32bpc/half/t1.3", 0xb24444985859d820), ("ec.generate.radiowaves/0/8bpc/t0.4", 0xbc3643b8af12c44b), ("ec.generate.radiowaves/0/8bpc/t1.3", 0x703e948dc562e132), ("ec.generate.radiowaves/0/32bpc/t0.4", 0x2b0db208ee7517c1), @@ -479,8 +479,8 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.generate.radiowaves/3/32bpc/half/t1.3", 0xcf62c191e2aa8cc4), ("ec.generate.vegas/0/8bpc/t0.4", 0x5e58d44254e9101b), ("ec.generate.vegas/0/8bpc/t1.3", 0x5e58d44254e9101b), - ("ec.generate.vegas/0/32bpc/t0.4", 0xe18712686e402759), - ("ec.generate.vegas/0/32bpc/t1.3", 0xe18712686e402759), + ("ec.generate.vegas/0/32bpc/t0.4", 0x2c6093dc655c6dbe), + ("ec.generate.vegas/0/32bpc/t1.3", 0x2c6093dc655c6dbe), ("ec.generate.vegas/0/32bpc/half/t0.4", 0xb037c97234bbd936), ("ec.generate.vegas/0/32bpc/half/t1.3", 0xb037c97234bbd936), ("ec.generate.vegas/1/8bpc/t0.4", 0x68566eb5dc411cd3), @@ -491,8 +491,8 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.generate.vegas/1/32bpc/half/t1.3", 0xe2f8f5f4a12628d6), ("ec.generate.vegas/2/8bpc/t0.4", 0x002c48a91aea2dcf), ("ec.generate.vegas/2/8bpc/t1.3", 0x002c48a91aea2dcf), - ("ec.generate.vegas/2/32bpc/t0.4", 0x4f550bbbf8b8316b), - ("ec.generate.vegas/2/32bpc/t1.3", 0x4f550bbbf8b8316b), + ("ec.generate.vegas/2/32bpc/t0.4", 0xd703631844687600), + ("ec.generate.vegas/2/32bpc/t1.3", 0xd703631844687600), ("ec.generate.vegas/2/32bpc/half/t0.4", 0x30036df3e56a01ed), ("ec.generate.vegas/2/32bpc/half/t1.3", 0x30036df3e56a01ed), ("ec.generate.stroke/0/8bpc/t0.4", 0xa580fbfb806d655c), @@ -557,10 +557,10 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.generate.paintbucket/1/32bpc/half/t1.3", 0xf5ea3b0a7ac224ed), ("ec.generate.paintbucket/2/8bpc/t0.4", 0x530d0a248dc22c72), ("ec.generate.paintbucket/2/8bpc/t1.3", 0x530d0a248dc22c72), - ("ec.generate.paintbucket/2/32bpc/t0.4", 0xe1b655f8815e1357), - ("ec.generate.paintbucket/2/32bpc/t1.3", 0xe1b655f8815e1357), - ("ec.generate.paintbucket/2/32bpc/half/t0.4", 0xb95b3b205255cd7e), - ("ec.generate.paintbucket/2/32bpc/half/t1.3", 0xb95b3b205255cd7e), + ("ec.generate.paintbucket/2/32bpc/t0.4", 0xf05e50b4261103a3), + ("ec.generate.paintbucket/2/32bpc/t1.3", 0xf05e50b4261103a3), + ("ec.generate.paintbucket/2/32bpc/half/t0.4", 0x7e74e09fcc83615c), + ("ec.generate.paintbucket/2/32bpc/half/t1.3", 0x7e74e09fcc83615c), ("ec.generate.paintbucket/3/8bpc/t0.4", 0xa7485b7277e03e66), ("ec.generate.paintbucket/3/8bpc/t1.3", 0xa7485b7277e03e66), ("ec.generate.paintbucket/3/32bpc/t0.4", 0xc8e06c6eed30448b), @@ -666,16 +666,16 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.key.innerouter/0/32bpc/half/t1.3", 0x003941eb2cd18339), ("ec.key.innerouter/1/8bpc/t0.4", 0xd7bfea3d9550db56), ("ec.key.innerouter/1/8bpc/t1.3", 0xd7bfea3d9550db56), - ("ec.key.innerouter/1/32bpc/t0.4", 0x62f7705e43cc6a71), - ("ec.key.innerouter/1/32bpc/t1.3", 0x62f7705e43cc6a71), - ("ec.key.innerouter/1/32bpc/half/t0.4", 0x22f15f770f22f072), - ("ec.key.innerouter/1/32bpc/half/t1.3", 0x22f15f770f22f072), + ("ec.key.innerouter/1/32bpc/t0.4", 0x36661c78c393b52a), + ("ec.key.innerouter/1/32bpc/t1.3", 0x36661c78c393b52a), + ("ec.key.innerouter/1/32bpc/half/t0.4", 0x73f19ba1aea7e496), + ("ec.key.innerouter/1/32bpc/half/t1.3", 0x73f19ba1aea7e496), ("ec.key.innerouter/2/8bpc/t0.4", 0x7bdece44f841bb11), ("ec.key.innerouter/2/8bpc/t1.3", 0x7bdece44f841bb11), - ("ec.key.innerouter/2/32bpc/t0.4", 0x5130a1fc09c42120), - ("ec.key.innerouter/2/32bpc/t1.3", 0x5130a1fc09c42120), - ("ec.key.innerouter/2/32bpc/half/t0.4", 0x46c0880853618fc8), - ("ec.key.innerouter/2/32bpc/half/t1.3", 0x46c0880853618fc8), + ("ec.key.innerouter/2/32bpc/t0.4", 0x093157401c50396e), + ("ec.key.innerouter/2/32bpc/t1.3", 0x093157401c50396e), + ("ec.key.innerouter/2/32bpc/half/t0.4", 0xbde58c0668a48529), + ("ec.key.innerouter/2/32bpc/half/t1.3", 0xbde58c0668a48529), ("ec.color.colorlink/0/8bpc/t0.4", 0x539d1da6b2ed744a), ("ec.color.colorlink/0/8bpc/t1.3", 0x539d1da6b2ed744a), ("ec.color.colorlink/0/32bpc/t0.4", 0x67ed14da67ce1a97), diff --git a/crates/effects/tests/it/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs index 388790901..4a0f3858b 100644 --- a/crates/effects/tests/it/sim_golden.rs +++ b/crates/effects/tests/it/sim_golden.rs @@ -537,18 +537,18 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.sim.caustics/1/8bpc/t1.5", 0x282968317c36ccdf), ("ec.sim.caustics/1/8bpc/t0.8", 0x282968317c36ccdf), ("ec.sim.caustics/1/8bpc/t2.2", 0x282968317c36ccdf), - ("ec.sim.caustics/1/32bpc/t0.5", 0xc12df8561f396d45), - ("ec.sim.caustics/1/32bpc/t1.5", 0xc12df8561f396d45), - ("ec.sim.caustics/1/32bpc/t0.8", 0xc12df8561f396d45), - ("ec.sim.caustics/1/32bpc/t2.2", 0xc12df8561f396d45), + ("ec.sim.caustics/1/32bpc/t0.5", 0xaa63d15038f75de2), + ("ec.sim.caustics/1/32bpc/t1.5", 0xaa63d15038f75de2), + ("ec.sim.caustics/1/32bpc/t0.8", 0xaa63d15038f75de2), + ("ec.sim.caustics/1/32bpc/t2.2", 0xaa63d15038f75de2), ("ec.sim.caustics/2/8bpc/t0.5", 0x52ab258ae56e9b15), ("ec.sim.caustics/2/8bpc/t1.5", 0x52ab258ae56e9b15), ("ec.sim.caustics/2/8bpc/t0.8", 0x52ab258ae56e9b15), ("ec.sim.caustics/2/8bpc/t2.2", 0x52ab258ae56e9b15), - ("ec.sim.caustics/2/32bpc/t0.5", 0x9a820d0e7c6d11ea), - ("ec.sim.caustics/2/32bpc/t1.5", 0x9a820d0e7c6d11ea), - ("ec.sim.caustics/2/32bpc/t0.8", 0x9a820d0e7c6d11ea), - ("ec.sim.caustics/2/32bpc/t2.2", 0x9a820d0e7c6d11ea), + ("ec.sim.caustics/2/32bpc/t0.5", 0x51d4304b505b5462), + ("ec.sim.caustics/2/32bpc/t1.5", 0x51d4304b505b5462), + ("ec.sim.caustics/2/32bpc/t0.8", 0x51d4304b505b5462), + ("ec.sim.caustics/2/32bpc/t2.2", 0x51d4304b505b5462), ("ec.sim.waveworld/0/8bpc/t0.5", 0x425d5d639d2a6495), ("ec.sim.waveworld/0/8bpc/t1.5", 0x233d7370e580804a), ("ec.sim.waveworld/0/8bpc/t0.8", 0x866da86ce5e78eb2), From 34f62efbcaef1b915e8b0ff81c9b6847482bd673 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 08:11:39 +0100 Subject: [PATCH 093/227] Settings: Fit New Layers to Work Area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings ▸ General ▸ Fit New Layers to Work Area (off by default) makes new text, solid, shape, null and adjustment layers start at the work area start and end at its end. It applies to Layer ▸ New, the shape tools and the Pen tool, and wins over Create Layers at Composition Start Time. Footage and compositions keep their own length. --- crates/engine/src/cmd/layer.rs | 2 +- crates/engine/src/cmd/layer/create.rs | 27 +++++++++++++-- crates/engine/src/cmd/shape_tool.rs | 9 +++-- crates/engine/src/cmd/viewer_cmds.rs | 3 +- crates/engine/src/settings/prefs/dialog.rs | 1 + crates/engine/src/settings/prefs/sections.rs | 3 ++ crates/engine/src/tests/settings.rs | 36 ++++++++++++++++++++ docs/preferences.md | 1 + 8 files changed, 76 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f48fa26f8..89cf45158 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -24,7 +24,7 @@ mod transform; use contents::{add_mask, add_shape_item, mask_props, set_text}; use create::{add_item, new_adjustment, new_null, new_shape, new_solid, new_text}; -pub(crate) use create::{color_p, index_p, insert_layer, insert_layer_at, place, position_p}; +pub(crate) use create::{color_p, fit_span, index_p, insert_layer, insert_layer_at, new_layer_span, place, position_p}; pub(crate) use parent::parent_fixes; use parent::set_parent; use replace::{has_replace_source, replace_source}; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 0196b5aee..14d9f1e0f 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -14,6 +14,22 @@ pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { } } +/// Settings ▸ General ▸ Fit New Layers to Work Area: the `(start, end)` that new text, solid, +/// shape, null and adjustment layers in `comp` take, or `None` for the whole composition. +pub(crate) fn new_layer_span(s: &Session, comp: &effectcraft_project::Comp) -> Option<(Tick, Tick)> { + let (start, end) = (comp.work_area.0, comp.work_area.1.min(comp.duration)); + (s.prefs.general.fit_new_layers_to_work_area && start >= Tick::ZERO && start < end).then_some((start, end)) +} + +/// Start a new layer at `span`'s start and end it at `span`'s end, when there is a span. +pub(crate) fn fit_span(l: &mut Layer, span: Option<(Tick, Tick)>) { + if let Some((start, end)) = span { + l.start_time = start; + l.in_point = start; + l.out_point = end; + } +} + /// Insert a new layer above the selection (or at the top) and select it. pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result { insert_layer_at(proj, st, cid, layer, None) @@ -89,10 +105,12 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect); let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() }; let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name); + let span = new_layer_span(s, &comp); let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| { let folder = solids_folder(proj); let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect })); let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None); + fit_span(&mut l, span); if adjustment { l.switches.adjustment = true; l.label = Label::Purple; @@ -186,9 +204,11 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { } } let edit = b_p(p, "edit").unwrap_or(false); + let span = new_layer_span(s, &comp); let id = s.edit("New Text Layer", None, |proj, st| { let name: String = layer_name.clone().unwrap_or_else(|| effectcraft_project::text_layer_name(&doc.text)); let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None); + fit_span(&mut l, span); // Named after its text, the name follows the text until renamed; a given name does not. l.name_follows_text = Some(layer_name.as_deref().is_none_or(str::is_empty)); if let Some(pr) = l.props.prop_mut("text/sourceText") { @@ -232,8 +252,9 @@ pub(super) fn new_shape(s: &mut Session, p: &Value) -> Result { .and_then(|v| v.as_array()) .map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string(); + let span = new_layer_span(s, &comp); let id = s.edit("New Shape Layer", None, |proj, st| { - let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?; + let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos, span)?; let mut next = proj.next_id; let g = crate::cmd::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint); proj.next_id = next; @@ -249,8 +270,10 @@ fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: & let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let name = str_p(p, "name").unwrap_or(name).to_string(); + let span = new_layer_span(s, &comp); let id = s.edit(label, None, |proj, st| { - let l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None); + let mut l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None); + fit_span(&mut l, span); insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index 57b22ba82..99f1cd859 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -8,6 +8,7 @@ use effectcraft_geom::vec2; use effectcraft_keyframe::{Gradient, ShapePath, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, ItemId, LayerId, LayerSource, Project, PropGroup}; +use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -263,7 +264,8 @@ pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Res } /// A new empty shape layer ("Shape Layer n" unless `name`) with its Position at `position` (comp -/// pixels; else the comp centre), above the selected layer and selected. +/// pixels; else the comp centre), above the selected layer and selected. `span` is the +/// [`new_layer_span`](super::layer::new_layer_span) the layer takes, if any. pub(crate) fn new_shape_layer( proj: &mut Project, st: &mut EditorState, @@ -271,11 +273,13 @@ pub(crate) fn new_shape_layer( cid: ItemId, name: Option<&str>, position: Option<[f64; 2]>, + span: Option<(Tick, Tick)>, ) -> Result { let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count(); let name = name.map(str::to_string).unwrap_or_else(|| format!("Shape Layer {}", count + 1)); let mut l = build::layer(proj, comp, &name, LayerSource::Shape, (comp.width, comp.height), None); place(&mut l, position); + super::layer::fit_span(&mut l, span); insert_layer(proj, st, cid, l) } @@ -346,10 +350,11 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { _ => "Rectangle Tool", }; let name = str_p(p, "name"); + let span = super::layer::new_layer_span(s, &comp); let (lid, group) = s.edit(label, None, |proj, st| { let lid = match target { Some(l) => l, - None => new_shape_layer(proj, st, &comp, cid, name, Some(pos))?, + None => new_shape_layer(proj, st, &comp, cid, name, Some(pos), span)?, }; let mut next = proj.next_id; let g = shape_group(&mut Ids(&mut next), kind, size, &paint).ok_or_else(|| bad(c, "unknown kind"))?; diff --git a/crates/engine/src/cmd/viewer_cmds.rs b/crates/engine/src/cmd/viewer_cmds.rs index 698a46f02..39a5102c9 100644 --- a/crates/engine/src/cmd/viewer_cmds.rs +++ b/crates/engine/src/cmd/viewer_cmds.rs @@ -282,10 +282,11 @@ fn new_path(s: &mut Session, p: &Value) -> Result { path.vertices.iter().fold(([f64::MAX; 2], [f64::MIN; 2]), |(lo, hi), v| ([lo[0].min(v[0]), lo[1].min(v[1])], [hi[0].max(v[0]), hi[1].max(v[1])])); let bounds = [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]]; let name = str_p(p, "name").map(str::to_string); + let span = super::layer::new_layer_span(s, &comp); let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| { let lid = match target { Some(l) => l, - None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None)?, + None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None, span)?, }; let mut next = proj.next_id; let mut ids = Ids(&mut next); diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index c23b2b5b1..d7c50cdce 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -92,6 +92,7 @@ pub fn pages() -> Vec { s("general.showToolTips", "Show Tool Tips", B, true), s("general.viewerWheelScrubbing", "Scrub Composition Frames with Mouse Wheel", B, true), s("general.createLayersAtCompStart", "Create Layers at Composition Start Time", B, true), + s("general.fitNewLayersToWorkArea", "Fit New Layers to Work Area", B, true), s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, true), s("general.defaultSpatialLinear", "Default Spatial Interpolation to Linear", B, true), s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, true), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index ca0e72bef..46d654ab0 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -36,6 +36,9 @@ page!(General { viewer_wheel_scrubbing: bool = false, /// Create New Layers at Composition Start Time (off: at the current time). create_layers_at_comp_start: bool = true, + /// Fit New Layers to Work Area: new text, solid, shape, null and adjustment layers span the + /// work area instead of the whole composition (off, as in After Effects). + fit_new_layers_to_work_area: bool = false, switches_affect_nested_comps: bool = true, /// Default Spatial Interpolation to Linear. default_spatial_linear: bool = false, diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index b37c5ca3e..34f7689f6 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -382,6 +382,42 @@ fn new_layers_start_at_the_current_time_when_asked() { assert_eq!(s.active_comp().unwrap().layers[0].start_time.seconds().round(), 2.0); } +/// Settings ▸ General ▸ Fit New Layers to Work Area: new text, solid, shape, null and adjustment +/// layers span the work area, ahead of the start-time setting; footage keeps its own length. +#[test] +fn new_layers_fit_the_work_area_when_asked() { + let mut s = session_with_comp(); + s.execute("comp.workArea", json!({"start": 2, "end": 5})).unwrap(); + let span = |s: &Session| { + let l = &s.active_comp().unwrap().layers[0]; + let r = |t: effectcraft_time::Tick| (t.seconds() * 10.0).round() / 10.0; + (r(l.start_time), r(l.in_point), r(l.out_point)) + }; + s.execute("layer.newSolid", json!({})).unwrap(); + assert_eq!(span(&s), (0.0, 0.0, 10.0), "off by default"); + + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("prefs.set", json!({"key": "general.createLayersAtCompStart", "value": false})).unwrap(); + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); + for (cmd, p) in [ + ("layer.newSolid", json!({})), + ("layer.newAdjustment", json!({})), + ("layer.newNull", json!({})), + ("layer.newText", json!({"text": "a"})), + ("layer.newShape", json!({"kind": "rect"})), + ("shape.newShape", json!({"kind": "ellipse"})), + ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]})), + ] { + s.state.selected_layers.clear(); + s.execute(cmd, p).unwrap(); + assert_eq!(span(&s), (2.0, 2.0, 5.0), "{cmd}"); + } + + let nested = s.execute("comp.new", json!({"name": "Nested", "duration": 3, "open": false})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.addItem", json!({"item": nested})).unwrap(); + assert_eq!(span(&s), (7.0, 7.0, 10.0), "footage and comps keep their own timing"); +} + #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp(); diff --git a/docs/preferences.md b/docs/preferences.md index e20084c5f..69abf3e76 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -37,6 +37,7 @@ Commands (CLI, MCP, control channel): - `general.showToolTips`: Show Tool Tips - `general.viewerWheelScrubbing`: Scrub Composition Frames with Mouse Wheel (off by default). On: an unmodified mouse wheel over the Composition viewer steps frames, and the smoothed tail of that notch does not zoom or pan. Ctrl/Cmd+wheel and a trackpad still zoom and pan. - `general.createLayersAtCompStart`: Create Layers at Composition Start Time +- `general.fitNewLayersToWorkArea`: Fit New Layers to Work Area (off by default). On: new text, solid, shape, null and adjustment layers start at the work area start and end at its end, ahead of Create Layers at Composition Start Time. Footage and compositions keep their own length. - `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear - `startup.showHomeOnLaunch`: Show Home Screen When Launching - `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash From abdf4c369ad67e8a8cbd0eb940cfb5e562348eaa Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 08:34:05 +0100 Subject: [PATCH 094/227] Settings: move the playhead onto layers the tools fit to the work area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Fit New Layers to Work Area on and the playhead outside the work area, the Type tool, shape tools and Pen now move the playhead to the work area start so the new layer and its controls show. Layer timing is unchanged; Layer ▸ New commands leave the playhead. --- crates/engine/src/cmd/layer.rs | 2 +- crates/engine/src/cmd/layer/create.rs | 13 ++++++++++ crates/engine/src/cmd/shape_tool.rs | 3 +++ crates/engine/src/cmd/viewer_cmds.rs | 3 +++ crates/engine/src/tests/settings.rs | 24 ++++++++++++------- crates/ui-egui/tests/it/ui_text_edit.rs | 30 +++++++++++++++++++++++ crates/ui-egui/tests/it/ui_viewer.rs | 32 +++++++++++++++++++++++++ 7 files changed, 97 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 89cf45158..de43454f4 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -24,7 +24,7 @@ mod transform; use contents::{add_mask, add_shape_item, mask_props, set_text}; use create::{add_item, new_adjustment, new_null, new_shape, new_solid, new_text}; -pub(crate) use create::{color_p, fit_span, index_p, insert_layer, insert_layer_at, new_layer_span, place, position_p}; +pub(crate) use create::{color_p, fit_span, index_p, insert_layer, insert_layer_at, new_layer_span, place, position_p, show_span}; pub(crate) use parent::parent_fixes; use parent::set_parent; use replace::{has_replace_source, replace_source}; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 14d9f1e0f..5147ccc71 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -30,6 +30,16 @@ pub(crate) fn fit_span(l: &mut Layer, span: Option<(Tick, Tick)>) { } } +/// Tools draw on the layer they create: when it was fitted to a span the playhead is outside, +/// move the playhead to the span's start so the new layer shows. The layer's timing is kept. +pub(crate) fn show_span(s: &mut Session, span: Option<(Tick, Tick)>) { + if let Some((start, end)) = span + && !(start..end).contains(&s.time()) + { + s.set_time(start); + } +} + /// Insert a new layer above the selection (or at the top) and select it. pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result { insert_layer_at(proj, st, cid, layer, None) @@ -233,6 +243,9 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { } Ok(id) })?; + if edit { + show_span(s, span); + } Ok(json!({"layer": id.0})) } diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index 99f1cd859..d983ce8d0 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -363,6 +363,9 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { st.selected_layers = vec![lid]; Ok((lid, group)) })?; + if target.is_none() { + super::layer::show_span(s, span); + } Ok(json!({"layer": lid.0, "group": group})) } diff --git a/crates/engine/src/cmd/viewer_cmds.rs b/crates/engine/src/cmd/viewer_cmds.rs index 39a5102c9..2840873cd 100644 --- a/crates/engine/src/cmd/viewer_cmds.rs +++ b/crates/engine/src/cmd/viewer_cmds.rs @@ -301,6 +301,9 @@ fn new_path(s: &mut Session, p: &Value) -> Result { st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }]; Ok((lid, group, path_uid)) })?; + if target.is_none() { + super::layer::show_span(s, span); + } Ok(json!({"layer": lid.0, "group": group, "path": path_uid})) } diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 34f7689f6..f8eb8ed82 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -398,20 +398,26 @@ fn new_layers_fit_the_work_area_when_asked() { s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); s.execute("prefs.set", json!({"key": "general.createLayersAtCompStart", "value": false})).unwrap(); - s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); - for (cmd, p) in [ - ("layer.newSolid", json!({})), - ("layer.newAdjustment", json!({})), - ("layer.newNull", json!({})), - ("layer.newText", json!({"text": "a"})), - ("layer.newShape", json!({"kind": "rect"})), - ("shape.newShape", json!({"kind": "ellipse"})), - ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]})), + // The tools that draw on their new layer (Type, shape tools, Pen) move the playhead into it; + // the menu commands leave it. + for (cmd, p, draws) in [ + ("layer.newSolid", json!({}), false), + ("layer.newAdjustment", json!({}), false), + ("layer.newNull", json!({}), false), + ("layer.newText", json!({"text": "a"}), false), + ("layer.newText", json!({"text": "a", "edit": true}), true), + ("layer.newShape", json!({"kind": "rect"}), false), + ("shape.newShape", json!({"kind": "ellipse"}), true), + ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]}), true), ] { + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); s.state.selected_layers.clear(); + s.state.text_edit = None; s.execute(cmd, p).unwrap(); assert_eq!(span(&s), (2.0, 2.0, 5.0), "{cmd}"); + assert_eq!(s.time().seconds().round(), if draws { 2.0 } else { 7.0 }, "{cmd} playhead"); } + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); let nested = s.execute("comp.new", json!({"name": "Nested", "duration": 3, "open": false})).unwrap()["comp"].as_u64().unwrap(); s.execute("layer.addItem", json!({"item": nested})).unwrap(); diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index 6b68028de..24a43522f 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -169,6 +169,36 @@ fn type_tool_click_type_edit_and_commit() { assert!(h.state().session.state.text_edit.is_none()); } +/// Fit New Layers to Work Area with the playhead past the work area: the Type tool's new layer +/// spans the work area and the playhead moves into it, so the text shows while it is typed and +/// after it is committed. +#[test] +fn type_tool_text_fitted_to_the_work_area_past_the_playhead_shows() { + let mut h = harness(app()); + h.state_mut().show_panel(PanelKind::Composition); + let s = &mut h.state_mut().session; + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("comp.workArea", json!({"start": 0.5, "end": 1})).unwrap(); + s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.5)); + h.run_steps(3); + h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type; + h.run_steps(1); + let comp = rect(&h, "viewer.comp"); + click(&mut h, comp.center(), Modifiers::NONE); + type_text(&mut h, "Hi"); + assert!(h.state().auto.find("viewer.textEdit").is_some(), "the edit region shows"); + let (lid, text, _, _) = edited(&h); + assert_eq!(text, "Hi"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().session.state.text_edit.is_none(), "committed"); + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap(); + let t = s.time(); + assert!(l.in_point <= t && t < l.out_point, "the committed text is on screen: {t:?} in {:?}..{:?}", l.in_point, l.out_point); + assert_eq!(((l.in_point.seconds() * 10.0).round(), (l.out_point.seconds() * 10.0).round()), (5.0, 10.0), "the layer keeps the work area"); + assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hi"); +} + /// #831: clicking another panel (a solid's row in the Timeline) ends text editing and gives the /// keyboard back, whether the edit began with the Type tool (which stays selected) or by command: /// typed characters change no text and Space plays. The Character panel keeps the edit, and a diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index 7934168cd..264f19ad0 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -1145,6 +1145,38 @@ fn shape_pen_draws_a_closed_shape_layer() { assert_eq!(sp.vertices.len(), 4); } +/// Fit New Layers to Work Area with the playhead past the work area: the Pen's new shape layer +/// spans the work area and the playhead moves into it, so the path and its vertex controls show +/// and the closing click lands. +#[test] +fn pen_draws_on_a_layer_fitted_to_the_work_area_past_the_playhead() { + let mut h = harness(); + let s = &mut h.state_mut().session; + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("comp.workArea", json!({"start": 1, "end": 2})).unwrap(); + s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0)); + h.state_mut().ui.tool = Tool::Pen; + h.run_steps(2); + let n0 = h.state().session.active_comp().unwrap().layers.len(); + for p in [[200.0, 100.0], [400.0, 120.0], [300.0, 260.0]] { + let at = screen(&h, p); + click(&mut h, at); + } + let l = h.state().session.active_comp().unwrap().layers[0].clone(); + let t = h.state().session.time(); + assert!(l.in_point <= t && t < l.out_point, "the playhead is on the new layer: {t:?} in {:?}..{:?}", l.in_point, l.out_point); + assert_eq!((l.in_point.seconds().round(), l.out_point.seconds().round()), (1.0, 2.0), "the layer keeps the work area"); + let path = l.props.sub("contents").unwrap().groups().next().unwrap().sub("contents").unwrap().groups().next().unwrap().clone(); + assert!(h.state().auto.find(&format!("viewer.shapePath.{}.vertex.0", path.uid)).is_some(), "the Pen's vertex controls show"); + let at = screen(&h, [200.0, 100.0]); + click(&mut h, at); + let comp = h.state().session.active_comp().unwrap().clone(); + assert_eq!(comp.layers.len(), n0 + 1); + let sp = comp.layers[0].props.find_group(path.uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); + assert_eq!(sp.vertices.len(), 3); + assert!(sp.closed, "the click on the first vertex closes the path"); +} + #[test] fn motion_path_key_drag_edits_that_key() { let mut h = harness(); From 34086899ad5da4e5d5478f9d1e54cb56c7d4828c Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 08:47:25 +0100 Subject: [PATCH 095/227] Settings: fit tools move the target comp's playhead, not the active one show_span now checks and moves the playhead of the comp the layer was made in, through a new Session::set_time_of that set_time uses for the active comp, so time sync and the text editor boundary stay the same. --- crates/engine/src/cmd/layer/create.rs | 10 +++++----- crates/engine/src/cmd/shape_tool.rs | 2 +- crates/engine/src/cmd/viewer_cmds.rs | 2 +- crates/engine/src/session/comps.rs | 23 ++++++++++++++--------- crates/engine/src/tests/settings.rs | 25 +++++++++++++++++++++++++ 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 5147ccc71..0b5116e95 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -31,12 +31,12 @@ pub(crate) fn fit_span(l: &mut Layer, span: Option<(Tick, Tick)>) { } /// Tools draw on the layer they create: when it was fitted to a span the playhead is outside, -/// move the playhead to the span's start so the new layer shows. The layer's timing is kept. -pub(crate) fn show_span(s: &mut Session, span: Option<(Tick, Tick)>) { +/// move `comp`'s playhead to the span's start so the new layer shows. The layer's timing is kept. +pub(crate) fn show_span(s: &mut Session, comp: ItemId, span: Option<(Tick, Tick)>) { if let Some((start, end)) = span - && !(start..end).contains(&s.time()) + && !(start..end).contains(&s.time_of(comp)) { - s.set_time(start); + s.set_time_of(comp, start); } } @@ -244,7 +244,7 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { Ok(id) })?; if edit { - show_span(s, span); + show_span(s, cid, span); } Ok(json!({"layer": id.0})) } diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index d983ce8d0..8b9cc28f2 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -364,7 +364,7 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { Ok((lid, group)) })?; if target.is_none() { - super::layer::show_span(s, span); + super::layer::show_span(s, cid, span); } Ok(json!({"layer": lid.0, "group": group})) } diff --git a/crates/engine/src/cmd/viewer_cmds.rs b/crates/engine/src/cmd/viewer_cmds.rs index 2840873cd..dfa6ce3e7 100644 --- a/crates/engine/src/cmd/viewer_cmds.rs +++ b/crates/engine/src/cmd/viewer_cmds.rs @@ -302,7 +302,7 @@ fn new_path(s: &mut Session, p: &Value) -> Result { Ok((lid, group, path_uid)) })?; if target.is_none() { - super::layer::show_span(s, span); + super::layer::show_span(s, cid, span); } Ok(json!({"layer": lid.0, "group": group, "path": path_uid})) } diff --git a/crates/engine/src/session/comps.rs b/crates/engine/src/session/comps.rs index 51d87e018..8c2e87647 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -114,18 +114,23 @@ impl Session { /// editor is open, so playback with no text editor does no text copying. pub fn set_time(&mut self, t: Tick) { if let Some(c) = self.state.active_comp { - let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); - let edited = commands::text_edit::active_editor_snapshot(self); - self.state.times.insert(c, t); - if self.prefs.general.sync_time_related_items { - // Direct call: this `set_time` owns the editor snapshot for both time changes, - // so a synced move does not take a second snapshot or start a second epoch. - self.sync_related_times_inner(c, t); - } - commands::text_edit::after_external_change(self, edited.as_ref()); + self.set_time_of(c, t); } } + /// Move `comp`'s current time, as [`Session::set_time`] does for the active comp. + pub fn set_time_of(&mut self, c: ItemId, t: Tick) { + let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); + let edited = commands::text_edit::active_editor_snapshot(self); + self.state.times.insert(c, t); + if self.prefs.general.sync_time_related_items { + // Direct call: this `set_time_of` owns the editor snapshot for both time changes, + // so a synced move does not take a second snapshot or start a second epoch. + self.sync_related_times_inner(c, t); + } + commands::text_edit::after_external_change(self, edited.as_ref()); + } + /// Settings ▸ General ▸ Synchronize Time of All Related Items: moving the current time of /// `comp` moves the current time of the comps nested in it (through their precomp layers' /// timing) and of the comps it is nested in, so every related viewer shows the same moment. diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index f8eb8ed82..556135fff 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -424,6 +424,31 @@ fn new_layers_fit_the_work_area_when_asked() { assert_eq!(span(&s), (7.0, 7.0, 10.0), "footage and comps keep their own timing"); } +/// A tool command aimed at another comp moves that comp's playhead into its work area and +/// leaves the active comp's playhead where it was. +#[test] +fn fitted_layers_in_another_comp_move_that_comps_playhead() { + let mut s = session_with_comp(); + let active = s.active_comp_id().unwrap(); + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + let other = effectcraft_project::ItemId(s.execute("comp.new", json!({"name": "Other", "duration": 10, "open": false})).unwrap()["comp"].as_u64().unwrap()); + s.execute("comp.workArea", json!({"comp": other.0, "start": 2, "end": 5})).unwrap(); + s.execute("comp.workArea", json!({"start": 8, "end": 9})).unwrap(); + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); + for (cmd, p) in [ + ("layer.newText", json!({"comp": other.0, "text": "a", "edit": true})), + ("shape.newShape", json!({"comp": other.0, "kind": "ellipse"})), + ("shape.newPath", json!({"comp": other.0, "vertices": [[0, 0], [10, 0], [10, 10]]})), + ] { + s.set_time_of(other, effectcraft_time::Tick::from_seconds_f64(7.0)); + s.state.selected_layers.clear(); + s.state.text_edit = None; + s.execute(cmd, p).unwrap(); + assert_eq!(s.time_of(other).seconds().round(), 2.0, "{cmd}: the target comp's playhead"); + assert_eq!(s.time_of(active).seconds().round(), 7.0, "{cmd}: the active comp's playhead"); + } +} + #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp(); From 1991ad5d56d590a45e9d8ab0e8978908294691bc Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 09:15:58 +0100 Subject: [PATCH 096/227] Settings: fit tools move the playhead to the first frame inside the span The work area need not sit on frames (a typed start, a frame-rate change), so rounding its start to the nearest frame could land before the new layer's in-point. show_span now rounds up to the first frame in the span and moves nothing when the span holds no frame. --- crates/engine/src/cmd/layer/create.rs | 11 +++++++++-- crates/engine/src/tests/settings.rs | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 0b5116e95..c46222e7e 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -31,12 +31,19 @@ pub(crate) fn fit_span(l: &mut Layer, span: Option<(Tick, Tick)>) { } /// Tools draw on the layer they create: when it was fitted to a span the playhead is outside, -/// move `comp`'s playhead to the span's start so the new layer shows. The layer's timing is kept. +/// move `comp`'s playhead to the first frame in the span so the new layer shows. The layer's timing +/// is kept. The work area need not sit on frames (a typed start, a frame-rate change), so the +/// frame is rounded up from the start, not to the nearest; a span holding no frame moves nothing. pub(crate) fn show_span(s: &mut Session, comp: ItemId, span: Option<(Tick, Tick)>) { + let Some(fr) = s.project.comp(comp).map(|c| c.frame_rate) else { return }; if let Some((start, end)) = span && !(start..end).contains(&s.time_of(comp)) { - s.set_time_of(comp, start); + let first = fr.snap(start); + let first = if first < start { fr.tick_of(fr.frame_at(start).saturating_add(1)) } else { first }; + if first < end { + s.set_time_of(comp, first); + } } } diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 556135fff..29792efc0 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -449,6 +449,27 @@ fn fitted_layers_in_another_comp_move_that_comps_playhead() { } } +/// A work area off the frame grid (set at 25 fps, then the comp went to 30 fps): the playhead +/// goes to the first frame inside it, not the nearest frame before its start. +#[test] +fn fitted_layers_off_the_frame_grid_move_the_playhead_inside_them() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Grid", "duration": 2, "frameRate": 25})).unwrap(); + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("comp.workArea", json!({"start": 0.04, "end": 0.08})).unwrap(); + s.execute("comp.settings", json!({"frameRate": 30})).unwrap(); + for (cmd, p) in [("layer.newText", json!({"text": "a", "edit": true})), ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]}))] { + s.set_time(effectcraft_time::Tick::from_seconds_f64(1.0)); + s.state.selected_layers.clear(); + s.state.text_edit = None; + s.execute(cmd, p).unwrap(); + let l = &s.active_comp().unwrap().layers[0]; + let t = s.time(); + assert!(l.in_point <= t && t < l.out_point, "{cmd}: {t:?} in {:?}..{:?}", l.in_point, l.out_point); + assert_eq!(t, effectcraft_time::Tick::from_seconds_f64(2.0 / 30.0), "{cmd}: the first frame inside"); + } +} + #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp(); From c344ff8c237819115c0806b750987b47873175bc Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 09:55:54 +0100 Subject: [PATCH 097/227] Time: keep a composition's final partial frame reachable A frame-rate change can leave the duration off the frame grid. The current-time clamp used duration minus one frame, which put the last reachable frame before a final partial frame, so a layer fitted to a work area inside it never got the playhead. Clamp to the last frame that starts before the end instead; on-grid durations are unchanged. --- crates/engine/src/session/comps.rs | 7 +++++-- crates/engine/src/tests/settings.rs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/session/comps.rs b/crates/engine/src/session/comps.rs index 8c2e87647..f8aba8f5a 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -1,8 +1,11 @@ use super::{Comp, CompSelection, Event, ItemId, LayerId, Result, Session, Tick, Value, commands}; -/// `t` as a current time of `comp`: a frame within its duration. +/// `t` as a current time of `comp`: a frame within its duration. The last frame is the last one +/// that starts before the end, so a final partial frame (a duration off the frame grid after a +/// frame-rate change) stays reachable; on the grid it is `duration - frame_duration`. fn comp_frame_at(comp: &Comp, t: Tick) -> Tick { - comp.frame_rate.snap_nearest(t.min(comp.duration - comp.frame_duration()).max(Tick::ZERO)) + let last = comp.frame_rate.snap(comp.duration - Tick(1)).max(Tick::ZERO); + comp.frame_rate.snap_nearest(t.max(Tick::ZERO)).min(last) } impl Session { diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 29792efc0..8caafb3e9 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -470,6 +470,26 @@ fn fitted_layers_off_the_frame_grid_move_the_playhead_inside_them() { } } +/// A duration off the frame grid (2.01 s set at 100 fps, then 24 fps) keeps its final partial +/// frame reachable: a layer fitted to a work area inside it gets the playhead on that frame. +#[test] +fn fitted_layers_in_the_final_partial_frame_get_the_playhead() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Tail", "duration": 2.01, "frameRate": 100})).unwrap(); + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("comp.workArea", json!({"start": 2.0, "end": 2.01})).unwrap(); + s.execute("comp.settings", json!({"frameRate": 24})).unwrap(); + s.set_time(effectcraft_time::Tick::ZERO); + s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]})).unwrap(); + let l = &s.active_comp().unwrap().layers[0]; + let t = s.time(); + assert!(l.in_point <= t && t < l.out_point, "{t:?} in {:?}..{:?}", l.in_point, l.out_point); + assert_eq!(t, effectcraft_time::Tick::from_seconds_f64(2.0), "frame 48 of 24 fps"); + // The playhead can't go past that last frame. + s.set_time(effectcraft_time::Tick::from_seconds_f64(5.0)); + assert_eq!(s.time(), effectcraft_time::Tick::from_seconds_f64(2.0)); +} + #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp(); From 5f1c5ad4048144c7305b1d6254bc2a9ba3b045d2 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 10:19:45 +0100 Subject: [PATCH 098/227] Time: synced comp times reach the final partial frame too Synchronize Time of All Related Items clamped each related comp's time with its own duration-minus-one-frame rule, so a nested comp whose duration is off the frame grid could not reach its final partial frame. Use comp_frame_at, as direct moves and restored times do. --- crates/engine/src/session/comps.rs | 6 +++--- crates/engine/src/tests/settings.rs | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/session/comps.rs b/crates/engine/src/session/comps.rs index f8aba8f5a..6ea79f0d9 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -147,8 +147,8 @@ impl Session { /// Sets the time of every comp related to `comp` through precomp layers: down into the comps /// it nests (through each layer's timing) and up into the comps nesting it. A seen-set keeps - /// cycles and diamonds finite; each found time is clamped to its comp's last frame, snapped to - /// the nearest frame and stored in `state.times`. The caller owns the text-editor boundary. + /// cycles and diamonds finite; each found time goes through [`comp_frame_at`] (as a direct move + /// does) and is stored in `state.times`. The caller owns the text-editor boundary. fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) { let mut seen = std::collections::BTreeSet::from([comp]); let mut todo = vec![(comp, t)]; @@ -178,7 +178,7 @@ impl Session { } for (cid, ct) in found { if let Some(c) = self.project.comp(cid) { - let ct = c.frame_rate.snap_nearest(ct.clamp(Tick::ZERO, (c.duration - c.frame_duration()).max(Tick::ZERO))); + let ct = comp_frame_at(c, ct); self.state.times.insert(cid, ct); } } diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 8caafb3e9..0f8d6ffa2 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -490,6 +490,19 @@ fn fitted_layers_in_the_final_partial_frame_get_the_playhead() { assert_eq!(s.time(), effectcraft_time::Tick::from_seconds_f64(2.0)); } +/// Synchronize Time of All Related Items reaches a nested comp's final partial frame as a direct +/// move does: the inner comp is 2.01 s (set at 100 fps, then 24 fps), the outer sits at 2.0 s. +#[test] +fn synced_time_reaches_a_nested_final_partial_frame() { + let mut s = Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "duration": 2.01, "frameRate": 100, "open": false})).unwrap()["comp"].as_u64().unwrap(); + s.execute("comp.settings", json!({"comp": inner, "frameRate": 24})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "duration": 4, "frameRate": 24})).unwrap(); + s.execute("layer.addItem", json!({"item": inner, "time": 0})).unwrap(); + s.set_time(effectcraft_time::Tick::from_seconds_f64(2.0)); + assert_eq!(s.time_of(effectcraft_project::ItemId(inner)), effectcraft_time::Tick::from_seconds_f64(2.0)); +} + #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp(); From 5922dabd3a087388450d1e115e1a097ea811b012 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 16:04:11 +0530 Subject: [PATCH 099/227] Time entry: a negative displayed timecode goes to the frame it shows (#3373) In a comp whose Start Timecode is negative, the current-time display shows the first frames as `-0:00:00:12`, but typing that back (the Go to Time dialog opens with it since #3419, and the Timeline's time field takes it) read the leading `-` as "move back", so OK on an unedited dialog jumped to another frame. There, a `-` before a full timecode is now the time's sign, as it already is before Feet + Frames; a bare `-n` still steps back, and comps that start at 0 or later are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/time.rs | 18 +++++++++++++++++- crates/engine/src/tests/timeline.rs | 25 +++++++++++++++++++++++++ crates/ui-egui/src/panels/forms.rs | 16 ++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/time.rs b/crates/engine/src/cmd/time.rs index 556c02bcb..49a3caede 100644 --- a/crates/engine/src/cmd/time.rs +++ b/crates/engine/src/cmd/time.rs @@ -43,7 +43,9 @@ pub fn display_duration(s: &Session, comp: &Comp, len: Tick) -> String { } /// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame -/// `current`) into a comp frame. +/// `current`) into a comp frame. In Timecode style, in a comp whose Start Timecode is negative, a +/// `-` before a timecode with separators (`-0:00:00:12`, `-1.00`) is the sign of a displayed time, +/// not a step back; a bare `-n` still steps back. pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result { let fr = comp.frame_rate; let st = &s.project.settings; @@ -53,6 +55,20 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0) } None => { + // A comp whose Start Timecode is negative shows the times before 0 with a `-` + // (`-0:00:00:12`), so there a `-` before a timecode with separators is the sign of a + // displayed time and typing what the display shows lands on the frame it shows. A bare + // `-n` still moves n frames back. + if st.time_display == TimeDisplayStyle::Timecode + && comp.display_start < Tick::ZERO + && let Some(body) = text.trim().strip_prefix('-').map(str::trim_start) + && body.contains([':', ';', '.', ',']) + && !body.starts_with(['+', '-']) + { + let drop_frame = fr.supports_drop_frame() && body.contains(';'); + let shown = effectcraft_time::parse_timecode(body, fr, drop_frame, current).map_err(|e| e.0)?; + return Ok(shown.saturating_neg().saturating_sub(fr.frame_at(comp.display_start))); + } // The Timeline shows non-drop timecode (`:`), so typed `:`/`.` text is read as non-drop // too; only explicit `;` fields mean drop-frame (#890). let drop_frame = fr.supports_drop_frame() && text.contains(';'); diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index 55475eef3..b5a44239b 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -649,6 +649,31 @@ fn time_set_keeps_the_frame_shown_in_the_current_time_field() { assert_eq!(r["frame"], 13); } +/// A comp that starts before 0 shows its first frames as negative timecode (`-0:00:00:12`). +/// Typing back what the current-time display shows (Go to Time opens with it) stays on that frame; +/// the leading `-` used to move back from the current frame instead. A bare `-n` still steps back, +/// and in a comp that starts at 0 a `-` stays relative. +#[test] +fn time_set_round_trips_negative_displayed_timecode() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Pre-roll", "width": 64, "height": 64, "frameRate": 24, "duration": 4, "startTime": -1})).unwrap(); + for frame in [0_i64, 1, 12, 23, 24, 25, 60] { + let r = s.execute("time.set", json!({"frame": frame})).unwrap(); + let display = r["display"].as_str().unwrap().to_string(); + let r = s.execute("time.set", json!({"timecode": display})).unwrap(); + assert_eq!(r["frame"], frame, "displayed {display}"); + } + s.execute("time.set", json!({"frame": 12})).unwrap(); + assert_eq!(s.execute("time.set", json!({"timecode": "-0:00:00:12"})).unwrap()["display"], "-0:00:00:12"); + assert_eq!(s.execute("time.set", json!({"timecode": "-5"})).unwrap()["frame"], 7, "a bare -n steps back"); + assert_eq!(s.execute("time.set", json!({"timecode": "-0:00:00:20"})).unwrap()["frame"], 4); + assert!(s.execute("time.set", json!({"timecode": "- -1:00"})).is_err(), "a second sign is still an error"); + + s.execute("comp.settings", json!({"startTime": 0})).unwrap(); + s.execute("time.set", json!({"frame": 12})).unwrap(); + assert_eq!(s.execute("time.set", json!({"timecode": "-0:00:00:02"})).unwrap()["frame"], 10, "relative when the comp starts at 0"); +} + /// Frames display style: typed digits are a frame count, so 00100 is frame 100, not 1 s (#530). #[test] fn time_set_reads_frames_style_digits_as_a_frame_count() { diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 9b94360a5..58f89e39d 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -1130,6 +1130,22 @@ mod tests { assert_eq!(at, 19, "displayed 00020 with frames starting at 1"); } + /// In a comp that starts 1 s before 0, Go to Time at frame 12 opens on the negative time the + /// viewer shows, and OK without editing it stays on frame 12 (it used to jump to frame 0). + #[test] + fn go_to_time_ok_keeps_a_negative_displayed_time() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("comp.new", json!({"name": "Main", "width": 64, "height": 36, "frameRate": 24, "duration": 4, "startTime": -1})).unwrap(); + app.session.execute("time.set", json!({"frame": 12})).unwrap(); + assert!(open_form(&mut app, "time.set", &json!({}))); + let f = &app.dialog_state.form; + assert!(matches!(&f.fields[0].kind, FieldKind::Text(t) if t == "-0:00:00:12"), "{:?}", f.fields[0]); + let (command, params) = (f.command.clone(), f.params()); + app.session.execute(&command, params).unwrap(); + let at = app.session.active_comp().unwrap().frame_rate.frame_at(app.session.time()); + assert_eq!(at, 12, "OK on the unedited time stays put"); + } + /// File ▸ Export ▸ Adobe Premiere Pro Project… opens a form that says it writes Final Cut Pro /// XML; its fields register automation ids and OK runs `file.exportTimeline`. #[test] From 47fc00f85d9169751ad2d0674f228afaf233681a Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:58:07 +0100 Subject: [PATCH 100/227] test(ui-egui): split the crypto-pick viewer matrix into one test per window size picks_what_the_viewer_shows_across_sizes_scales_zoom_pan_resolution_and_proxies looped over four independent window-size and display-scale cases, each with its own session. As one test it took ~90 s in the gate and ~590 s under coverage, where it alone set the slowest SonarCloud shard. Each case is now its own test, running the same checks, so nextest runs them in parallel and partitions them across shards. The "more than 100 hovers checked" floor becomes more than 25 per case, which still adds up to more than 100. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/it/ui_crypto_pick.rs | 153 ++++++++++++---------- 1 file changed, 86 insertions(+), 67 deletions(-) diff --git a/crates/ui-egui/tests/it/ui_crypto_pick.rs b/crates/ui-egui/tests/it/ui_crypto_pick.rs index 0d1767c7e..03e49ca18 100644 --- a/crates/ui-egui/tests/it/ui_crypto_pick.rs +++ b/crates/ui-egui/tests/it/ui_crypto_pick.rs @@ -277,84 +277,103 @@ fn eyedropper_arms_hovers_picks_with_modifiers_and_stays_armed_until_esc() { let _ = std::fs::remove_dir_all(&ids.dir); } -/// Window and panel sizes × display scales × viewer zoom / pan × Resolution (and a proxy): hover -/// and click name the ID of the pixel the viewer shows, next to cell edges too. Each hover is -/// checked against the rendered matte of the named ID at the point the screen maps back to. +// Window and panel sizes × display scales, one test each so they run in parallel. Together they +// took ~90 s as one test, and ~10 min under coverage, where that one test set the slowest shard. #[test] -fn picks_what_the_viewer_shows_across_sizes_scales_zoom_pan_resolution_and_proxies() { +fn picks_what_the_viewer_shows_at_900x640_and_1x() { + picks_what_the_viewer_shows(0, vec2(900.0, 640.0), 1.0, 300.0); +} + +#[test] +fn picks_what_the_viewer_shows_at_1280x720_and_1_25x() { + picks_what_the_viewer_shows(1, vec2(1280.0, 720.0), 1.25, 420.0); +} + +#[test] +fn picks_what_the_viewer_shows_at_1600x1000_and_1_5x() { + picks_what_the_viewer_shows(2, vec2(1600.0, 1000.0), 1.5, 360.0); +} + +#[test] +fn picks_what_the_viewer_shows_at_2400x1400_and_2x() { + picks_what_the_viewer_shows(3, vec2(2400.0, 1400.0), 2.0, 600.0); +} + +/// Case `i`: a window of `size` at display scale `ppp`, with an Effect Controls panel `ec` wide, +/// × viewer zoom / pan × Resolution (and a proxy). Hover and click name the ID of the pixel the +/// viewer shows, next to cell edges too. Each hover is checked against the rendered matte of the +/// named ID at the point the screen maps back to. Case 0 also checks frames from the idle cache. +fn picks_what_the_viewer_shows(i: usize, size: egui::Vec2, ppp: f32, ec: f32) { let mut checked = 0; let mut failures = vec![]; - let cases = [(vec2(900.0, 640.0), 1.0, 300.0), (vec2(1280.0, 720.0), 1.25, 420.0), (vec2(1600.0, 1000.0), 1.5, 360.0), (vec2(2400.0, 1400.0), 2.0, 600.0)]; - for (i, (size, ppp, ec)) in cases.into_iter().enumerate() { - let (s, ids) = crypto_session(&format!("matrix{i}"), 1919, 1079, false); - let mut h = harness(s, size, ppp, ec); - h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); - arm(&mut h, &ids); - for proxy in [false, true] { - if proxy { - let p = ids.dir.join("proxy").join("plate_proxy.exr"); - h.state_mut().session.execute("file.setProxy", json!({"item": ids.footage, "path": p.to_string_lossy()})).unwrap(); - } - for res in [Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter, Resolution::Custom(6)] { - for (zoom, pan) in [(None, [0.0, 0.0]), (Some(0.5), [0.0, 0.0]), (Some(2.0), [-300.0, 120.0])] { - let cid = h.state().session.active_comp_id().unwrap(); - h.state_mut().session.set_resolution(cid, res).unwrap(); - h.state_mut().ui.viewer.zoom = zoom.map(|z: f32| z / ppp); - h.state_mut().ui.viewer.pan = pan; - settle(&mut h); - let area = h.state().auto.find("viewer.fxPick").unwrap().rect; - let scale = shown_scale(&h); - // Points next to cell edges, in the middle and at the far corner. - // (Cell edges far from the origin: 925, 1044 shows the 1/3 vs 0.333 rounding.) - for p in [[36.6, 28.7], [37.4, 29.3], [959.7, 539.2], [1918.4, 1078.6], [74.1, 57.9], [925.4, 1044.4], [1110.6, 1043.6]] { - let Some(sp) = comp_to_screen(&h.ctx, [p[0] as f32, p[1] as f32]) else { continue }; - if sp.x < area[0] + 4.0 || sp.y < area[1] + 40.0 || sp.x > area[0] + area[2] - 4.0 || sp.y > area[1] + area[3] - 4.0 { - continue; - } - let got = hover(&mut h, sp); - let got = got.trim_end_matches(" (selected)").to_string(); - // The comp point under the cursor, as the viewer maps it back. - let q = to_comp(&h, sp); - checked += 1; - // The named ID has the largest coverage of the shown pixel (a blended edge - // pixel of a proxy at Quarter has several). No ID named: the frame shows - // (almost) no matte there either (an odd size's last half-resolution pixel - // samples just outside the layer). - let ok = if got.starts_with('c') { largest(&mut h, &got, q, scale) } else { got == HINT && lit(&mut h, "*", q, scale) < 0.5 }; - if !ok { - failures.push(format!( - "{size:?} ppp {ppp} proxy {proxy} {res:?} zoom {zoom:?} (scale {scale}): at {q:?} hover {got:?} is not lit there" - )); - } + let (s, ids) = crypto_session(&format!("matrix{i}"), 1919, 1079, false); + let mut h = harness(s, size, ppp, ec); + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + arm(&mut h, &ids); + for proxy in [false, true] { + if proxy { + let p = ids.dir.join("proxy").join("plate_proxy.exr"); + h.state_mut().session.execute("file.setProxy", json!({"item": ids.footage, "path": p.to_string_lossy()})).unwrap(); + } + for res in [Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter, Resolution::Custom(6)] { + for (zoom, pan) in [(None, [0.0, 0.0]), (Some(0.5), [0.0, 0.0]), (Some(2.0), [-300.0, 120.0])] { + let cid = h.state().session.active_comp_id().unwrap(); + h.state_mut().session.set_resolution(cid, res).unwrap(); + h.state_mut().ui.viewer.zoom = zoom.map(|z: f32| z / ppp); + h.state_mut().ui.viewer.pan = pan; + settle(&mut h); + let area = h.state().auto.find("viewer.fxPick").unwrap().rect; + let scale = shown_scale(&h); + // Points next to cell edges, in the middle and at the far corner. + // (Cell edges far from the origin: 925, 1044 shows the 1/3 vs 0.333 rounding.) + for p in [[36.6, 28.7], [37.4, 29.3], [959.7, 539.2], [1918.4, 1078.6], [74.1, 57.9], [925.4, 1044.4], [1110.6, 1043.6]] { + let Some(sp) = comp_to_screen(&h.ctx, [p[0] as f32, p[1] as f32]) else { continue }; + if sp.x < area[0] + 4.0 || sp.y < area[1] + 40.0 || sp.x > area[0] + area[2] - 4.0 || sp.y > area[1] + area[3] - 4.0 { + continue; + } + let got = hover(&mut h, sp); + let got = got.trim_end_matches(" (selected)").to_string(); + // The comp point under the cursor, as the viewer maps it back. + let q = to_comp(&h, sp); + checked += 1; + // The named ID has the largest coverage of the shown pixel (a blended edge + // pixel of a proxy at Quarter has several). No ID named: the frame shows + // (almost) no matte there either (an odd size's last half-resolution pixel + // samples just outside the layer). + let ok = if got.starts_with('c') { largest(&mut h, &got, q, scale) } else { got == HINT && lit(&mut h, "*", q, scale) < 0.5 }; + if !ok { + failures + .push(format!("{size:?} ppp {ppp} proxy {proxy} {res:?} zoom {zoom:?} (scale {scale}): at {q:?} hover {got:?} is not lit there")); } } } - // Frames from Cache Frames When Idle (the first case): rendered on the grid the - // viewer renders and the pick reads (1/3, not the key's 0.333), so frame 20 from the - // idle cache is frame 0 as the viewer rendered it, and hovers there are right too. - if i == 0 && !proxy { - (h.state_mut().ui.viewer.zoom, h.state_mut().ui.viewer.pan) = (None, [0.0, 0.0]); - for res in [Resolution::Third, Resolution::Custom(6)] { - let fails = idle_cached_frames_are_shown_frames(&mut h, res); - checked += 1; - failures.extend(fails); - } - } - // A click picks what the hover named. + } + // Frames from Cache Frames When Idle (the first case): rendered on the grid the + // viewer renders and the pick reads (1/3, not the key's 0.333), so frame 20 from the + // idle cache is frame 0 as the viewer rendered it, and hovers there are right too. + if i == 0 && !proxy { (h.state_mut().ui.viewer.zoom, h.state_mut().ui.viewer.pan) = (None, [0.0, 0.0]); - settle(&mut h); - let sp = comp_to_screen(&h.ctx, [959.7, 539.2]).unwrap(); - let named = hover(&mut h, sp); - click(&mut h, sp, Modifiers::NONE); - assert_eq!(selection(&h, &ids), named.trim_end_matches(" (selected)")); - if proxy { - h.state_mut().session.execute("file.setProxy", json!({"item": ids.footage, "path": null})).ok(); + for res in [Resolution::Third, Resolution::Custom(6)] { + let fails = idle_cached_frames_are_shown_frames(&mut h, res); + checked += 1; + failures.extend(fails); } } - let _ = std::fs::remove_dir_all(&ids.dir); + // A click picks what the hover named. + (h.state_mut().ui.viewer.zoom, h.state_mut().ui.viewer.pan) = (None, [0.0, 0.0]); + settle(&mut h); + let sp = comp_to_screen(&h.ctx, [959.7, 539.2]).unwrap(); + let named = hover(&mut h, sp); + click(&mut h, sp, Modifiers::NONE); + assert_eq!(selection(&h, &ids), named.trim_end_matches(" (selected)")); + if proxy { + h.state_mut().session.execute("file.setProxy", json!({"item": ids.footage, "path": null})).ok(); + } } + let _ = std::fs::remove_dir_all(&ids.dir); assert!(failures.is_empty(), "{} of {checked} hovers wrong:\n{}", failures.len(), failures.join("\n")); - assert!(checked > 100, "{checked}"); + // more than 100 over the four cases, as when they were one test + assert!(checked > 25, "{checked}"); } fn set_frame(h: &mut Harness<'_, EffectcraftApp>, f: i64) { From 6b7731d05a9a1161bef809f56b164887c46dcc4c Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 16:15:54 +0530 Subject: [PATCH 101/227] #3365: the losses prompt shows every detail line and how many have none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt showed 3 detail lines per category under a bare count, which read as the project's total. The headline now says how many occurrences are left out or changed, every line the report keeps shows, and "… and N more" counts the rest. The import report records that number as `unlisted` (an unmapped effect's line covers all its instances); unmapped effects are listed most used first. Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import/report.rs | 45 ++++++++++++++++++++++--- crates/ui-egui/src/i18n/ui.rs | 22 ++++++++++++ crates/ui-egui/src/panels/aep_losses.rs | 39 ++++++++++++++++----- docs/aep-import.md | 10 ++++-- 4 files changed, 99 insertions(+), 17 deletions(-) diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index 2825b61b3..aa50f4dff 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -98,8 +98,12 @@ pub struct Loss { pub category: LossCategory, /// How many occurrences (exact). pub count: usize, - /// Up to [`MAX_DETAILS`] example lines. + /// Example lines: up to [`MAX_DETAILS`], and for effects with no counterpart one more line per + /// effect (with its number of instances). pub details: Vec, + /// How many of the `count` occurrences no line of `details` describes. + #[serde(default)] + pub unlisted: usize, } /// What arrived in the project. @@ -281,7 +285,7 @@ const MAX_UNMAPPED_EFFECTS: usize = 100; impl Collector { /// Records one loss. pub fn lose(&mut self, category: LossCategory, detail: impl Into) { - let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() }); + let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new(), unlisted: 0 }); loss.count = loss.count.saturating_add(1); if loss.details.len() < MAX_DETAILS { loss.details.push(detail.into()); @@ -292,7 +296,8 @@ impl Collector { /// made in [`Collector::finish`], one per effect with the number of instances, so that many /// other losses cannot hide which effects were dropped. pub fn lose_unmapped_effect(&mut self, match_name: &str, detail: String) { - let loss = self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new() }); + let loss = + self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new(), unlisted: 0 }); loss.count = loss.count.saturating_add(1); if let Some(seen) = self.unmapped_effects.get_mut(match_name) { seen.1 = seen.1.saturating_add(1); @@ -303,12 +308,22 @@ impl Collector { } pub fn finish(mut self) -> ImportReport { + // Every line describes one occurrence, except an unmapped effect's, which describes all + // its instances. + for loss in self.losses.values_mut() { + loss.unlisted = loss.count.saturating_sub(loss.details.len()); + } if let Some(loss) = self.losses.get_mut(&LossCategory::Effects) { - let named = std::mem::take(&mut self.unmapped_effects).into_values().map(|(first, n)| match n { + // The most used effects first. + let mut named: Vec<(String, usize)> = std::mem::take(&mut self.unmapped_effects).into_values().collect(); + named.sort_by(|a, b| b.1.cmp(&a.1)); + let described = named.iter().fold(0usize, |sum, (_, n)| sum.saturating_add(*n)); + loss.unlisted = loss.unlisted.saturating_sub(described); + let lines = named.into_iter().map(|(first, n)| match n { 1 => first, _ => format!("{first}, and {} more like it", n - 1), }); - loss.details.splice(0..0, named); + loss.details.splice(0..0, lines); } ImportReport { imported: self.imported, @@ -355,6 +370,25 @@ mod tests { // One instance stays the single line it was. assert_eq!(named("(Pseudo/)"), ["C: 'D' has the effect 'Dropdown' (Pseudo/)"]); assert!(effects.details.len() <= MAX_DETAILS + 2, "the examples stay bounded"); + // 20 parameter lines, the cylinders' line and the dropdown's describe 46; 10 parameters + // have no line. + assert_eq!(effects.unlisted, 10); + // The most used effect comes first. + assert!(effects.details[0].contains("(CC Cylinder)"), "{:?}", effects.details); + } + + /// #3365: each category says how many of its occurrences have no example line. + #[test] + fn a_loss_counts_the_occurrences_without_a_line() { + let mut col = Collector::default(); + for i in 0..27 { + col.lose(LossCategory::Masks, format!("mask {i}")); + } + col.lose(LossCategory::ShapeLayers, "one"); + let report = col.finish(); + let unlisted = |c| report.losses.iter().find(|l| l.category == c).map(|l| (l.details.len(), l.unlisted)); + assert_eq!(unlisted(LossCategory::Masks), Some((MAX_DETAILS, 7))); + assert_eq!(unlisted(LossCategory::ShapeLayers), Some((1, 0))); } /// A file with more than [`MAX_UNMAPPED_EFFECTS`] different unmapped effects names that many @@ -369,5 +403,6 @@ mod tests { let effects = report.losses.iter().find(|l| l.category == LossCategory::Effects).unwrap(); assert_eq!(effects.count, MAX_UNMAPPED_EFFECTS + 1, "every instance is counted"); assert_eq!(effects.details.len(), MAX_UNMAPPED_EFFECTS, "{} lines", effects.details.len()); + assert_eq!(effects.unlisted, 1, "the effect past the limit has no line"); } } diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 1a162dab3..b2e0b3631 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -245,6 +245,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Import Adobe After Effects Project", "Adobe After Effectsプロジェクトを読み込み"), ("Some parts of this project cannot be imported exactly (left out or changed):", "このプロジェクトの一部は正確に読み込めません(省略または変更されます):"), ("Import Anyway", "このまま読み込み"), + ("{}: {} left out or changed", "{}:{} 件が省略または変更"), + ("… and {} more", "…ほか {} 件"), ("Casts Shadows", "シャドウを落とす"), ("Change Renderer…", "レンダラーを変更…"), ( @@ -1386,6 +1388,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Import Adobe After Effects Project", "导入 Adobe After Effects 项目"), ("Some parts of this project cannot be imported exactly (left out or changed):", "此项目的某些部分无法准确导入(将被省略或更改):"), ("Import Anyway", "仍然导入"), + ("{}: {} left out or changed", "{}:{} 项被省略或更改"), + ("… and {} more", "…以及另外 {} 项"), ("Casts Shadows", "投射阴影"), ("Change Renderer…", "更改渲染器…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快捷键会创建自定义预设;默认预设保持不变。"), @@ -2505,6 +2509,8 @@ const SWEDISH: &[(&str, &str)] = &[ "Vissa delar av projektet kan inte importeras exakt (utelämnas eller ändras):", ), ("Import Anyway", "Importera ändå"), + ("{}: {} left out or changed", "{}: {} utelämnas eller ändras"), + ("… and {} more", "… och {} till"), ("Casts Shadows", "Kastar skuggor"), ("Change Renderer…", "Byt renderare…"), ( @@ -3778,6 +3784,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Import Adobe After Effects Project", "匯入 Adobe After Effects 專案"), ("Some parts of this project cannot be imported exactly (left out or changed):", "此專案的部分內容無法準確匯入(將被省略或變更):"), ("Import Anyway", "仍要匯入"), + ("{}: {} left out or changed", "{}:{} 項被省略或變更"), + ("… and {} more", "…以及另外 {} 項"), ("Casts Shadows", "投射陰影"), ("Change Renderer…", "更改算圖引擎…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快速鍵會創建自定義預設集;預設預設集保持不變。"), @@ -4847,6 +4855,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ "Деякі частини цього проєкту неможливо імпортувати точно (їх пропущено або змінено):", ), ("Import Anyway", "Усе одно імпортувати"), + ("{}: {} left out or changed", "{}: {} пропущено або змінено"), + ("… and {} more", "… і ще {}"), ("Casts Shadows", "Відкидає тіні"), ("Change Renderer…", "Змінити рушій рендерингу…"), ( @@ -5428,6 +5438,8 @@ const PORTUGUESE: &[(&str, &str)] = &[ "Algumas partes deste projeto não podem ser importadas exatamente (omitidas ou alteradas):", ), ("Import Anyway", "Importar mesmo assim"), + ("{}: {} left out or changed", "{}: {} omitidos ou alterados"), + ("… and {} more", "… e mais {}"), ("Casts Shadows", "Projeta sombras"), ("Change Renderer…", "Alterar renderizador…"), ( @@ -6201,6 +6213,8 @@ const POLISH: &[(&str, &str)] = &[ "Niektórych części tego projektu nie można zaimportować dokładnie (zostaną pominięte lub zmienione):", ), ("Import Anyway", "Importuj mimo to"), + ("{}: {} left out or changed", "{}: {} pominięto lub zmieniono"), + ("… and {} more", "… i {} więcej"), ("Casts Shadows", "Rzuca cienie"), ("Change Renderer…", "Zmień renderer…"), ( @@ -6957,6 +6971,8 @@ const CZECH: &[(&str, &str)] = &[ "Některé části tohoto projektu nelze importovat přesně (budou vynechány nebo změněny):", ), ("Import Anyway", "Přesto importovat"), + ("{}: {} left out or changed", "{}: {} vynecháno nebo změněno"), + ("… and {} more", "… a {} dalších"), ("Casts Shadows", "Vrhat stíny"), ("Change Renderer…", "Změnit vykreslovací modul…"), ( @@ -7532,6 +7548,8 @@ const SPANISH: &[(&str, &str)] = &[ "Algunas partes de este proyecto no se pueden importar con exactitud (se omiten o cambian):", ), ("Import Anyway", "Importar de todos modos"), + ("{}: {} left out or changed", "{}: {} omitidos o cambiados"), + ("… and {} more", "… y {} más"), ("Casts Shadows", "Proyecta sombras"), ("Change Renderer…", "Cambiar motor de procesamiento…"), ( @@ -8098,6 +8116,8 @@ const GEORGIAN: &[(&str, &str)] = &[ "ამ პროექტის ზოგიერთი ნაწილის ზუსტად იმპორტი შეუძლებელია (გამოტოვებული ან შეცვლილი):", ), ("Import Anyway", "მაინც იმპორტი"), + ("{}: {} left out or changed", "{}: {} გამოტოვებული ან შეცვლილი"), + ("… and {} more", "… და კიდევ {}"), ("Casts Shadows", "ჩრდილს აგდებს"), ("Change Renderer…", "რენდერერის შეცვლა…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "მალსახმობის შეცვლა ქმნის მორგებულ პრესეტს; ნაგულისხმევი უცვლელი რჩება."), @@ -9070,6 +9090,8 @@ const FRENCH: &[(&str, &str)] = &[ "Certaines parties de ce projet ne peuvent pas être importées à l’identique (omises ou modifiées) :", ), ("Import Anyway", "Importer quand même"), + ("{}: {} left out or changed", "{} : {} omis ou modifiés"), + ("… and {} more", "… et {} de plus"), ("Casts Shadows", "Projette des ombres"), ("Change Renderer…", "Changer de moteur de rendu…"), ( diff --git a/crates/ui-egui/src/panels/aep_losses.rs b/crates/ui-egui/src/panels/aep_losses.rs index 4eb71104d..4674c369a 100644 --- a/crates/ui-egui/src/panels/aep_losses.rs +++ b/crates/ui-egui/src/panels/aep_losses.rs @@ -8,7 +8,7 @@ //! //! Automation ids: `dialog.aepLosses.continue`, `dialog.aepLosses.cancel`. -use crate::i18n::tr; +use crate::i18n::{tr, tr_args}; use egui::vec2; use serde_json::{Value, json}; @@ -23,9 +23,6 @@ pub struct Pending { pub queue: Vec, } -/// How many detail lines of a loss category the prompt shows. -const DETAILS: usize = 3; - /// Whether the prompt is showing with a conversion waiting on it. pub fn asking(app: &EffectcraftApp) -> bool { app.dialog == Some(Dialog::AepLosses) && app.dialog_state.aep_losses.command.is_some() @@ -77,14 +74,24 @@ fn category(c: &str) -> String { chars.next().map(|f| f.to_uppercase().chain(chars).collect()).unwrap_or_default() } -/// The report's losses as lines: one per category with its count, and a few of its details. +/// The report's losses as lines: one per category with how many occurrences are left out or +/// changed, then every detail line the report keeps and "… and N more" for the occurrences none +/// describes (#3365). pub fn lines(report: &Value) -> Vec<(String, Vec)> { let Some(losses) = report["losses"].as_array() else { return vec![] }; losses .iter() .map(|l| { - let head = format!("{} ({})", category(l["category"].as_str().unwrap_or("other")), l["count"].as_u64().unwrap_or(0)); - let details = l["details"].as_array().map(|d| d.iter().filter_map(Value::as_str).take(DETAILS).map(str::to_string).collect()).unwrap_or_default(); + let count = l["count"].as_u64().unwrap_or(0); + let head = tr_args("{}: {} left out or changed", &[&category(l["category"].as_str().unwrap_or("other")), &count]); + let mut details: Vec = + l["details"].as_array().map(|d| d.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + // An export report has no `unlisted`: each of its lines is one occurrence. + let listed = u64::try_from(details.len()).unwrap_or(u64::MAX); + let unlisted = l["unlisted"].as_u64().unwrap_or_else(|| count.saturating_sub(listed)); + if unlisted > 0 { + details.push(tr_args("… and {} more", &[&unlisted])); + } (head, details) }) .collect() @@ -179,11 +186,25 @@ mod tests { fn the_report_reads_as_categories_with_counts_and_details() { let report = json!({"losses": [{"category": "layer_styles", "count": 4, "details": ["a", "b", "c", "d"]}, {"category": "effects", "count": 1}]}); let l = lines(&report); - assert_eq!(l[0], ("Layer styles (4)".to_string(), vec!["a".to_string(), "b".into(), "c".into()])); - assert_eq!(l[1], ("Effects (1)".to_string(), vec![])); + assert_eq!(l[0], ("Layer styles: 4 left out or changed".to_string(), vec!["a".to_string(), "b".into(), "c".into(), "d".into()])); + assert_eq!(l[1], ("Effects: 1 left out or changed".to_string(), vec!["… and 1 more".to_string()])); assert!(lines(&json!({})).is_empty()); } + /// #3365: every detail line shows, then how many occurrences have none: the report's + /// `unlisted` (an effect line can describe many instances), else the lines past the count. + #[test] + fn every_detail_shows_and_the_rest_is_counted() { + let details: Vec = (0..20).map(|i| format!("d{i}")).collect(); + let l = lines(&json!({"losses": [{"category": "masks", "count": 27, "details": details}]})); + assert_eq!(l[0].1.len(), 21); + assert_eq!(l[0].1[20], "… and 7 more"); + let l = lines(&json!({"losses": [{"category": "effects", "count": 50, "details": ["X, and 44 more like it"], "unlisted": 5}]})); + assert_eq!(l[0].1, ["X, and 44 more like it", "… and 5 more"]); + let l = lines(&json!({"losses": [{"category": "effects", "count": 45, "details": ["X, and 44 more like it"], "unlisted": 0}]})); + assert_eq!(l[0].1, ["X, and 44 more like it"]); + } + /// Projects dropped or picked together wait only behind a prompt that shows; with none /// showing they are tried at once (a missing file is an error naming it). #[test] diff --git a/docs/aep-import.md b/docs/aep-import.md index c704e0a89..3713ca0fe 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-10 · **Change:** dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -322,8 +322,12 @@ parameter gets the file's value: ## What is reported `ImportReport` (`serde`, returned as JSON) has `imported` (counts, including `textLayers`), -`losses` (per category: `count` exact, up to 20 `details`), `missingFootage`, `missingFonts` -(PostScript names; not losses), `limitations` and `aeVersion`. The categories: +`losses` (per category: `count` exact; up to 20 `details`, plus one line per unmapped effect with its +number of instances, the most used first; `unlisted`, how many of the `count` no line describes), +`missingFootage`, `missingFonts` +(PostScript names; not losses), `limitations` and `aeVersion`. The losses prompt shows each +category with how many occurrences are left out or changed, every detail line, and "… and N more" +for the `unlisted` ones. The categories: | Category | Counted | |---|---| From b55226320b1aa6890be83e89d436482cb66ddf80 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 16:17:03 +0530 Subject: [PATCH 102/227] #3087: a shape property in the default state is no loss Shape Direction, blend mode, composite order and the gradient highlight values were reported when their record was in the default state (tdsb 3), which holds After Effects' default (docs/aep-format.md 3.4), so a plain guide rectangle reported "shape direction not imported". They are now read only from written records, keys or an expression. Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import/shapes.rs | 46 +++++++++++++++++++++++++++++++-- docs/aep-import.md | 4 +-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/aep/src/import/shapes.rs b/crates/aep/src/import/shapes.rs index 345346a6f..b86ee7492 100644 --- a/crates/aep/src/import/shapes.rs +++ b/crates/aep/src/import/shapes.rs @@ -457,18 +457,22 @@ fn shift_copy_offset(p: &mut effectcraft_project::Property, cx: &mut Cx<'_, '_>) } } -/// Reports a property whose only sampled value is the default 1. +/// Reports a property whose only sampled value is the default 1. A record in the default state +/// holds After Effects' default (docs/aep-format.md 3.4, state), so it holds nothing to lose. fn not_default(g: &AepGroup<'_, '_>, match_name: &str, what: &str, cx: &mut Cx<'_, '_>) { if let Some(PropNode::Property(p)) = g.get(match_name) + && stored(&p) && (p.is_animated() || p.expression().is_some() || p.static_value().and_then(|v| v.first().copied()).is_some_and(|v| v != 1.0)) { cx.col.lose(LossCategory::ShapeLayers, format!("{}: {what} not imported", cx.what)); } } -/// Reports a property whose only sampled value is 0. +/// Reports a property whose only sampled value is 0 (a record in the default state, as above, +/// holds nothing to lose). fn not_zero(g: &AepGroup<'_, '_>, match_name: &str, what: &str, cx: &mut Cx<'_, '_>) { if let Some(PropNode::Property(p)) = g.get(match_name) + && stored(&p) && (p.is_animated() || p.expression().is_some() || p.static_value().and_then(|v| v.first().copied()).is_some_and(|v| v != 0.0)) { cx.col.lose(LossCategory::ShapeLayers, format!("{}: {what} not imported", cx.what)); @@ -490,3 +494,41 @@ fn unknown(g: &AepGroup<'_, '_>, known: &[&str], what: &str, cx: &mut Cx<'_, '_> } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Chunk; + + /// A group holding `match_name` as a one-component property in state `tdsb` with value `v`. + fn group_with(match_name: &str, tdsb: u32, v: f64) -> Chunk<'static> { + let mut name = match_name.as_bytes().to_vec(); + name.resize(40, 0); + let mut tdb4 = vec![0u8; 124]; + tdb4[3] = 1; + let tdbs = Chunk::list( + *b"tdbs", + vec![Chunk::data(*b"tdsb", tdsb.to_be_bytes().to_vec()), Chunk::data(*b"tdb4", tdb4), Chunk::data(*b"cdat", v.to_be_bytes().to_vec())], + ); + Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdmn", name), tdbs]) + } + + fn reported(match_name: &str, tdsb: u32, v: f64, check: fn(&AepGroup<'_, '_>, &str, &str, &mut Cx<'_, '_>)) -> usize { + let chunk = group_with(match_name, tdsb, v); + let g = AepGroup::from_chunk(&chunk).unwrap(); + let tm = TimeMap { timescale: 30, frame_duration: 1, rate: crate::import::time::frame_rate(30, 1).unwrap() }; + let (mut next, mut col) = (1, Collector::default()); + check(&g, match_name, "it", &mut Cx { what: "L", tm: &tm, ids: &mut Ids(&mut next), col: &mut col }); + col.finish().losses.iter().map(|l| l.count).sum() + } + + /// #3087: a guide rectangle's Shape Direction recorded in the default state (`tdsb` 3, holding + /// 2) is After Effects' default and is no loss; written (`tdsb` 1) with 2, it is one. + #[test] + fn a_default_state_record_is_no_loss() { + assert_eq!(reported("ADBE Vector Shape Direction", 3, 2.0, not_default), 0); + assert_eq!(reported("ADBE Vector Shape Direction", 1, 2.0, not_default), 1); + assert_eq!(reported("ADBE Vector Grad HiLite Length", 3, 5.0, not_zero), 0); + assert_eq!(reported("ADBE Vector Grad HiLite Length", 1, 5.0, not_zero), 1); + } +} diff --git a/docs/aep-import.md b/docs/aep-import.md index 3713ca0fe..379b81b67 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -336,7 +336,7 @@ for the `unlisted` ones. The categories: | `layer_styles` | per layer: a style in switch state 3 (not sampled; state 0 is imported as a style switched off, 988), Pattern Overlay, an unknown style or parameter, a gradient (`GCst`, not decoded: EffectCraft's default gradient stays), keys on a popup or checkbox, a value that does not fit, styles on a layer that takes none here (adjustment, camera, light) | | `markers` | a marker with an unknown cue type (imported as an event cue) | | `text_layers` ("text layer features") | per text layer, what of it is not imported: wiggly and expression selectors and unmapped animator properties, Path Options or More Options with set values, Source Text keyframes or an expression, character style or paragraph fields that are not decoded and differ from the default (named by their slot, e.g. `/68`), an unknown capitalisation or justification code, manual kerning, No Break, the stroke line join, the Japanese leading model, a digit set other than the default and Arabic, control characters of unknown meaning (they are kept in the text; U+0003 is not one of them, see Text above). The text layer itself is imported | -| `shape_layers` ("shape layers") | what of a shape layer is not imported: shape items other than groups, rectangle, ellipse, polystar, path, fill, stroke, gradient fill and stroke, trim paths, round corners, merge paths, repeater and offset paths (for example wiggle or twist), stroke dashes, taper and wave with values, item blend modes and composite order, shape direction, gradient colour stops other than the default, gradient highlight values other than 0, material options, groups nested deeper than 32, a line cap or join of the unsampled third kind (imported as it), a path without a readable path. The layer itself is imported | +| `shape_layers` ("shape layers") | what of a shape layer is not imported: shape items other than groups, rectangle, ellipse, polystar, path, fill, stroke, gradient fill and stroke, trim paths, round corners, merge paths, repeater and offset paths (for example wiggle or twist), stroke dashes, taper and wave with values, item blend modes and composite order, shape direction, gradient colour stops other than the default, gradient highlight values other than 0 (only from written records: one in the default state holds the default), material options, groups nested deeper than 32, a line cap or join of the unsampled third kind (imported as it), a path without a readable path. The layer itself is imported | | `lights` | each environment light (the layer is skipped; the other layers of the composition are imported) | | `blend_modes` | a blend mode code that is not mapped (1, 3 = Dissolve or Dancing Dissolve, 21 / 22 = Alpha Add or Luminescent Premul, codes above 34; the layer stays Normal) | | `transform_properties` | a stored transform property that is not imported (keyed or expression-driven orientation, separated position dimensions), and the stored camera and light options that are not mapped (Blur Level, Iris, Falloff, ...) | From 1f38431fafb82f3fec10282782915a4851d76aa8 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 16:18:15 +0530 Subject: [PATCH 103/227] #3087: an expression on Orientation is imported with its value or keys An Orientation with an expression was reported as "not decoded" and lost its value and keys too. The expression is stored in the otst's tdbs like on any property, so it now arrives with the stored value or keys. Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import/props.rs | 18 +++++++---- crates/aep/tests/it/import/synthetic.rs | 42 +++++++++++++++++++++++++ docs/aep-import.md | 6 ++-- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/crates/aep/src/import/props.rs b/crates/aep/src/import/props.rs index fd0a4f9fc..8c331f1cb 100644 --- a/crates/aep/src/import/props.rs +++ b/crates/aep/src/import/props.rs @@ -231,15 +231,21 @@ pub(crate) fn import_transform( } import_expression(&node, prop, col); } - if let Some(PropNode::Other(_)) = group.get("ADBE Orientation") { + if let Some(PropNode::Other(otst)) = group.get("ADBE Orientation") { match (layer.orientation(), target.get_mut("orientation")) { - (Some(o), Some(prop)) if !o.animated && !o.has_expression && sane(&Value::Vec3(o.degrees)) => prop.value = Value::Vec3(o.degrees), + (Some(o), Some(prop)) if o.animated || sane(&Value::Vec3(o.degrees)) => { + if o.animated { + import_orientation_keys(layer, layer_name, tm, prop, col); + } else { + prop.value = Value::Vec3(o.degrees); + } + // The expression is stored in the `otst`'s `tdbs` as on any property (#3087). + if let Some(node) = otst.child_list(b"tdbs").and_then(AepProp::from_chunk) { + import_expression(&node, prop, col); + } + } // A layer without an orientation (an ambient light) loses nothing with the default. (Some(o), None) if !o.animated && !o.has_expression && o.degrees == [0.0; 3] => {} - (Some(o), Some(prop)) if o.animated && !o.has_expression => import_orientation_keys(layer, layer_name, tm, prop, col), - (Some(o), Some(_)) if o.has_expression => { - col.lose(LossCategory::TransformProperties, format!("{layer_name}: ADBE Orientation expression (not decoded)")); - } _ => col.lose(LossCategory::TransformProperties, format!("{layer_name}: ADBE Orientation")), } } diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 8e4c28f16..f2bc36a70 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -842,3 +842,45 @@ fn a_default_state_colour_record_holds_the_value() { assert_eq!(colour(&default_state), colour(&written)); assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default"); } + +/// #3087: an expression on Orientation arrives with the stored value, as on any property; it was +/// dropped together with the value and reported as "not decoded". +#[test] +fn an_orientation_expression_is_imported_with_its_value() { + let bytes = mutate(&mut |c| { + if !c.is_list(b"otst") { + return; + } + for child in c.children_mut() { + if child.is_list(b"tdbs") { + let Body::List { children, .. } = &mut child.body else { continue }; + for k in children.iter_mut() { + if k.is(b"tdsb") { + set_data(k, 0, &1u32.to_be_bytes()); + } else if k.is(b"tdb4") { + set_data(k, 120, &[1]); + } + } + children.push(Chunk::data(*b"Utf8", b"[0, time * 90, 0]".to_vec())); + } else if child.is_list(b"otky") { + for k in child.children_mut() { + let degrees: Vec = [10.0f64, 20.0, 30.0].iter().flat_map(|d| d.to_be_bytes()).collect(); + set_data(k, 0, °rees); + } + } + } + }); + let (p, r) = run(&bytes, true); + assert_eq!(losses(&r.unwrap(), LossCategory::TransformProperties), Vec::::new()); + let mut seen = 0; + for item in p.items.values() { + let ItemKind::Comp(c) = &item.kind else { continue }; + for layer in &c.layers { + let Some(o) = layer.transform().and_then(|t| t.prop("orientation")).filter(|o| o.expr.is_some()) else { continue }; + assert_eq!(o.value, effectcraft_keyframe::Value::Vec3([10.0, 20.0, 30.0]), "{}", layer.name); + assert_eq!(o.expr.as_ref().map(|e| (e.text.as_str(), e.enabled)), Some(("[0, time * 90, 0]", true)), "{}", layer.name); + seen += 1; + } + } + assert!(seen > 0); +} diff --git a/docs/aep-import.md b/docs/aep-import.md index 379b81b67..ee7469a8c 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss; an expression on Orientation is imported with its value or keys (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -59,7 +59,7 @@ refused losses) leaves the project unchanged. outside the sampled ones keeps the current value and is reported as `project settings`. After Effects renders in the working space (902: in a Rec. 709 Gamma 2.4 project an sRGB PNG step of 128 comes out as 135 and a grey 0.5 solid as 128, in the Render Queue output and in the captures), while EffectCraft converts its output to `output_space` (sRGB when unset): a Rec. 709 working space therefore also sets the output space to Rec. 709 (902 within 1 level of After Effects, was up to 13). They come in `ImportResult::settings`; the library never changes the project's own settings, `file.importAep` applies them when the project has no compositions yet (or with `applySettings: true`) | | Composition | name, size, exact frame rate, duration, work area, background colour (8 bit), pixel aspect, composition motion blur switch, shutter angle and phase (as stored), display start time, Classic/Advanced 3D renderer | | Layers | solid, null, adjustment, footage, precomposition and **text** layers: name (the source's name when the layer has none; for a text layer the first 40 characters of its first line, as EffectCraft names a text layer after its text), in/out/start and time stretch (exact ratios, e.g. 50 % and 150 %); a layer of a clip, sequence or sound ends at its source's end as After Effects ends it (unless remapped, looped or less than a frame past; f1), enabled/audio/solo/shy/locked/effects/motion blur/collapse/3D/guide switches, draft quality, label, blend mode (Normal, Multiply, Add, Screen, Overlay, Difference; Soft Light, Hard Light, Darken, Lighten, Exclusion, Color Dodge, Color Burn by name, 906; Classic Difference, Hue, Saturation, Color, Luminosity, the Stencil and Silhouette modes, Classic Color Dodge / Burn, Linear Dodge / Burn, Linear, Vivid and Pin Light, Hard Mix by render, 907: each code rendered by After Effects matched exactly one EffectCraft mode; 906 renders within 1/255 of After Effects), parent, **track matte** (alpha, alpha inverted, luma, luma inverted; the matte layer anywhere in the composition, also below its target; 402-408) | -| Transform | Anchor Point, Position, Scale, Rotation, Opacity, **X and Y Rotation** (486, 487) and **Orientation**, static (488) or keyed (703: key values, times, linear and hold sides; a bezier side becomes linear and is reported; After Effects slerps between the keys, EffectCraft interpolates each angle, so in-between values differ, e.g. 703 at 1 s `[6.26, 9.35, 15.22]` against `[5, 10, 15]`; an expression-driven orientation is reported): the stored value, or the default when the file stores none; keyframes with time, linear/bezier/hold on each side, temporal ease (speed and influence, per dimension for Scale), automatic or manual spatial tangents, roving and key labels | +| Transform | Anchor Point, Position, Scale, Rotation, Opacity, **X and Y Rotation** (486, 487) and **Orientation**, static (488) or keyed (703: key values, times, linear and hold sides; a bezier side becomes linear and is reported; After Effects slerps between the keys, EffectCraft interpolates each angle, so in-between values differ, e.g. 703 at 1 s `[6.26, 9.35, 15.22]` against `[5, 10, 15]`), with its expression as on any property: the stored value, or the default when the file stores none; keyframes with time, linear/bezier/hold on each side, temporal ease (speed and influence, per dimension for Scale), automatic or manual spatial tangents, roving and key labels | | Expressions | on the transform and mask properties and on effect, camera and light parameters (534): source text and enable switch | | Cameras and lights | **cameras** (one-node and two-node, from `ldta` byte 38 bit `0x40`): point of interest, Position as stored (also the auto Position `[0, 0, -zoom]`, where After Effects renders it, 537), orientation and rotations, Zoom (absent: `-z` of the auto Position, else the 50 mm default), Depth of Field, Focus Distance (absent: the zoom), Aperture (absent: 25.309 at 1920 px, read back from 256; scaled with the width, which is not sampled, E); **spot, point, parallel and ambient lights** (light type 1, 2, 0, 3: 701, 702): point of interest (not for point and ambient lights), Position, Intensity, Color, Cone Angle, Cone Feather, Casts Shadows; keys and expressions as for the transform; 251-256, 341-345, 535-537, 701, 702. Environment lights are reported | | Text | the Source Text of a text layer: the text (paragraphs separated by CR; a hard line break is a CR too; U+0003 is laid out by After Effects exactly like a CR (386 against 387) and becomes a CR), per style run the font (PostScript name), size, fill and stroke colour, whether fill and stroke are drawn, stroke width, stroke over fill (one switch for the text), tracking, leading (automatic or explicit), faux bold, faux italic, all caps, small caps, superscript and subscript (526-528), horizontal and vertical scale, baseline shift, automatic kerning (metrics, optical, none), ligatures, tsume (538-578); per paragraph the justification (left, centre, right and the four justified variants), first-line / start / end indent, space before / after, direction, composer, hanging punctuation (in a right-to-left paragraph the stored justification is relative to the direction: After Effects lays 0 and 3 out flush right and 1 and 4 flush left, read-back test t7, so they arrive as right / left); point text or box text (box size and top-left in layer pixels); vertical right-to-left text (575) | @@ -339,7 +339,7 @@ for the `unlisted` ones. The categories: | `shape_layers` ("shape layers") | what of a shape layer is not imported: shape items other than groups, rectangle, ellipse, polystar, path, fill, stroke, gradient fill and stroke, trim paths, round corners, merge paths, repeater and offset paths (for example wiggle or twist), stroke dashes, taper and wave with values, item blend modes and composite order, shape direction, gradient colour stops other than the default, gradient highlight values other than 0 (only from written records: one in the default state holds the default), material options, groups nested deeper than 32, a line cap or join of the unsampled third kind (imported as it), a path without a readable path. The layer itself is imported | | `lights` | each environment light (the layer is skipped; the other layers of the composition are imported) | | `blend_modes` | a blend mode code that is not mapped (1, 3 = Dissolve or Dancing Dissolve, 21 / 22 = Alpha Add or Luminescent Premul, codes above 34; the layer stays Normal) | -| `transform_properties` | a stored transform property that is not imported (keyed or expression-driven orientation, separated position dimensions), and the stored camera and light options that are not mapped (Blur Level, Iris, Falloff, ...) | +| `transform_properties` | a stored transform property that is not imported (an orientation that cannot be read, or set on a layer without one, separated position dimensions), and the stored camera and light options that are not mapped (Blur Level, Iris, Falloff, ...) | | `time_remapping` | an enabled Time Remap without a readable keyframe | | `approximated_keyframes` | a key imported in a simpler form (unknown interpolation code, bezier ease that cannot be decoded: a colour key with a non-zero ease, non-finite ease data; a mask path key with a speed other than 0, imported unverified) | | `parent_links` | a parent that was not imported (a skipped camera, a missing layer) or that forms a loop | From b60f231fe9153e911a6ad980078a77f697eeb8e9 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 16:29:25 +0530 Subject: [PATCH 104/227] #3087: keep an effect without a counterpart as a placeholder Third-party plug-ins and pseudo effects (expression controls) were dropped on import, so expressions reading them broke. Such an effect now arrives as an effect group whose effect id is its After Effects match name: it renders nothing, like a missing plug-in, and is listed by Find Missing Effects. It holds the effect's parameters flat, named as in After Effects, with their values, keys and expressions in the units After Effects' expressions read. It is still reported (once per match name, "kept without rendering") and counted in imported.placeholderEffects. The parameters and popup items kept are bounded per import. Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import.rs | 4 +- crates/aep/src/import/effects.rs | 87 +++++---- crates/aep/src/import/placeholder.rs | 166 +++++++++++++++++ crates/aep/src/import/report.rs | 7 + crates/aep/tests/it/import.rs | 1 + crates/aep/tests/it/import/placeholders.rs | 200 +++++++++++++++++++++ crates/expr/src/tests.rs | 18 ++ docs/aep-import.md | 20 ++- 8 files changed, 461 insertions(+), 42 deletions(-) create mode 100644 crates/aep/src/import/placeholder.rs create mode 100644 crates/aep/tests/it/import/placeholders.rs diff --git a/crates/aep/src/import.rs b/crates/aep/src/import.rs index fae3b3256..20a4b7b1e 100644 --- a/crates/aep/src/import.rs +++ b/crates/aep/src/import.rs @@ -14,7 +14,8 @@ //! missing-footage placeholders). `docs/aep-import.md` has the full list. //! //! What does not arrive is **reported**, never dropped silently: [`ImportReport`] counts other -//! effects (by match name) and unimported effect parameters, unusable masks, layer style +//! effects (by match name; they arrive as placeholders that render nothing, with their parameters +//! for expressions) and unimported effect parameters, unusable masks, layer style //! features, text layer features, shape items, unsupported blend modes and transform //! properties, simplified keyframes and unreadable data per [`LossCategory`]. By default the import //! is refused when anything would be lost ([`ImportOptions::allow_losses`] lifts that); a refusal @@ -30,6 +31,7 @@ mod effects; mod lumetri; mod markers; mod masks; +mod placeholder; mod props; mod puppet; mod report; diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 1ab4b208a..86faac76a 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -1,7 +1,8 @@ //! Effects: a table maps an After Effects effect (its match name) and its parameters (by their -//! index in `parT`) to an EffectCraft effect, a parameter id and a unit conversion. Effects and -//! parameters outside the table are reported in [`LossCategory::Effects`] by match name, never -//! dropped silently. +//! index in `parT`) to an EffectCraft effect, a parameter id and a unit conversion. Parameters +//! outside the table are reported in [`LossCategory::Effects`] by match name, never dropped +//! silently. An effect outside it arrives as a placeholder holding its parameters (`placeholder.rs`) +//! and is reported too. //! //! Every conversion is backed by the corpus (`docs/aep-format.md` 3.7, cases 356-450, 511-516): //! @@ -82,44 +83,29 @@ pub(crate) fn import_effects<'f, 'a>( continue; } let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string()); - let Some(map) = EFFECTS.iter().find(|m| m.match_name == fx.match_name()) else { - col.lose_unmapped_effect(fx.match_name(), format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); - continue; - }; - let Some(spec) = effectcraft_effects::find(map.effect) else { - col.lose_unmapped_effect(fx.match_name(), format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); - continue; - }; + let found = EFFECTS.iter().find(|m| m.match_name == fx.match_name()).and_then(|m| Some((m, effectcraft_effects::find(m.effect)?))); + let lost = format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()); if defs.source(&fx) == DefinitionSource::Missing { - col.lose( - LossCategory::Effects, - format!("{}: '{}' has the effect '{shown}' ({}) without readable parameter definitions, not imported", cx.comp, cx.layer, fx.match_name()), - ); + match found { + Some(_) => col.lose(LossCategory::Effects, format!("{lost} without readable parameter definitions, not imported")), + None => col.lose_unmapped_effect(fx.match_name(), lost), + } continue; } - let size = [f64::from(cx.size.0), f64::from(cx.size.1)]; - let mut group = effectcraft_effects::instantiate(spec, ids, &shown, size); - match fx.enabled() { - Some(on) => group.enabled = on, - None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)), - } let params = defs.params(&fx); - // Borrowed definitions (another instance's or the project copy) may not name every value - // record this instance has: such a record has nothing to import into, so it is reported. - if params.first().is_some_and(|p| p.borrowed_definition) { - let defined: std::collections::HashSet<&str> = params.iter().map(|p| p.match_name.as_str()).collect(); - for name in fx.value_record_names().iter().filter(|n| !defined.contains(n.as_str())) { - col.lose( - LossCategory::Effects, - format!( - "{}: '{}' effect '{shown}' ({}): the value record {name} has no parameter definition, not imported", - cx.comp, - cx.layer, - fx.match_name() - ), - ); - } - } + report_records_without_definition(&fx, ¶ms, cx, &shown, col); + let Some((map, spec)) = found else { + // No counterpart: kept as a placeholder that renders nothing, for expressions (#3087). + let mut group = crate::import::placeholder::placeholder(&fx, ¶ms, cx, &shown, ids, refs, col); + set_enabled(&fx, &mut group, cx, &shown, col); + col.lose_unmapped_effect(fx.match_name(), format!("{lost}, kept without rendering")); + col.imported.placeholder_effects = col.imported.placeholder_effects.saturating_add(1); + target.children.push(Node::Group(group)); + continue; + }; + let size = [f64::from(cx.size.0), f64::from(cx.size.1)]; + let mut group = effectcraft_effects::instantiate(spec, ids, &shown, size); + set_enabled(&fx, &mut group, cx, &shown, col); if map.extra == Extra::Lumetri { crate::import::lumetri::import_lumetri(&fx, ¶ms, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col }); } else if map.extra == Extra::Cryptomatte { @@ -134,6 +120,29 @@ pub(crate) fn import_effects<'f, 'a>( } } +/// The effect's fx switch. +fn set_enabled(fx: &AepEffect<'_, '_>, group: &mut PropGroup, cx: &LayerCtx<'_>, shown: &str, col: &mut Collector) { + match fx.enabled() { + Some(on) => group.enabled = on, + None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)), + } +} + +/// Borrowed definitions (another instance's or the project copy) may not name every value record +/// this instance has: such a record has nothing to import into, so it is reported. +fn report_records_without_definition(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, '_>], cx: &LayerCtx<'_>, shown: &str, col: &mut Collector) { + if !params.first().is_some_and(|p| p.borrowed_definition) { + return; + } + let defined: std::collections::HashSet<&str> = params.iter().map(|p| p.match_name.as_str()).collect(); + for name in fx.value_record_names().iter().filter(|n| !defined.contains(n.as_str())) { + col.lose( + LossCategory::Effects, + format!("{}: '{}' effect '{shown}' ({}): the value record {name} has no parameter definition, not imported", cx.comp, cx.layer, fx.match_name()), + ); + } +} + /// Parameters that never carry a value to import. pub(crate) fn carries_nothing(p: &EffectParam<'_, '_>) -> bool { matches!(p.kind, ParamKind::Button | ParamKind::NoData | ParamKind::GroupStart | ParamKind::GroupEnd) || (p.index == 0 && p.kind == ParamKind::Layer) @@ -364,7 +373,7 @@ fn report_beyond_limit<'v>(limit: Option<(f64, f64)>, values: impl Iterator, target: &str, own: bool, cx: &LayerCtx<'_>, names: (&str, &str), out: ParamOut<'_>) { +pub(crate) fn import_layer_ref(p: &EffectParam<'_, '_>, target: &str, own: bool, cx: &LayerCtx<'_>, names: (&str, &str), out: ParamOut<'_>) { let (here, label) = names; let ParamOut { group, refs, col } = out; // After Effects hands the named layer's source to the effect, without its masks and effects @@ -799,7 +808,7 @@ fn px(v: f64) -> String { /// A decoded value in EffectCraft's units, `None` when it does not fit the kind or is not a valid /// number. -fn convert(value: ParamValue, kind: Kind) -> Option { +pub(crate) fn convert(value: ParamValue, kind: Kind) -> Option { let raw = match (value, kind) { // The reader gives colours as ARGB fractions; `Kind` takes the stored 0..255. (ParamValue::Argb(c), Kind::EffectColor) => c.map(|x| x * 255.0).to_vec(), diff --git a/crates/aep/src/import/placeholder.rs b/crates/aep/src/import/placeholder.rs new file mode 100644 index 000000000..4e9fd8047 --- /dev/null +++ b/crates/aep/src/import/placeholder.rs @@ -0,0 +1,166 @@ +//! Placeholders: an effect with no EffectCraft counterpart (a third-party plug-in, a pseudo effect +//! of expression controls) arrives as an effect group that renders nothing, as a missing plug-in +//! does. Its effect id is the After Effects match name, so Find Missing Effects lists it. It holds +//! the effect's parameters, flat and named as in After Effects, each with its value, keys and +//! expression, so expressions that read them (`effect("Settings")("Speed")`) keep working. It is +//! still reported, once per match name. +//! +//! The values keep After Effects' units, the ones its expressions read: sliders and angles as +//! stored, points in layer pixels, colours RGBA 0..1, checkboxes on / off, popups as EffectCraft's +//! 0-based items over the file's items in order (expressions read them 1-based, as After Effects +//! does). Layer parameters name their layer. Paths and arbitrary data are not kept (reported when +//! they hold something). +//! +//! The parameters a placeholder holds are bounded per import ([`MAX_COST`]): definitions can be +//! shared by many instances, so a small file could otherwise ask for very many properties. + +use effectcraft_keyframe::Value; +use effectcraft_project::build::Ids; +use effectcraft_project::{GroupKind, Node, ParamUi, PropGroup, Property}; + +use crate::import::effects::{LayerCtx, ParamOut, PendingLayerRef, carries_nothing, convert, import_layer_ref, param_label}; +use crate::import::props::{Kind, import_expression, import_keys}; +use crate::import::report::{Collector, LossCategory}; +use crate::{Effect as AepEffect, EffectParam, ParamKind, ParamValue}; + +/// Most placeholder properties and popup items one import creates. +pub(crate) const MAX_COST: usize = 200_000; + +/// Most items read from one placeholder popup. +const MAX_POPUP_ITEMS: usize = 1024; + +/// The placeholder of `fx`, with `params` (its parameter definitions) as properties. +pub(crate) fn placeholder( + fx: &AepEffect<'_, '_>, + params: &[EffectParam<'_, '_>], + cx: &LayerCtx<'_>, + shown: &str, + ids: &mut Ids<'_>, + refs: &mut Vec, + col: &mut Collector, +) -> PropGroup { + let mut group = ids.group(fx.match_name(), shown); + group.kind = GroupKind::Effect { effect: fx.match_name().to_string() }; + let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()); + for p in params.iter().filter(|p| !carries_nothing(p)) { + if col.placeholder_cost >= MAX_COST { + // Once the budget is spent nothing more is built (definitions shared by many instances + // would otherwise still be walked item by item). + col.lose(LossCategory::Effects, format!("{here}: parameters past the import's {MAX_COST} placeholder parameters not kept")); + break; + } + let label = param_label(p); + let Some((kind, ui, default)) = shape(p, cx.size) else { + if p.stored().is_some() { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} not kept (type {:?})", p.kind)); + } + continue; + }; + let items = match &ui { + ParamUi::Popup { options } => options.len(), + _ => 0, + }; + let cost = col.placeholder_cost.saturating_add(items).saturating_add(1); + if cost > MAX_COST { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} not kept (past the import's {MAX_COST} placeholder parameters)")); + continue; + } + col.placeholder_cost = cost; + let mut prop = Property::new(ids.alloc(), ¶m_id(p), p.name.trim(), default).with_ui(ui); + prop.spatial = p.kind == ParamKind::Point; + prop.hold_only = matches!(p.kind, ParamKind::Checkbox | ParamKind::Popup | ParamKind::Layer); + match kind { + Some(kind) => { + fill(p, &mut prop, kind, cx, (&here, &label), col); + group.children.push(Node::Prop(prop)); + } + // A layer parameter: the layer it names (set once every layer exists, by its uid). + None => { + let id = format!("@{}", prop.uid); + group.children.push(Node::Prop(prop)); + import_layer_ref(p, &id, true, cx, (&here, &label), ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col }); + } + } + } + group +} + +/// How parameter `p` is held on a layer `size` big: its unit conversion (`None` for a layer +/// parameter), its control and its value until one is read. `None` for a type that is not kept. +fn shape(p: &EffectParam<'_, '_>, size: (u32, u32)) -> Option<(Option, ParamUi, Value)> { + let (kind, ui, default) = match p.kind { + ParamKind::IntSlider | ParamKind::FixedSlider | ParamKind::FloatSlider => (Kind::EffectNumber(1.0), ParamUi::Number, Value::Scalar(0.0)), + ParamKind::Angle => (Kind::EffectNumber(1.0), ParamUi::Angle, Value::Scalar(0.0)), + ParamKind::Checkbox => (Kind::EffectBool, ParamUi::Checkbox, Value::Bool(false)), + ParamKind::Color => (Kind::EffectColor, ParamUi::Color, Value::Color([0.0, 0.0, 0.0, 1.0])), + ParamKind::Point => (Kind::EffectPoint(f64::from(size.0), f64::from(size.1)), ParamUi::Point, Value::Vec2([0.0, 0.0])), + ParamKind::Layer => return Some((None, ParamUi::Layer, Value::Layer(None))), + ParamKind::Popup => { + // The file's items, separators (`(-`) included: After Effects counts them. + let options: Vec = match &p.items { + Some(items) => items.iter().take(MAX_POPUP_ITEMS).cloned().collect(), + None => (1..=usize::from(p.popup_item_count()?).min(MAX_POPUP_ITEMS)).map(|i| format!("Item {i}")).collect(), + }; + let n = u32::try_from(options.len()).ok().filter(|n| *n > 0)?; + (Kind::EffectPopup(n), ParamUi::Popup { options }, Value::Enum(0)) + } + _ => return None, + }; + Some((Some(kind), ui, default)) +} + +/// The property id of `p`: its match name when that is one segment of a property path (a +/// third-party effect's `CC Cylinder-0003`), else `param` (a pseudo effect's +/// `Pseudo/0.25-0001`: `/` and `.` separate path segments). Expressions also find it by its name. +fn param_id(p: &EffectParam<'_, '_>) -> String { + let plain = !p.match_name.is_empty() && p.match_name.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')); + if plain { p.match_name.clone() } else { format!("param{}", p.index) } +} + +/// Sets `prop` from `p`: its expression, then its keys or its value (the record, else the `pard` +/// default). `names` is `(here, label)` for the report. +fn fill(p: &EffectParam<'_, '_>, prop: &mut Property, kind: Kind, cx: &LayerCtx<'_>, names: (&str, &str), col: &mut Collector) { + let (here, label) = names; + if let Some(stored) = p.stored() { + import_expression(&stored, prop, col); + } + if p.is_animated() { + let Some(stored) = p.stored() else { return }; + if kind == Kind::EffectBool { + // Keys on a checkbox are not sampled by the corpus. + col.lose(LossCategory::Effects, format!("{here}: the keyframes of parameter {label} are not kept (not decoded)")); + return; + } + let before = prop.keys.len(); + import_keys(&stored, kind, cx.layer, &p.match_name, cx.tm, prop, col); + if prop.keys.len() == before { + col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} has no readable keyframe")); + } + return; + } + let value = match (p.stored(), p.recorded_value()) { + (Some(_), Some(v)) => v, + (Some(_), None) => { + col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds an unreadable value")); + return; + } + // A borrowed popup's default slot holds another copy's selection (docs/aep-format.md 3.7). + (None, _) if p.kind == ParamKind::Popup && p.borrowed_definition => { + col.lose(LossCategory::Effects, format!("{here}: parameter {label}: the default item is not known (shared definition), the first item used")); + return; + } + // A popup's default slot can hold 0, no item (After Effects' save of a project it opened). + (None, _) => match p.pard_default() { + Some(ParamValue::Index(0)) => { + col.lose(LossCategory::Effects, format!("{here}: parameter {label}: the default item is not stored, the first item used")); + return; + } + Some(v) => v, + None => return, + }, + }; + match convert(value, kind) { + Some(v) => prop.value = v, + None => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?})")), + } +} diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index 2825b61b3..8b3599266 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -156,6 +156,10 @@ pub struct Imported { /// in [`LossCategory::Effects`]). #[serde(default)] pub effects: usize, + /// Effects with no counterpart, kept as placeholders that render nothing (their parameters, + /// for expressions; each is also in [`LossCategory::Effects`]). + #[serde(default)] + pub placeholder_effects: usize, /// Layer styles (Drop Shadow, Stroke, ...), one per style on a layer. #[serde(default)] pub layer_styles: usize, @@ -273,6 +277,9 @@ pub(crate) struct Collector { /// many instances there were. They get a line each in [`Collector::finish`], so one that /// comes after the example lines are used up is still named (#3365). unmapped_effects: BTreeMap, + /// The properties and popup items the placeholders of this import hold so far (at most + /// `placeholder::MAX_COST`). + pub placeholder_cost: usize, } /// How many different unmapped effects get a line of their own (a file names at most this many). diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 2535edcff..240c3ea0f 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -22,6 +22,7 @@ mod lumetri; mod markers; mod masks; mod mattes; +mod placeholders; mod puppet; mod resaved_defaults; mod robustness; diff --git a/crates/aep/tests/it/import/placeholders.rs b/crates/aep/tests/it/import/placeholders.rs new file mode 100644 index 000000000..847060f93 --- /dev/null +++ b/crates/aep/tests/it/import/placeholders.rs @@ -0,0 +1,200 @@ +//! #3087: an effect with no EffectCraft counterpart (a third-party plug-in, a pseudo effect of +//! expression controls) arrives as a placeholder holding its parameters, so expressions that read +//! them keep working; it renders nothing and is still reported. Each effect is built into +//! `synthetic.aep` (`fx_inject`) in place of its Glow, on the 8 x 8 sequence layer. + +use crate::import::fx_inject::{Default, Fx, bytes_with, import_bytes, losses, other, popup, run, slider, v}; +use effectcraft_aep::import::LossCategory; +use effectcraft_aep::{Body, Chunk, ParamKind as K, parse, write}; +use effectcraft_keyframe::Value; +use effectcraft_project::{GroupKind, ParamUi, PropGroup}; + +/// A third-party effect with one parameter of each kept kind, inside a twirl-down group. +fn third_party(values: Vec<(usize, f64)>, keys: Vec<(usize, Vec<(i32, f64)>)>) -> Fx { + Fx { + match_name: "CC Cylinder", + name: "CC Cylinder", + defs: vec![ + other(K::GroupStart, "Light", Default::None), + slider(K::FloatSlider, "Radius (%)", 30.0, (0.0, 30000.0)), + other(K::GroupEnd, "", Default::None), + other(K::Angle, "Rotation X", Default::Number(12.5)), + other(K::Checkbox, "Wire", Default::Number(1.0)), + other(K::Color, "Light Color", Default::Color([255, 51, 102, 204])), + other(K::Point, "Position", Default::Point(0.25, 0.5)), + popup("Render", 1, &["Full", "(-", "Outside", "Inside"]), + slider(K::IntSlider, "Count", 3.0, (0.0, 50.0)), + ], + values, + data: vec![], + keys, + } +} + +fn placeholder_of(fx: &Fx) -> (PropGroup, effectcraft_aep::import::ImportResult) { + let (g, r) = run(fx, fx.match_name); + (g.expect("the placeholder"), r) +} + +#[test] +fn an_effect_without_a_counterpart_is_kept_with_its_values() { + // Radius 37.5 and Render "Outside" (3: the separator counts) stored, the rest at defaults. + let (g, r) = placeholder_of(&third_party(vec![(2, 37.5), (8, 3.0)], vec![])); + assert_eq!(g.kind, GroupKind::Effect { effect: "CC Cylinder".into() }); + assert_eq!(g.name, "CC Cylinder"); + let names: Vec<(&str, &str)> = g.props().map(|p| (p.match_id.as_str(), p.name.as_str())).collect(); + assert_eq!( + names, + [ + ("CC Cylinder-0002", "Radius (%)"), + ("CC Cylinder-0004", "Rotation X"), + ("CC Cylinder-0005", "Wire"), + ("CC Cylinder-0006", "Light Color"), + ("CC Cylinder-0007", "Position"), + ("CC Cylinder-0008", "Render"), + ("CC Cylinder-0009", "Count"), + ], + "flat, in order, without the group markers" + ); + assert_eq!(v(&g, "CC Cylinder-0002"), Value::Scalar(37.5)); + assert_eq!(v(&g, "CC Cylinder-0004"), Value::Scalar(12.5)); + assert_eq!(v(&g, "CC Cylinder-0005"), Value::Bool(true)); + let Value::Color(c) = v(&g, "CC Cylinder-0006") else { panic!("a colour") }; + assert!(c.iter().zip([0.2, 0.4, 0.8, 1.0]).all(|(a, b)| (a - b).abs() < 1e-6), "{c:?}"); + assert_eq!(v(&g, "CC Cylinder-0007"), Value::Vec2([2.0, 4.0]), "layer pixels"); + assert_eq!(v(&g, "CC Cylinder-0008"), Value::Enum(2), "item 3 of 4, 0-based"); + assert_eq!(v(&g, "CC Cylinder-0009"), Value::Scalar(3.0)); + let render = g.prop("CC Cylinder-0008").unwrap(); + assert_eq!(render.ui, ParamUi::Popup { options: ["Full", "(-", "Outside", "Inside"].map(String::from).to_vec() }); + // Still reported, once, and counted apart from the imported effects. + let found = losses(&r, LossCategory::Effects, "CC Cylinder"); + assert!(found.len() == 1 && found[0].ends_with("has the effect 'CC Cylinder' (CC Cylinder), kept without rendering"), "{found:?}"); + assert_eq!((r.report.imported.placeholder_effects, r.report.imported.effects), (1, 1), "the placeholder and the Levels"); +} + +#[test] +fn keys_and_expressions_of_a_placeholder_are_kept() { + let fx = third_party(vec![(4, 12.5)], vec![(2, vec![(0, 10.0), (25, 20.0)])]); + let bytes = with_expression(&bytes_with(&fx), "CC Cylinder-0004", "time * 90"); + let (g, _) = import_bytes(&bytes, "CC Cylinder"); + let g = g.unwrap(); + let radius = g.prop("CC Cylinder-0002").unwrap(); + assert_eq!(radius.keys.iter().map(|k| k.value.clone()).collect::>(), [Value::Scalar(10.0), Value::Scalar(20.0)]); + let angle = g.prop("CC Cylinder-0004").unwrap(); + assert_eq!(angle.expr.as_ref().map(|e| (e.text.as_str(), e.enabled)), Some(("time * 90", true))); +} + +/// A pseudo effect's parameter match names (`Pseudo/0.25-0001`) are no property path segment: +/// its parameters are `param`, found by their names. +#[test] +fn a_pseudo_effect_is_kept_with_its_parameter_names() { + let fx = Fx { + match_name: "Pseudo/0.25", + name: "Settings", + defs: vec![slider(K::FloatSlider, "Speed", 1.0, (0.0, 100.0)), popup("Mode", 2, &["A", "B"])], + values: vec![(1, 4.0)], + data: vec![], + keys: vec![], + }; + let (g, r) = placeholder_of(&fx); + assert_eq!(g.prop("param1").map(|p| (p.name.as_str(), p.value.clone())), Some(("Speed", Value::Scalar(4.0)))); + assert_eq!(g.prop("param2").map(|p| (p.name.as_str(), p.value.clone())), Some(("Mode", Value::Enum(1)))); + assert_eq!(losses(&r, LossCategory::Effects, "Pseudo/0.25").len(), 1); +} + +/// A popup whose definition is borrowed (the project copy) and has no value record: its default +/// slot holds another copy's selection, so the first item is used and that is reported. +#[test] +fn a_borrowed_popup_without_a_record_takes_the_first_item_and_is_reported() { + let bytes = without_instance_definitions(&bytes_with(&third_party(vec![], vec![]))); + let (g, r) = import_bytes(&bytes, "CC Cylinder"); + assert_eq!(v(&g.unwrap(), "CC Cylinder-0008"), Value::Enum(0)); + let found = losses(&r, LossCategory::Effects, "CC Cylinder"); + assert!(found.iter().any(|d| d.contains("Render") && d.contains("default item is not known")), "{found:?}"); +} + +/// A popup default slot of 0 (no item; After Effects' save of a project it opened) takes the first +/// item and is reported, not read as damaged data. +#[test] +fn a_popup_default_of_no_item_takes_the_first_item() { + let fx = Fx { match_name: "Some Fx", name: "Some", defs: vec![popup("Mode", 0, &["A", "B"])], values: vec![], data: vec![], keys: vec![] }; + let (g, r) = placeholder_of(&fx); + assert_eq!(v(&g, "Some Fx-0001"), Value::Enum(0)); + let found = losses(&r, LossCategory::Effects, "Some Fx"); + assert!(found.iter().any(|d| d.contains("default item is not stored")), "{found:?}"); + assert!(losses(&r, LossCategory::DamagedData, "Some Fx").is_empty()); +} + +/// Many large popups stay within the import's budget: what is beyond is reported, not kept. +#[test] +fn a_hostile_placeholder_stays_bounded() { + let items: &'static [&'static str] = Box::leak((0..1000).map(|i| &*Box::leak(format!("I{i}").into_boxed_str())).collect::>().into_boxed_slice()); + let fx = Fx { match_name: "Big Fx", name: "Big", defs: (0..300).map(|_| popup("P", 1, items)).collect(), values: vec![], data: vec![], keys: vec![] }; + let (g, r) = placeholder_of(&fx); + let kept = g.props().count(); + assert!(kept > 0 && kept < 300, "{kept}"); + assert!(g.props().map(|p| if let ParamUi::Popup { options } = &p.ui { options.len() + 1 } else { 1 }).sum::() <= 200_000); + assert!(losses(&r, LossCategory::Effects, "Big Fx").iter().any(|d| d.contains("placeholder parameters"))); +} + +/// `bytes` with the expression `text` on the parameter record `param` (enabled: `tdb4` byte 120 +/// 1, docs/aep-format.md 3.6). +fn with_expression(bytes: &[u8], param: &str, text: &str) -> Vec { + let mut file = parse(bytes).unwrap(); + let mut done = 0; + edit(&mut file.chunks, false, &mut |c, _| { + let Body::List { children, .. } = &mut c.body else { return }; + let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(param.as_bytes()))) else { + return; + }; + let Some(Body::List { children: record, .. }) = children.get_mut(at + 1).map(|k| &mut k.body) else { return }; + for k in record.iter_mut().filter(|k| k.is(b"tdb4")) { + if let Body::Data(d) = &mut k.body + && let Some(b) = d.to_mut().get_mut(120) + { + *b = 1; + } + } + record.push(Chunk::data(*b"Utf8", text.as_bytes().to_vec())); + done += 1; + }); + assert!(done >= 1); + write(&file).unwrap() +} + +/// `bytes` with the layer's instance of `CC Cylinder` stripped of its own definitions (an empty +/// `parT`): it borrows the project copy's (`EfdG`). +fn without_instance_definitions(bytes: &[u8]) -> Vec { + let mut file = parse(bytes).unwrap(); + let mut done = 0; + edit(&mut file.chunks, false, &mut |c, in_project_copy| { + let Body::List { children, .. } = &mut c.body else { return }; + if in_project_copy { + return; + } + let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(&b"CC Cylinder"[..]))) + else { + return; + }; + let Some(sspc) = children.get_mut(at + 1).filter(|k| k.is_list(b"sspc")) else { return }; + for k in sspc.children_mut() { + if let Body::List { list_type, children } = &mut k.body + && list_type == b"parT" + { + children.clear(); + done += 1; + } + } + }); + assert_eq!(done, 1, "one instance"); + write(&file).unwrap() +} + +/// Calls `f` on every chunk with whether it is inside the project's effect definitions (`EfdG`). +fn edit(chunks: &mut [Chunk<'_>], in_efdg: bool, f: &mut dyn FnMut(&mut Chunk<'_>, bool)) { + for c in chunks { + let inside = in_efdg || c.is_list(b"EfdG"); + f(c, inside); + edit(c.children_mut(), inside, f); + } +} diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index d16f1dd54..f6d07bedc 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1376,6 +1376,24 @@ fn checkbox_and_dropdown_results() { assert_close!(f.ev(f.b, ROT, "effect('Dropdown Menu Control')('Menu') * 10", 0.0).unwrap().as_f64(), 10.0); } +/// An effect EffectCraft does not have (an imported After Effects placeholder, #3087) is read by +/// name like any other: its parameters by their names, a popup 1-based. +#[test] +fn an_unknown_effects_parameters_are_read_by_name() { + let mut f = fx(); + let g = group_push(&mut f.p, |ids| { + let mut g = ids.group("Pseudo/0.25", "Settings"); + g.kind = effectcraft_project::GroupKind::Effect { effect: "Pseudo/0.25".into() }; + g.children.push(ids.prop("param1", "Speed", Value::Scalar(3.0)).into()); + g.children.push(ids.prop("param2", "Mode", Value::Enum(1)).into()); + g + }); + f.layer_mut(f.b).props.sub_mut("effects").unwrap().children.push(g.into()); + assert_close!(f.ev(f.b, ROT, "effect('Settings')('Speed') * 2", 0.0).unwrap().as_f64(), 6.0); + assert_close!(f.ev(f.b, ROT, "effect('Settings')('Mode') * 10", 0.0).unwrap().as_f64(), 20.0); + assert_close!(f.ev(f.b, ROT, "effect('Settings')(1)", 0.0).unwrap().as_f64(), 3.0); +} + #[test] fn syntax_error_reports_line() { let f = fx(); diff --git a/docs/aep-import.md b/docs/aep-import.md index c704e0a89..b4c37646f 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-10 · **Change:** dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** an effect without a counterpart arrives as a placeholder with its parameters, values, keys and expressions; it renders nothing and is still reported (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -318,6 +318,22 @@ parameter gets the file's value: `approximated_keyframes`. - **The fx switch** of each instance (the `tdsb` of its value group: 1 on, 0 off; `fx-switch.aep`) is imported and exported, and so is an expression on a stored effect parameter (its text and its switch). +- **Effects without a counterpart** (third-party plug-ins, pseudo effects of expression controls, + `Pseudo/…`) arrive as **placeholders** (#3087): an effect group whose effect id is the After + Effects match name. Like a missing plug-in it renders nothing and is listed by Find Missing + Effects. It holds the effect's parameters flat (without the twirl-down group markers), named as + in After Effects, each with its value, keys and expression, in the units After Effects' + expressions read (sliders and angles as stored, points in layer pixels, colours, checkboxes, + popups over the file's items with separators, read 1-based; layer parameters name their layer, + without a record the layer itself as for Layer Control), + so expressions that read them (`effect("Settings")("Speed")`) keep working. A parameter's id is + its match name (`CC Cylinder-0003`), or `param` when that name is no path segment (a + pseudo effect's `Pseudo/0.25-0001`). Paths and arbitrary data are not kept; a popup whose + definition is borrowed, or whose default slot holds no item, and that has no value record takes + its first item (reported). One import keeps at + most 200 000 placeholder parameters and popup items (a popup at most 1024 items); the rest is + reported. A placeholder is still reported in `effects` and counted in + `imported.placeholderEffects`; .aep export reports it and leaves it out. ## What is reported @@ -327,7 +343,7 @@ parameter gets the file's value: | Category | Counted | |---|---| -| `effects` | each effect on an imported layer that is not in the table (by name and match name); for an imported effect: each stored parameter outside the table, a parameter whose type or valid maximum differs from the table, keys or an expression on a layer parameter, an effect without readable parameter definitions | +| `effects` | each effect on an imported layer that is not in the table (by name and match name; it arrives as a placeholder, "kept without rendering", unless it has no readable parameter definitions); for an imported effect: each stored parameter outside the table, a parameter whose type or valid maximum differs from the table, keys or an expression on a layer parameter, an effect without readable parameter definitions | | `masks` | a mask that is not imported because its path cannot be used (a Mask Path entry without shapes, a mask on a text or shape layer whose path was never set (its default is not sampled), unreadable, no vertices, a point count that is not a multiple of three, more than 65 536 vertices, numbers that are not finite), a mask mode code outside the seven known ones (imported as Add) | | `layer_styles` | per layer: a style in switch state 3 (not sampled; state 0 is imported as a style switched off, 988), Pattern Overlay, an unknown style or parameter, a gradient (`GCst`, not decoded: EffectCraft's default gradient stays), keys on a popup or checkbox, a value that does not fit, styles on a layer that takes none here (adjustment, camera, light) | | `markers` | a marker with an unknown cue type (imported as an event cue) | From e2e341301120de3393798c2b952a3ac090a207e5 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 16:21:47 +0530 Subject: [PATCH 105/227] #3087: an item made from one Photoshop layer shows that layer Footage made from one layer of a layered .psd showed the whole document and was named after the file. Its opti (tag 8BPS) stores the layer: the record index (BE i32 at 14, -1 for the merged image), the bounds (LE i32 at 78-93) and the name (UTF-8 at 344). The item now shows that layer as EffectCraft's own Photoshop import does (at its own size with Retain Layer Sizes), named /. A layer without pixels (an adjustment layer) keeps the merged image and is reported. --- crates/aep/src/container.rs | 2 +- crates/aep/src/container/footage.rs | 78 ++++++++++++++++++++- crates/aep/src/import/build.rs | 28 +++++++- crates/aep/src/import/report.rs | 3 +- crates/aep/src/lib.rs | 8 +-- crates/aep/tests/it/container/robustness.rs | 1 + crates/aep/tests/it/import/synthetic.rs | 65 +++++++++++++++++ docs/aep-format.md | 3 +- docs/aep-import.md | 6 +- 9 files changed, 180 insertions(+), 14 deletions(-) diff --git a/crates/aep/src/container.rs b/crates/aep/src/container.rs index 531d74843..77511fcf1 100644 --- a/crates/aep/src/container.rs +++ b/crates/aep/src/container.rs @@ -44,7 +44,7 @@ pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM}; pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk}; pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue}; pub use error::AepError; -pub use footage::{Footage, SequenceInfo}; +pub use footage::{Footage, PsdLayer, SequenceInfo}; pub use header::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Quality, Rational, Renderer}; pub use keys::{ExpressionFlags, Interpolation, KeyEase, KeyframeKind, SpatialKey}; pub use masks::{Mask, MaskAtom, MaskMode, PathVertex, ShapePath}; diff --git a/crates/aep/src/container/footage.rs b/crates/aep/src/container/footage.rs index da0369d64..7a3782d79 100644 --- a/crates/aep/src/container/footage.rs +++ b/crates/aep/src/container/footage.rs @@ -1,7 +1,7 @@ //! Item ids and labels, footage items (solids and files), precomp linkage, and the name and size //! of a layer's source. -use crate::container::bytes::{be_f32, be_f64, be_u32, cstr, le_u32, text}; +use crate::container::bytes::{be_f32, be_f64, be_i32, be_u32, cstr, le_u32, text, until_nul}; use crate::container::chunk::Chunk; use crate::container::header::Rational; use crate::container::typed::{Item, ItemKind, Layer, Project}; @@ -45,6 +45,48 @@ pub struct Footage { /// True when the alias points at a folder (`"target_is_folder":true`): an image sequence is ONE /// item whose alias is its folder and whose `opti` holds the first file name. pub is_sequence: bool, + /// The one layer of a Photoshop file the item shows; `None` for the merged image and for + /// any other footage. + pub psd_layer: Option, +} + +/// One layer of a Photoshop file shown by a footage item, from its `opti` (tag `8BPS`, +/// docs/aep-format.md 3.3): the layer record's index as a big-endian `i32` at byte 14 (-1 for the +/// merged image), its bounds top, left, bottom, right as little-endian `i32` at 78, 82, 86 and +/// 90, and its name (UTF-8, NUL terminated) from byte 344. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PsdLayer { + /// The layer record's position in the file (bottom of the stack = 0). + pub index: u32, + pub name: String, + /// Top, left, bottom, right in document pixels; all 0 for a layer without pixels (an + /// adjustment layer). + pub bounds: [i32; 4], +} + +/// Most bytes of a Photoshop layer name read. +const MAX_NAME: usize = 1024; + +impl PsdLayer { + /// The bounds' width and height; `None` when they are empty. + pub fn size(&self) -> Option<(u32, u32)> { + let [top, left, bottom, right] = self.bounds; + let w = u32::try_from(i64::from(right) - i64::from(left)).ok().filter(|w| *w > 0)?; + let h = u32::try_from(i64::from(bottom) - i64::from(top)).ok().filter(|h| *h > 0)?; + Some((w, h)) + } + + /// The layer an `opti` names; `None` for another tag, the merged image or a cut record. + fn read(opti: &[u8]) -> Option { + if opti.get(..4) != Some(&b"8BPS"[..]) { + return None; + } + let index = u32::try_from(be_i32(opti, 14)?).ok()?; + let le_i32 = |at| le_u32(opti, at).map(|v| i32::from_le_bytes(v.to_le_bytes())); + // (After Effects writes 602 bytes; a longer hostile name is cut.) + let name = opti.get(344..).map(|b| text(until_nul(b.get(..MAX_NAME).unwrap_or(b)))).unwrap_or_default(); + Some(Self { index, name, bounds: [le_i32(78)?, le_i32(82)?, le_i32(86)?, le_i32(90)?] }) + } } impl Footage { @@ -330,6 +372,7 @@ impl<'f, 'a> Item<'f, 'a> { file_size: be_u32(sspc, 208).unwrap_or(0), audio_sample_rate: be_f64(sspc, 160).filter(|r| r.is_finite() && *r > 0.0), is_sequence, + psd_layer: PsdLayer::read(opti), }) } @@ -370,7 +413,38 @@ impl<'f, 'a> Project<'f, 'a> { #[cfg(test)] mod tests { - use super::json_string; + use super::{PsdLayer, json_string}; + + /// A 602-byte Photoshop `opti` naming layer `index` with `bounds` and `name`. + fn opti(index: i32, bounds: [i32; 4], name: &[u8]) -> Vec { + let mut b = vec![0u8; 602]; + b[..4].copy_from_slice(b"8BPS"); + b[14..18].copy_from_slice(&index.to_be_bytes()); + for (i, v) in bounds.iter().enumerate() { + b[78 + 4 * i..82 + 4 * i].copy_from_slice(&v.to_le_bytes()); + } + b[344..344 + name.len()].copy_from_slice(name); + b + } + + #[test] + fn a_photoshop_layer_reads_its_index_bounds_and_name() { + let l = PsdLayer::read(&opti(7, [749, 800, 764, 848], "tést".as_bytes())).unwrap(); + assert_eq!((l.index, l.name.as_str(), l.bounds, l.size()), (7, "tést", [749, 800, 764, 848], Some((48, 15)))); + // The merged image, another tag, a cut record: no layer. + assert_eq!(PsdLayer::read(&opti(-1, [0; 4], b"x")), None); + let mut other = opti(1, [0; 4], b"x"); + other[..4].copy_from_slice(b"Soli"); + assert_eq!(PsdLayer::read(&other), None); + assert_eq!(PsdLayer::read(&opti(1, [0; 4], b"x")[..80]), None); + // A cut name, and no name at all. + assert_eq!(PsdLayer::read(&opti(1, [0; 4], b"x")[..345]).unwrap().name, "x"); + assert_eq!(PsdLayer::read(&opti(1, [0; 4], b"x")[..300]).unwrap().name, ""); + // A layer without pixels has no size; extreme bounds do not overflow. + assert_eq!(PsdLayer::read(&opti(2, [0; 4], b"")).unwrap().size(), None); + assert_eq!(PsdLayer::read(&opti(2, [i32::MIN, i32::MIN, i32::MAX, i32::MAX], b"")).unwrap().size(), Some((u32::MAX, u32::MAX))); + assert_eq!(PsdLayer::read(&opti(2, [i32::MAX, 0, i32::MIN, 1], b"")).unwrap().size(), None); + } #[test] fn reads_a_string_with_escapes() { diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 7d5b902f1..9da8bb39a 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -12,7 +12,8 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::Value; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ - AutoOrient, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, MatteKind, Project, Quality, Solid, TrackMatte, + AutoOrient, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, MatteKind, Project, Quality, Solid, SourceLayer, + TrackMatte, }; use effectcraft_time::Tick; @@ -216,7 +217,17 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ let aep_id = item.id(); // An item without a stored name shows its file's name, a sequence `shot_[0101-0103].exr` // (126, 603-607). - let name = if name.is_empty() { default_footage_name(item, &f).unwrap_or_else(|| "Footage".to_string()) } else { name }; + // One layer of a Photoshop file is `/`, as EffectCraft's own Photoshop import + // names it (#3087). + let name = if name.is_empty() { + let file = default_footage_name(item, &f).unwrap_or_else(|| "Footage".to_string()); + match &f.psd_layer { + Some(l) if !l.name.is_empty() => format!("{}/{file}", l.name), + _ => file, + } + } else { + name + }; if f.is_solid { // A null object's placeholder solid is not an item here. if aep_id.is_some_and(|i| null_only.contains(&i)) { @@ -299,6 +310,7 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ missing: !present, placed_page, sequence, + layer: source_layer(&f, &name, &mut cx.col), ..Footage::default() }; // The native frame rate and the stored duration of sequences and clips (352, 603, 606). @@ -338,6 +350,18 @@ fn is_placed_page_path(path: &str) -> bool { matches!(ext.as_deref(), Some("pdf" | "ai")) } +/// The Photoshop layer a footage item shows (#3087): at its own bounds when the item is as big +/// as they are (Retain Layer Sizes), else on the document. A layer without pixels (an adjustment +/// layer) keeps the merged image and is reported. +fn source_layer(f: &crate::Footage, name: &str, col: &mut Collector) -> Option { + let l = f.psd_layer.as_ref()?; + let Some(size) = l.size() else { + col.lose(LossCategory::UnsupportedItems, format!("footage '{name}': a Photoshop layer without pixels (an adjustment layer), the merged image used")); + return None; + }; + Some(SourceLayer { index: l.index, name: l.name.clone(), layer_size: size == (f.width, f.height), ..SourceLayer::default() }) +} + /// The name After Effects shows for a footage item that stores none: the file's name, or for a /// sequence the name with the frame range (`shot_[0101-0103].exr`). fn default_footage_name(item: &AepItem<'_, '_>, f: &crate::Footage) -> Option { diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index 2825b61b3..454b2991b 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -56,7 +56,8 @@ pub enum LossCategory { /// Layers that could not be imported for another reason (unknown layer type, missing or /// unsupported source, precomposition loop). UnsupportedLayers, - /// Project items of a kind that is not imported. + /// Project items of a kind that is not imported, and items made from a Photoshop layer + /// without pixels (they show the merged image). UnsupportedItems, /// Data that could not be read (damaged headers, unreadable keyframes). DamagedData, diff --git a/crates/aep/src/lib.rs b/crates/aep/src/lib.rs index 4bcdbab9e..cb4b9ff60 100644 --- a/crates/aep/src/lib.rs +++ b/crates/aep/src/lib.rs @@ -20,8 +20,8 @@ pub use container::{ GradientType, HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer, LayerHeader, LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS, Orientation, OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode, - Property, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun, - TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace, - curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, - write, write_to, + Property, PsdLayer, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, + StyleRun, TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, + WorkingSpace, curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, + read_aepx_with_limits, style_map, write, write_to, }; diff --git a/crates/aep/tests/it/container/robustness.rs b/crates/aep/tests/it/container/robustness.rs index 5c3422a10..0c887b8c4 100644 --- a/crates/aep/tests/it/container/robustness.rs +++ b/crates/aep/tests/it/container/robustness.rs @@ -720,6 +720,7 @@ fn extreme_values_in_the_batch_3_fields() { file_size: 0, audio_sample_rate: None, is_sequence: true, + psd_layer: None, }; for (num, den, rate) in [(3i64, 30i64, 30u32), (3, 0, 30), (-3, 30, 30), (i64::MAX, 1, u32::MAX), (i64::MIN, -1, u32::MAX), (1, i64::MAX, 1), (0, 1, 0)] { let f = Footage { duration: Some(Rational { num, den }), native_frame_rate: rate, ..base.clone() }; diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 8e4c28f16..f1c36110c 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -842,3 +842,68 @@ fn a_default_state_colour_record_holds_the_value() { assert_eq!(colour(&default_state), colour(&written)); assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default"); } + +/// The project with its png item made one layer of a Photoshop file `art.psd`: the `opti` of +/// docs/aep-format.md 3.3 (tag `8BPS`, the layer index at 14, its bounds at 78-94, its name at +/// 344) and the item `size` big. +fn as_psd_layer(index: i32, bounds: [i32; 4], name: &str, size: (u32, u32)) -> Vec { + let mut opti = vec![0u8; 602]; + opti[..4].copy_from_slice(b"8BPS"); + opti[14..18].copy_from_slice(&index.to_be_bytes()); + for (i, v) in bounds.iter().enumerate() { + opti[78 + 4 * i..82 + 4 * i].copy_from_slice(&v.to_le_bytes()); + } + opti[344..344 + name.len()].copy_from_slice(name.as_bytes()); + let alias = br#"{"fullpath":"C:\\x\\art.psd","platform":1,"target_is_folder":false}"#.to_vec(); + mutate(&mut |c| { + if !c.is_list(b"Pin ") { + return; + } + let Body::List { children, .. } = &mut c.body else { return }; + if !children.iter().any(|k| k.is(b"opti") && k.bytes().is_some_and(|d| d.get(..4) == Some(&b"png!"[..]))) { + return; + } + // No sequence naming between the alias and the `opti`. + children.retain(|k| k.is(b"sspc") || k.is(b"opti") || k.is_list(b"Als2")); + for k in children.iter_mut() { + if k.is(b"opti") { + k.body = Body::Data(opti.clone().into()); + } else if k.is(b"sspc") { + set_data(k, 30, &size.0.to_be_bytes()); + set_data(k, 34, &size.1.to_be_bytes()); + } + } + visit(children, &mut |k| { + if k.is(b"alas") { + k.body = Body::Data(alias.clone().into()); + } + }); + }) +} + +/// #3087: an item made from one layer of a Photoshop file shows that layer, named +/// `/`; it showed the whole document, named after the file. Item as big as the layer: +/// Retain Layer Sizes; as big as the document: the layer on the document. +#[test] +fn a_photoshop_layer_item_shows_its_layer() { + for (size, layer_size) in [((48, 15), true), ((1000, 1000), false)] { + let (p, r) = run(&as_psd_layer(7, [749, 800, 764, 848], "Tëst", size), true); + r.unwrap(); + let (name, f) = p.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((i.name.clone(), f.clone())) } else { None }).unwrap(); + assert_eq!(name, "Tëst/art.psd"); + let l = f.layer.expect("the layer"); + assert_eq!((l.index, l.name.as_str(), l.layer_size, (f.width, f.height)), (7, "Tëst", layer_size, size)); + } +} + +/// #3087: a Photoshop layer without pixels (an adjustment layer) keeps the merged image and is +/// reported; the merged image itself (index -1) is no layer. +#[test] +fn a_photoshop_layer_without_pixels_keeps_the_merged_image() { + let (p, r) = run(&as_psd_layer(4, [0; 4], "Levels 1", (1000, 1000)), true); + let found = losses(&r.unwrap(), LossCategory::UnsupportedItems); + assert!(found.iter().any(|d| d.contains("'Levels 1/art.psd'") && d.contains("without pixels")), "{found:?}"); + assert_eq!(the_footage(&p).layer, None); + let (p, _) = run(&as_psd_layer(-1, [0; 4], "", (1000, 1000)), true); + assert_eq!(the_footage(&p).layer, None); +} diff --git a/docs/aep-format.md b/docs/aep-format.md index 8830a869e..2b4dae27f 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -1,6 +1,6 @@ # After Effects project format (`.aep`) -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-07 · **Change:** Puppet (`ADBE FreePin3`, 3.7): the property tree from an external scripting listing, **?** until a byte sample exists (#3093); before that: footage: an image sequence without a filename prefix stores only the suffix `Utf8`, and one imported with Force alphabetical order stores its file list in `LIST StVc` / `StVS` (section 3, footage); before that: versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** footage: the Photoshop layer an item shows (`opti` tag `8BPS`, section 3.3, issue 3087); before that: Puppet (`ADBE FreePin3`, 3.7): the property tree from an external scripting listing, **?** until a byte sample exists (#3093); before that: footage: an image sequence without a filename prefix stores only the suffix `Utf8`, and one imported with Force alphabetical order stores its file list in `LIST StVc` / `StVS` (section 3, footage); before that: versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026 What `crates/aep` ([`effectcraft-aep`](../crates/aep/README.md)) reads and writes, and the evidence for each statement. Everything here was observed on project files we generated ourselves; no @@ -263,6 +263,7 @@ property of `L` after 229 and 230). The corpus manifest calls the classic render | footage size | `Pin /sspc` | 30, 34 | u32, u32 | width, height in pixels | 000 (1920 x 1080), 022 (100 x 100 null), 126 (8 x 8), 211, 212, 259 | V | | footage name | `Pin /opti` | 26 (solid), 58 (file) | ASCII, NUL terminated | the item's own `Utf8` name is empty for footage | 000 (`L`), 022 (`Null 1`), 126 (`test8x8.png`), 211 (`S100`), 259 (`Blue`) | V | | **solid colour** | `Pin /opti` | 10, 14, 18, 22 | 4 x f32 | ARGB, 0..1: red [1, 0, 0] is `1, 1, 0, 0`, blue [0, 0, 1] is `1, 0, 0, 1` | 000, 259 | V | +| **Photoshop layer** | `Pin /opti` (tag `8BPS`) | 14; 78, 82, 86, 90; 344 | i32 BE; 4 x i32 **LE**; UTF-8, NUL terminated | an item made from one layer of a `.psd`: the layer record's index in the file (bottom of the stack = 0; -1 is the merged image), the layer's bounds top, left, bottom, right in document pixels (all 0 for a layer without pixels, such as an adjustment layer) and the layer's name. The item's size (`sspc` 30 / 34) is the bounds' size with Retain Layer Sizes, else the document's. `Footage::psd_layer` | the project of issue 3087: ten layers, each index, bounds and name equal to the `.psd`'s layer record at that position (`Layer 1` `[9, -346, 1083, 1086]`, item 1432 x 1074; `test` `[749, 800, 764, 848]`, item 48 x 15) | V | | file path | `Pin /Als2/alas` | key `fullpath` | JSON string | alias JSON of a file footage item | 126 (the fixture holds a neutral path) | V | | precomp linkage | `Layr/ldta` | 40 | u32 | the layer's source id is the id of the precomp's item | 124 | V | diff --git a/docs/aep-import.md b/docs/aep-import.md index c704e0a89..32e19fd42 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-10 · **Change:** dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** an item made from one layer of a Photoshop file shows that layer (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -70,7 +70,7 @@ outside the sampled ones keeps the current value and is reported as `project set | Shape layers | groups (nested, with anchor, position, scale, skew, skew axis, rotation, opacity), rectangle (size, position, roundness), ellipse (size, position), polystar (star or polygon, points, position, rotation, radii, roundness), bezier path (vertices and tangents in layer pixels, path keys), fill (colour, opacity, fill rule), stroke (colour, opacity, width, cap, join, miter limit), gradient fill and stroke (linear or radial, start and end point; the default white-to-black colours), trim paths (start, end, offset, trim multiple shapes), round corners (radius), merge paths (mode; 707, 708), offset paths (amount, line join, miter limit, copies, copy offset; 710; After Effects offsets copy *i* by Amount x (*i* + Copy Offset), default 1, EffectCraft by Amount x (*i* + 1 + copyOffset), so the copy offset arrives 1 less, also its keys; an expression on it is kept and reported; 826-828, 828 renders equal to After Effects on every grid point), repeater (copies, offset, composite, transform; 709), in file order, with keyframes and expressions, the operators' defaults as After Effects reads them back (731); the layer anchor in pixels; 238-242, 330-333, 451-484, 519, 705-710, 731 | | Markers | layer and composition markers: time (layer time for layer markers, exact frames), duration (1/600 s units, 509, 510), comment, chapter, URL, frame target, label, cue point (name, event or navigation, parameters); 122, 123, 249, 347-349, 509, 510 | | Time Remapping | an enabled remap becomes the `timeRemap` property (source seconds, keys in layer time with their interpolation and ease, expression), first in the layer's properties as Layer > Time > Enable Time Remapping puts it; 319, 354 | -| Footage | a link to the stored path; **missing-footage placeholder** when the path does not exist where the import runs (listed in `missingFootage`), a normal link when it does. The importer never opens footage. **An image sequence** (its alias is the folder) becomes a sequence of its files, named from the stored numbering (`shot_0101.exr` .. `shot_0103.exr` in 603, `seq_001.png` .. in 353), at its native frame rate; the first file is the footage's path and the one checked. Sequences and clips take the stored native frame rate and duration (603, 606). A sequence of more than 100 000 frames, or beyond a million file names in one import, links only its first file and is reported | +| Footage | a link to the stored path; **missing-footage placeholder** when the path does not exist where the import runs (listed in `missingFootage`), a normal link when it does. The importer never opens footage. **An image sequence** (its alias is the folder) becomes a sequence of its files, named from the stored numbering (`shot_0101.exr` .. `shot_0103.exr` in 603, `seq_001.png` .. in 353), at its native frame rate; the first file is the footage's path and the one checked. Sequences and clips take the stored native frame rate and duration (603, 606). A sequence of more than 100 000 frames, or beyond a million file names in one import, links only its first file and is reported. **One layer of a Photoshop file** shows that layer (`Footage::layer`: its index and name; at its own size with Retain Layer Sizes, else on the document) and is named `/` unless the item has a name; a layer without pixels (an adjustment layer) keeps the merged image and is reported | Units, the "absent = default" rule and the keyframe templates are the reader's ([aep-format.md](aep-format.md), sections 3.3–3.6). The importer converts them to EffectCraft's: @@ -341,7 +341,7 @@ parameter gets the file's value: | `parent_links` | a parent that was not imported (a skipped camera, a missing layer) or that forms a loop | | `track_mattes` | a track matte of unknown mode, whose matte layer was not imported or does not exist, a layer matted by itself, or a matte of the older "layer above" form on the top layer | | `unsupported_layers` | an unknown layer type, a layer without a usable source, a precomposition loop (the layer is cut) | -| `unsupported_items` | a project item of an unknown kind | +| `unsupported_items` | a project item of an unknown kind; an item made from a Photoshop layer without pixels (it shows the merged image) | | `damaged_data` | a header, key or value that could not be read, numbers that are not finite or beyond ±1e9 (default used); an unreadable text document (the text layer is imported with empty text), damaged style runs | `limitations` states what the reader cannot see at all, so it can be neither imported nor counted: From a44b042fce81adfe5520acc693c3328274470367 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 17:00:44 +0530 Subject: [PATCH 106/227] #3087: report the items a placeholder popup cuts A popup of more than 1024 items kept only the first 1024 without a word, and a valid selection past them was reported as damaged data. The cut is now reported, and such a selection takes the first item and says so. Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import/placeholder.rs | 20 ++++++++++++++++---- crates/aep/tests/it/import/placeholders.rs | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/aep/src/import/placeholder.rs b/crates/aep/src/import/placeholder.rs index 4e9fd8047..01d845f24 100644 --- a/crates/aep/src/import/placeholder.rs +++ b/crates/aep/src/import/placeholder.rs @@ -60,6 +60,9 @@ pub(crate) fn placeholder( ParamUi::Popup { options } => options.len(), _ => 0, }; + if p.kind == ParamKind::Popup && popup_len(p).is_some_and(|n| n > items) { + col.lose(LossCategory::Effects, format!("{here}: parameter {label}: the items past the first {MAX_POPUP_ITEMS} not kept")); + } let cost = col.placeholder_cost.saturating_add(items).saturating_add(1); if cost > MAX_COST { col.lose(LossCategory::Effects, format!("{here}: parameter {label} not kept (past the import's {MAX_COST} placeholder parameters)")); @@ -99,7 +102,7 @@ fn shape(p: &EffectParam<'_, '_>, size: (u32, u32)) -> Option<(Option, Par // The file's items, separators (`(-`) included: After Effects counts them. let options: Vec = match &p.items { Some(items) => items.iter().take(MAX_POPUP_ITEMS).cloned().collect(), - None => (1..=usize::from(p.popup_item_count()?).min(MAX_POPUP_ITEMS)).map(|i| format!("Item {i}")).collect(), + None => (1..=popup_len(p)?.min(MAX_POPUP_ITEMS)).map(|i| format!("Item {i}")).collect(), }; let n = u32::try_from(options.len()).ok().filter(|n| *n > 0)?; (Kind::EffectPopup(n), ParamUi::Popup { options }, Value::Enum(0)) @@ -109,6 +112,11 @@ fn shape(p: &EffectParam<'_, '_>, size: (u32, u32)) -> Option<(Option, Par Some((Some(kind), ui, default)) } +/// How many items a popup has: the file's names, else the count in its definition. +fn popup_len(p: &EffectParam<'_, '_>) -> Option { + p.items.as_ref().map(Vec::len).or_else(|| p.popup_item_count().map(usize::from)) +} + /// The property id of `p`: its match name when that is one segment of a property path (a /// third-party effect's `CC Cylinder-0003`), else `param` (a pseudo effect's /// `Pseudo/0.25-0001`: `/` and `.` separate path segments). Expressions also find it by its name. @@ -159,8 +167,12 @@ fn fill(p: &EffectParam<'_, '_>, prop: &mut Property, kind: Kind, cx: &LayerCtx< None => return, }, }; - match convert(value, kind) { - Some(v) => prop.value = v, - None => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?})")), + match (convert(value, kind), value) { + (Some(v), _) => prop.value = v, + // An item of the file that is past the ones kept (reported with the cut): the first item. + (None, ParamValue::Index(i)) if usize::try_from(i).is_ok_and(|i| i > MAX_POPUP_ITEMS && popup_len(p).is_some_and(|n| i <= n)) => { + col.lose(LossCategory::Effects, format!("{here}: parameter {label}: item {i} is past the items kept, the first item used")); + } + (None, _) => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?})")), } } diff --git a/crates/aep/tests/it/import/placeholders.rs b/crates/aep/tests/it/import/placeholders.rs index 847060f93..9afccd6b9 100644 --- a/crates/aep/tests/it/import/placeholders.rs +++ b/crates/aep/tests/it/import/placeholders.rs @@ -125,6 +125,22 @@ fn a_popup_default_of_no_item_takes_the_first_item() { assert!(losses(&r, LossCategory::DamagedData, "Some Fx").is_empty()); } +/// A popup past the 1024 items kept: the cut is reported, and a selection among the cut items +/// takes the first item and is reported as such, not as damaged data. +#[test] +fn a_popup_past_the_items_kept_is_reported() { + let items: &'static [&'static str] = Box::leak((0..1100).map(|i| &*Box::leak(format!("I{i}").into_boxed_str())).collect::>().into_boxed_slice()); + let fx = Fx { match_name: "Long Fx", name: "Long", defs: vec![popup("Pick", 1, items)], values: vec![(1, 1050.0)], data: vec![], keys: vec![] }; + let (g, r) = placeholder_of(&fx); + let pick = g.prop("Long Fx-0001").unwrap(); + assert_eq!(pick.value, Value::Enum(0)); + assert!(matches!(&pick.ui, ParamUi::Popup { options } if options.len() == 1024)); + let found = losses(&r, LossCategory::Effects, "Long Fx"); + assert!(found.iter().any(|d| d.contains("past the first 1024")), "{found:?}"); + assert!(found.iter().any(|d| d.contains("item 1050 is past the items kept")), "{found:?}"); + assert!(losses(&r, LossCategory::DamagedData, "Long Fx").is_empty()); +} + /// Many large popups stay within the import's budget: what is beyond is reported, not kept. #[test] fn a_hostile_placeholder_stays_bounded() { From 670896404695ed80e9cf86930cee11de5c01ab23 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 17:15:43 +0530 Subject: [PATCH 107/227] #3365: sort the unmapped effects with sort_by_key (clippy) Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import/report.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index aa50f4dff..4732592d9 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -316,7 +316,7 @@ impl Collector { if let Some(loss) = self.losses.get_mut(&LossCategory::Effects) { // The most used effects first. let mut named: Vec<(String, usize)> = std::mem::take(&mut self.unmapped_effects).into_values().collect(); - named.sort_by(|a, b| b.1.cmp(&a.1)); + named.sort_by_key(|n| std::cmp::Reverse(n.1)); let described = named.iter().fold(0usize, |sum, (_, n)| sum.saturating_add(*n)); loss.unlisted = loss.unlisted.saturating_sub(described); let lines = named.into_iter().map(|(first, n)| match n { From 12260b288ca6b6e43e1904e468fc81a2c6cc618f Mon Sep 17 00:00:00 2001 From: sunkidda Date: Wed, 11 Nov 2026 12:46:25 +0100 Subject: [PATCH 108/227] .aep import: recognize French names for Levels' Channel and Clip popups (#3432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * .aep import: recognize French names for Levels' Channel and Clip popups After Effects stores a popup's items in the UI's own language. A project saved by a French After Effects writes "RVB", "Activé" and "Désactivé pour la couleur 32 bpc" for Levels' Channel and Clip to Output Black / White. EffectCraft's own popup options are English only, so these never matched by name and the parameter fell back to its default, reported as "has no counterpart". Add popup_alias, a small table of confirmed After Effects labels in other languages, used as a fallback in popup_mapping after the exact name match fails. A synthetic test reproduces the three losses from a real file and checks they are gone, with the same values as the English original. * Cover Levels' French Red, Green and Blue popup items too popup_alias only mapped "rvb" (RGB), so a French Levels with Red, Green or Blue selected still lost that selection the same way. Add "rouge", "vert" and "bleu" (from the same real pdnm list, confirmed against it); "Alpha" is already the same word in both languages. A direct test on popup_alias covers every item of the Channel popup it claims to know. --------- Co-authored-by: sunkidda --- crates/aep/src/container/effect_map.rs | 22 ++++++++++ crates/aep/src/import/effects.rs | 13 ++++-- crates/aep/tests/it/import/synthetic.rs | 56 +++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index aea320395..06b80cc3a 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -1147,6 +1147,28 @@ pub fn popup_key(item: &str) -> String { s.strip_suffix(" channel").map(str::to_string).unwrap_or(s) } +/// The English [`popup_key`] a non-English After Effects popup item name stands for, when this +/// table knows it; `None` otherwise. EffectCraft's own popup options are English only, so a +/// project saved by an After Effects set to another UI language never matches them by name and +/// the parameter falls back to its default (observed on a real "Niveaux" project, French After +/// Effects 2026 26.0.0x67: Levels' Channel and Clip to Output Black / White). Add an entry only +/// from a confirmed After Effects label, never a guess at a whole menu's translation. +pub fn popup_alias(key: &str) -> Option<&'static str> { + Some(match key { + // French: Levels' Channel popup (`ADBE Easy Levels2-0001`). "Alpha" is the same word in + // both languages, so it needs no entry. + "rvb" => "rgb", + "rouge" => "red", + "vert" => "green", + "bleu" => "blue", + // French: Levels' Clip to Output Black / White popups (`-0008`, `-0009`). + "activé" => "on", + "désactivé" => "off", + "désactivé pour la couleur 32 bpc" => "off for 32 bpc color", + _ => return None, + }) +} + /// The table row of an After Effects match name. pub fn by_match_name(match_name: &str) -> Option<&'static EffectMap> { EFFECTS.iter().find(|m| m.match_name == match_name) diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 1ab4b208a..a09ef37b6 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -34,7 +34,7 @@ //! import. Hue/Saturation's master values and Levels' values are arbitrary data, decoded by the //! reader (444-446, 512-516). -use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_key}; +use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_alias, popup_key}; use crate::{ CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, ExtractorChannels, HueSaturationData, Layer as AepLayer, LayerRef, LevelsData, MeshData, ParamKind, ParamValue, @@ -413,8 +413,8 @@ pub(crate) fn resolve_layer_refs( } } -/// The EffectCraft item of each file item (by name, ignoring case and separators); `None` when -/// the parameter here is not a popup. +/// The EffectCraft item of each file item (by name, ignoring case and separators, and a known +/// non-English name for it, [`popup_alias`]); `None` when the parameter here is not a popup. fn popup_mapping(file_items: &[String], ui: &ParamUi) -> Option>> { let ParamUi::Popup { options } = ui else { return None }; let norm = popup_key; @@ -425,7 +425,12 @@ fn popup_mapping(file_items: &[String], ui: &ParamUi) -> Option> if item.trim() == "(-" { return None; } - options.iter().position(|o| norm(o) == norm(item)).and_then(|i| u32::try_from(i).ok()) + let key = norm(item); + options + .iter() + .position(|o| norm(o) == key) + .or_else(|| popup_alias(&key).and_then(|a| options.iter().position(|o| norm(o) == a))) + .and_then(|i| u32::try_from(i).ok()) }) .collect(), ) diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 8e4c28f16..5e01c9f9b 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -842,3 +842,59 @@ fn a_default_state_colour_record_holds_the_value() { assert_eq!(colour(&default_state), colour(&written)); assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default"); } + +/// `SYNTHETIC` with Levels' Channel and Clip to Output Black / White popups' item names (`pdnm`) +/// rewritten to the French labels a French-language After Effects stores there, keeping the same +/// item order so the index each popup is set to is unaffected. +fn with_french_levels_popups() -> Vec { + let (mut channel, mut clip) = (0, 0); + let bytes = mutate(&mut |c| { + if !c.is(b"Utf8") { + return; + } + if c.bytes() == Some(b"RGB|Red|Green|Blue|Alpha".as_slice()) { + *c = Chunk::data(*b"Utf8", b"RVB|Rouge|Vert|Bleu|Alpha".to_vec()); + channel += 1; + } else if c.bytes() == Some(b"On|Off|Off for 32 bpc Color".as_slice()) { + *c = Chunk::data(*b"Utf8", "Activé|Désactivé|Désactivé pour la couleur 32 bpc".as_bytes().to_vec()); + clip += 1; + } + }); + assert_eq!(channel, 2, "Levels' Channel pdnm"); + assert_eq!(clip, 4, "Levels' two Clip pdnm (Black and White), each stored twice"); + bytes +} + +/// A popup's item names in the file are matched to EffectCraft's own (English) ones by name +/// (`popup_key`): a project saved by a French After Effects stores "RVB", "Activé", "Désactivé +/// pour la couleur 32 bpc" for Levels' Channel and Clip to Output Black / White (observed in a +/// real "Niveaux" effect, After Effects 2026 26.0.0x67). Before `popup_alias`, none of these +/// matched EffectCraft's English-only options, so each came back "has no counterpart, default +/// used" and the real selection was silently lost. +#[test] +fn french_levels_popup_items_are_recognized() { + let (unchanged, r1) = run(SYNTHETIC, true); + let (french, r2) = run(&with_french_levels_popups(), true); + let (r1, r2) = (r1.unwrap(), r2.unwrap()); + let found = losses(&r2, LossCategory::Effects); + assert!(!found.iter().any(|d| d.contains("has no counterpart")), "{found:?}"); + for id in ["channel", "clipToOutputBlack", "clipToOutputWhite"] { + let want: Vec<_> = effect_values(&unchanged).into_iter().filter(|(p, _)| p == id).collect(); + let got: Vec<_> = effect_values(&french).into_iter().filter(|(p, _)| p == id).collect(); + assert_eq!(got, want, "{id}"); + } + assert_eq!(r1.report.imported.effects, r2.report.imported.effects); +} + +/// `popup_alias` must cover every item of a popup it claims to know, not only the one a first +/// report happened to show: Channel's French "Rouge", "Vert", "Bleu" ("Alpha" is the same word in +/// both languages) alongside the already-covered "RVB" (`effect_map.rs`, reviewed on #3432). +#[test] +fn french_levels_channel_aliases_cover_every_item() { + use effectcraft_aep::effect_map::popup_alias; + for (french, english) in [("rvb", "rgb"), ("rouge", "red"), ("vert", "green"), ("bleu", "blue")] { + assert_eq!(popup_alias(french), Some(english), "{french}"); + } + // "Alpha" needs no alias: the same word in both languages. + assert_eq!(popup_alias("alpha"), None); +} From 284cec6e461a5ea4e23ceec6cb5499b01b094b01 Mon Sep 17 00:00:00 2001 From: ash Date: Wed, 11 Nov 2026 17:16:46 +0530 Subject: [PATCH 109/227] .aep: popup items match ignoring case outside ASCII too (#3432 follow-up) (#3447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit popup_key lowered only ASCII letters, so an item starting with a capital outside ASCII (French "Écran") kept it and never matched the same word in lower case. After Effects stores popup items in its UI language, and #3432 starts matching such names through popup_alias, keyed by popup_key. Use Unicode lower case; ASCII keys are unchanged. --- crates/aep/src/container/effect_map.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index 06b80cc3a..49a53d2e7 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -1142,8 +1142,12 @@ pub const GLOW_MIDPOINT_MAX: f64 = 0.990_005_493_164_062_5; /// The key a popup item is matched by between the two programs: trimmed, lower case, and /// Set Matte's "Red Channel" ... "Alpha Channel" are EffectCraft's "Red" ... "Alpha" (904 E24). +/// +/// Lower case is Unicode's, not only ASCII's: After Effects stores popup items in its UI +/// language, so an item can start with a capital outside ASCII (French "Écran"), and its key must +/// be the same as for the same word in lower case. pub fn popup_key(item: &str) -> String { - let s = item.trim().to_ascii_lowercase(); + let s = item.trim().to_lowercase(); s.strip_suffix(" channel").map(str::to_string).unwrap_or(s) } @@ -1186,3 +1190,17 @@ pub const HUE_SATURATION_MASTER: [&str; 3] = ["hue", "saturation", "lightness"]; /// The EffectCraft parameters Levels' RGB channel values (`Extra::Levels`) go to, in the order of /// the data (input black, input white, gamma, output black, output white: 512-516). pub const LEVELS_RGB: [&str; 5] = ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"]; + +#[cfg(test)] +mod tests { + use super::*; + + /// A capital outside ASCII has the same key as its lower case; ASCII keys are unchanged. + #[test] + fn popup_key_lowers_non_ascii_capitals() { + assert_eq!(popup_key("ÉCRAN"), popup_key("écran")); + assert_eq!(popup_key(" Désactivé "), "désactivé"); + assert_eq!(popup_key("Off for 32 bpc Color"), "off for 32 bpc color"); + assert_eq!(popup_key("Red Channel"), "red"); + } +} From 6540c9ab447131724647e7838e1090ea82cf449a Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 17:44:01 +0530 Subject: [PATCH 110/227] #3454: Apply Color LUT loads files whose path contains LUT_ load_lut took any source containing "LUT_" for inline .cube text, so a path like /luts/LUT_Rec709.cube was parsed as text, failed, and the effect passed the image through. A .cube can only parse with a size header and data rows on separate lines, so a newline (after trimming, as the path already is) is what tells text from a path. --- crates/effects/src/utility.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index fca9d607e..f0275eab1 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -344,7 +344,9 @@ pub fn load_lut(src: &str) -> Option> { { return v.clone(); } - let inline = src.contains('\n') || src.contains("LUT_"); + // `.cube` text always spans lines (a size header, then data rows); a path never does, even one + // whose file name contains `LUT_` (#3454). Trimmed, as the path is, so a trailing newline is a path. + let inline = src.trim().contains('\n'); let parsed = if inline { parse_cube(src) } else { std::fs::read_to_string(src.trim()).ok().and_then(|t| parse_cube(&t)) }.map(Arc::new); if let Ok(mut m) = cache.lock() { m.insert(src.to_string(), parsed.clone()); @@ -484,6 +486,20 @@ mod tests { assert_eq!(out.img, img); } + /// #3454: a path containing `LUT_` was taken for `.cube` text and the file never loaded. + #[test] + fn a_path_whose_name_contains_lut_loads_the_file() { + let dir = std::env::temp_dir().join(format!("ec-fx-3454-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("LUT_x.cube"); + std::fs::write(&path, identity_cube(2)).unwrap(); + let path = path.to_string_lossy().into_owned(); + assert!(load_lut(&path).is_some(), "{path}"); + assert!(load_lut(&format!("{path}\r\n")).is_some(), "{path} with a trailing newline"); + assert!(load_lut(&identity_cube(2)).is_some(), "inline text"); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn one_d_lut_with_domain() { let lut = parse_cube("LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 2 2 2\n1 1 1\n0 0 0\n").unwrap(); From 33a15e1d8691ffcd5a6301ac7eade7855d97d846 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 08:24:28 -0500 Subject: [PATCH 111/227] Scripting: export commands a script runs directly respect Allow Scripts to Write Files (#2921) * Scripting: export commands a script runs directly respect Allow Scripts to Write Files CompItem.saveFrameToPng and exportAsMotionGraphicsTemplate checked the file-write preference only in their JavaScript wrappers. A script could call comp.saveFrameAs or essential.exportTemplate itself (app.run, __exec) and the native handlers wrote the file with the preference off. Session::refuse_export and write_output_with now refuse when the running command was issued by a script and the preference is off, so every command that writes a path it is given is gated the same way. The wrappers check the gate with a new side-effect-free __file("checkWrite") instead of writing an empty file at the destination. * address review: clear stale export test dir before use * test: allow script writes in script_imports_footage_and_saves --- crates/engine/src/output_guard.rs | 16 ++++++++++ crates/host/src/tests.rs | 1 + crates/script/src/prelude.js | 6 ++-- crates/script/src/runtime.rs | 2 ++ crates/script/src/tests.rs | 50 +++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/output_guard.rs b/crates/engine/src/output_guard.rs index 46c4356ed..73d938a32 100644 --- a/crates/engine/src/output_guard.rs +++ b/crates/engine/src/output_guard.rs @@ -509,12 +509,27 @@ impl Session { /// is not for an export to replace. For callers that write the file themselves; call it /// before the work, not after it. pub fn refuse_export(&self, path: &str) -> crate::Result<()> { + self.refuse_script_write(path)?; match self.blocking(path) { Some(source) => Err(crate::EngineError::Other(source.refusal(path, Use::Export))), None => Ok(()), } } + /// `Err` when a script issued the running command ([`Session::running_for_script`]) and + /// Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files is off. The scripting + /// wrappers check this too, but a script can run any command directly (`app.run`, `__exec`), + /// so the commands that write a path they are given check here, through + /// [`Session::refuse_export`] and [`Session::write_output`]. + pub fn refuse_script_write(&self, path: &str) -> crate::Result<()> { + if self.running_for_script() && !self.prefs.scripting.allow_scripts_write_files { + return Err(crate::EngineError::Other(format!( + "Unable to write {path}: scripts can't write files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on" + ))); + } + Ok(()) + } + /// Write `bytes` to `path` through the host's services, unless the project reads the file /// there ([`Session::refuse_export`]). Every command that writes to a path it is given /// writes through this; Save and Save As (the project's own file) do not. @@ -525,6 +540,7 @@ impl Session { /// [`Session::write_output`] against files already found ([`Session::protected_files`]), /// for a command that writes many. pub(crate) fn write_output_with(&self, protected: &Protected, path: &str, bytes: &[u8]) -> crate::Result<()> { + self.refuse_script_write(path)?; if let Some(source) = protected.find(Path::new(path)) { return Err(crate::EngineError::Other(source.refusal(path, Use::Export))); } diff --git a/crates/host/src/tests.rs b/crates/host/src/tests.rs index 55240698f..687aafe77 100644 --- a/crates/host/src/tests.rs +++ b/crates/host/src/tests.rs @@ -547,6 +547,7 @@ fn script_imports_footage_and_saves() { std::fs::create_dir_all(&dir).unwrap(); let q = |n: &str| serde_json::to_string(&dir.join(n).to_string_lossy()).unwrap(); let mut s = super::session(); + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); let code = format!( r#" var c = app.project.items.addComp("Src", 32, 32, 1, 1, 10); diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index d4962d1b9..37910bf9e 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1658,7 +1658,7 @@ CompItem.prototype.constructor = CompItem; P.saveFrameToPng = function (time, file) { var f = __str(file); if (!f) throw __err("saveFrameToPng: missing file"); - __file("write", f, ""); + __file("checkWrite", f); __call("comp.saveFrameAs", { comp: this.__id, path: f, time: __num(time, "time") }); }; P.openInEssentialGraphics = function () { __call("comp.openInEssentialGraphics", { comp: this.__id }); }; @@ -1668,8 +1668,8 @@ CompItem.prototype.constructor = CompItem; if (!/\.[A-Za-z0-9]+$/.test(f)) f += ".ectemplate"; if (!overwrite && JSON.parse(__file("exists", f)) === true) return false; if (this.__controllers().length === 0) return false; - // The file gate applies to templates too. - __file("write", f, ""); + // The file gate applies to templates too (checked without touching the file). + __file("checkWrite", f); __call("essential.exportTemplate", { comp: this.__id, path: f }); return true; }; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index c23eaefb0..830e250e9 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -427,6 +427,8 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult check_access(s, &path, true).map(|_| json!(true)), "read" | "readBinary" => { let p = check_access(s, &path, false)?; let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?; diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index d0884dbcb..c7cd8730c 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -700,6 +700,56 @@ fn save_frame_to_png_writes_behind_the_file_gate() { let _ = std::fs::remove_dir_all(&dir); } +/// A script that runs the export commands itself (`app.run`, `__exec`) meets the same file-write +/// preference as the wrappers; with the preference off nothing is created or replaced. +#[test] +fn export_commands_run_directly_by_a_script_respect_the_file_gate() { + let dir = std::env::temp_dir().join(format!("ec-direct-export-{}", std::process::id())); + // A leftover dir from an interrupted run (same pid) must not feed stale absence checks. + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("frame.png"); + let tpl = dir.join("card.ectemplate"); + let kept = dir.join("kept.png"); + std::fs::write(&kept, b"original").unwrap(); + let mut s = session(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("layer.newText", json!({"text": "Hi"})).unwrap(); + ok(&mut s, "var c = app.project.activeItem; c.layer(1).property('ADBE Transform Group').property('ADBE Opacity').addToMotionGraphicsTemplate(c);"); + let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap(); + let comp = s.active_comp_id().unwrap().0; + for (code, target) in [ + (format!("app.run('comp.saveFrameAs', {{comp: {comp}, path: {}}})", q(&png)), &png), + (format!("__exec('comp.saveFrameAs', JSON.stringify({{comp: {comp}, path: {}}}))", q(&png)), &png), + (format!("app.run('essential.exportTemplate', {{comp: {comp}, path: {}}})", q(&tpl)), &tpl), + (format!("app.run('comp.saveFrameAs', {{comp: {comp}, path: {}}})", q(&kept)), &kept), + ] { + let o = run_code(&mut s, &code, "direct.jsx"); + let e = o.error.unwrap_or_else(|| panic!("`{code}` was not refused")); + assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}"); + if target == &kept { + assert_eq!(std::fs::read(&kept).unwrap(), b"original"); + } else { + assert!(!target.exists(), "{} was written", target.display()); + } + } + // The wrappers check the gate without touching the file: an existing one is left as it was. + let o = run_code(&mut s, &format!("app.project.activeItem.saveFrameToPng(0, new File({}))", q(&kept)), "wrap.jsx"); + assert!(o.error.is_some()); + assert_eq!(std::fs::read(&kept).unwrap(), b"original"); + let o = run_code(&mut s, &format!("app.project.activeItem.exportAsMotionGraphicsTemplate(true, {})", q(&tpl)), "wrap.jsx"); + assert!(o.error.is_some()); + assert!(!tpl.exists()); + // The user, from the UI, is not a script: the same command writes. + s.execute("comp.saveFrameAs", json!({"comp": comp, "path": png.to_string_lossy()})).unwrap(); + assert!(png.exists()); + // With the preference on, the script's direct call writes too. + s.prefs.scripting.allow_scripts_write_files = true; + ok(&mut s, &format!("app.run('essential.exportTemplate', {{comp: {comp}, path: {}}})", q(&tpl))); + assert!(std::fs::metadata(&tpl).unwrap().len() > 100); + let _ = std::fs::remove_dir_all(&dir); +} + /// #1246: with Warn User When Executing Files on, a script that asserts `confirmed` itself still /// gets the question; only the user's answer opens the file. #[test] From 006dcc962debf258dc661c7b923076b5e705e694 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:31:36 +0100 Subject: [PATCH 112/227] #1615: Auto caps CPU frames in flight from free memory after a job switches to the CPU (#3458) A GPU project whose Render Queue job measured the CPU faster (#3335) rendered every frame of a batch at once on the CPU, one per thread, with no memory cap: on a 16-thread machine at about 1.4 GB a frame the app ran out of RAM and exited without an error (0.23.1, Windows). While a job renders on the CPU after a GPU frame measured its working memory, CPU frames in flight now fit half the memory the system has free at that size per frame, 1 to one per thread, settled once per job and logged. The probe's timed CPU frames use the same cap. EFFECTCRAFT_CPU_INFLIGHT still overrides it; Software Rendering projects, with no measurement, are unchanged. The host reads the free memory through the new JobOptions::free_memory. --- crates/export/src/lib.rs | 5 ++ crates/export/src/pipeline.rs | 131 +++++++++++++++++++++++++++++++++- crates/host/src/lib.rs | 6 ++ docs/preferences.md | 11 +-- 4 files changed, 147 insertions(+), 6 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 21b7501fa..48cb07ac4 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -122,6 +122,11 @@ pub struct JobOptions<'a> { /// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: 1 to 3, or `None` for Auto (from /// the device memory a frame measured, #3335). `EFFECTCRAFT_GPU_INFLIGHT` overrides it. pub gpu_frames: Option, + /// Reads the bytes of memory the system can hand out now (`None`: unknown), for Auto's CPU + /// frames in flight (#1615): read at most once per job, when a job renders on the CPU after + /// a GPU frame measured a frame's working memory. `None`: CPU frames are capped only by + /// `EFFECTCRAFT_CPU_INFLIGHT`. + pub free_memory: Option<&'a (dyn Fn() -> Option + Sync)>, } /// The layer caches of a Render Queue run (see [`JobOptions::caches`]): one per compositor, diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 922b1b4ea..d447e0980 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -137,6 +137,17 @@ pub(crate) fn cpu_inflight(var: Option<&str>) -> Option { } } +/// Auto's CPU frames in flight (#1615): as many frames as half of the `free` bytes the system +/// can hand out hold at `frame` bytes each, 1 to `threads`. `frame` is a GPU frame's measured +/// working memory: both compositors keep 16-byte RGBA pixels (`[f32; 4]`, `Rgba32Float`), and +/// the other half is headroom for the layer caches, decoded footage, the encoder and the +/// estimate being low. `None` (not capped) when either is unknown or `frame` is 0. +pub(crate) fn auto_cpu_frames(frame: Option, free: Option, threads: usize) -> Option { + let (frame, free) = (frame.filter(|&f| f > 0)?, free?); + let n = free / 2 / frame; + Some(usize::try_from(n).unwrap_or(usize::MAX).clamp(1, threads.max(1))) +} + /// The frames-in-flight caps of a batch: `gpu` (#879) for the GPU's memory, `cpu` (#1615) for /// the process's. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -253,6 +264,9 @@ pub(crate) struct Cx<'a> { gpu_frames: Mutex>, /// A frame has rendered on the GPU compositor: its memory can be measured. gpu_rendered: std::sync::atomic::AtomicBool, + /// Auto's CPU frames in flight once settled ([`Cx::auto_cpu_frames`]; `Some(None)`: not + /// capped). + cpu_frames: Mutex>>, /// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs). pub frame_times: Option>>, /// Files written to an overflow folder. @@ -337,6 +351,7 @@ impl<'a> Cx<'a> { choice: Mutex::new(None), gpu_frames: Mutex::new(None), gpu_rendered: Default::default(), + cpu_frames: Mutex::new(None), frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]), @@ -555,9 +570,11 @@ impl<'a> Cx<'a> { let timed = |on: crate::JobRenderer, frames: &[u64]| { let warm = render(first, on); // The GPU's frames in flight settle with its warm-up frame (Auto measures it). + // The CPU's frames are held to what the job would render at once on it, from the + // GPU frames just measured (#1615): uncapped, they could exhaust RAM here already. let caps = match on { crate::JobRenderer::Gpu => Inflight { gpu: Some(self.gpu_inflight()), cpu: cpu_cap }, - crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap }, + crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap.or_else(|| self.auto_cpu_frames()) }, }; let t0 = Instant::now(); let gpu_live = || on == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| a.available()); @@ -660,7 +677,48 @@ impl<'a> Cx<'a> { /// The GPU and CPU frames-in-flight caps every frame loop shares (#1615). fn inflight(&self) -> Inflight { - Inflight { gpu: self.gpu_cap(), cpu: cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) } + Inflight { gpu: self.gpu_cap(), cpu: self.cpu_cap() } + } + + /// CPU frames in flight (#1615): `EFFECTCRAFT_CPU_INFLIGHT`, else Auto's + /// ([`Cx::auto_cpu_frames`]) while the job renders on the CPU (it chose the CPU's + /// compositor, or the GPU stopped for good). `None`: not capped. + fn cpu_cap(&self) -> Option { + if let Some(n) = cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) { + return Some(n); + } + let on_cpu = self.backend() == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| !a.available()); + if on_cpu { self.auto_cpu_frames() } else { None } + } + + /// Auto's CPU frames in flight ([`auto_cpu_frames`]), settled once per job from the working + /// memory a GPU frame measured and the memory the system has free + /// ([`crate::JobOptions::free_memory`]). `None` (not capped, as before #1615) until a GPU + /// frame has rendered, and in a job that never renders one (a Software Rendering project): + /// there is no per-frame estimate then. + fn auto_cpu_frames(&self) -> Option { + if let Some(n) = *self.cpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner) { + return n; + } + if !self.gpu_rendered.load(std::sync::atomic::Ordering::Relaxed) { + return None; + } + // Read outside the lock: the allocator report walks every allocation, and reading the + // system's memory can take a second (PowerShell on Windows). + let frame = self.job.accel.and_then(|a| a.frame_memory()).map(|m| m.frame); + let free = self.job.options.free_memory.and_then(|read| read()); + let n = auto_cpu_frames(frame, free, rayon::current_num_threads()); + let mut slot = self.cpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + *slot.get_or_insert_with(|| { + let mib = |b: Option| b.map_or_else(|| "unknown".to_string(), |b| format!("{} MiB", b >> 20)); + let (frame, free) = (mib(frame), mib(free)); + let label = &self.job.options.label; + match n { + Some(n) => log::info!(target: "effectcraft_export", "{label}: {n} CPU frame(s) at once (Auto: {frame} per frame on the GPU, {free} free)"), + None => log::info!(target: "effectcraft_export", "{label}: CPU frames not capped (Auto: {frame} per frame on the GPU, {free} free)"), + } + n + }) } /// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized. @@ -1285,6 +1343,75 @@ mod tests { assert_eq!(describe_choice(&cx.choice().unwrap()), "Software Rendering (measured on the first frames: GPU 117.0 ms/frame, CPU 68.0 ms/frame)"); } + #[test] + fn auto_cpu_frames_fit_half_the_free_memory() { + // #1615: 16 threads at 1378 MiB a frame (a GTX 1050 Ti user's comp) with 10 GiB free + // render 3 frames at once, not 16. + assert_eq!(auto_cpu_frames(Some(1378 << 20), Some(10 << 30), 16), Some(3)); + assert_eq!(auto_cpu_frames(Some(1 << 28), Some(64 << 30), 16), Some(16), "at most one per thread"); + assert_eq!(auto_cpu_frames(Some(4 << 30), Some(2 << 30), 16), Some(1), "at least one"); + assert_eq!(auto_cpu_frames(Some(1 << 30), Some(0), 8), Some(1)); + assert_eq!(auto_cpu_frames(Some(1 << 20), Some(u64::MAX), 0), Some(1)); + // Unknown: not capped, as before. + assert_eq!(auto_cpu_frames(None, Some(8 << 30), 16), None); + assert_eq!(auto_cpu_frames(Some(1 << 30), None, 16), None); + assert_eq!(auto_cpu_frames(Some(0), Some(8 << 30), 16), None); + } + + #[test] + fn a_job_that_chose_the_cpu_after_measuring_the_gpu_caps_its_frames() { + // #1615: a GPU project that measured the CPU faster rendered one frame per thread at + // once, so a heavy comp ran out of RAM (an exit without an error, 0.23.1 on Windows). + // Its frames now fit half the free memory at the GPU's measured size per frame. + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + if std::env::var_os(CPU_INFLIGHT_ENV).is_some() || std::env::var_os(RENDERER_ENV).is_some() { + return; + } + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence)); + let mut p = Project::default(); + p.settings.gpu_acceleration = true; + let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into())); + let accel = MemAccel(effectcraft_render::FrameMemory { budget: 3 << 30, peak: 2 << 30, frame: 1 << 30, reserve: 0 }); + let reads = AtomicUsize::new(0); + let free = || { + reads.fetch_add(1, Ordering::SeqCst); + Some(6u64 << 30) + }; + let job = Job { + project: &p, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: Some(&accel), + comp: cid, + settings: &settings, + output: &output, + path: "/c/f_[#####].png", + sink: None, + nested_switches: true, + options: crate::JobOptions { free_memory: Some(&free), ..Default::default() }, + }; + let pool = rayon::ThreadPoolBuilder::new().num_threads(16).build().unwrap(); + pool.install(|| { + let cx = Cx::new(&job); + assert_eq!(cx.inflight().cpu, None, "nothing measured yet"); + let c = cx.comp().unwrap(); + cx.frame_on(c, 0, crate::JobRenderer::Gpu); + assert_eq!(cx.inflight().cpu, None, "the GPU renders: its own cap holds"); + assert_eq!(reads.load(Ordering::SeqCst), 0, "the free memory is read only for CPU frames"); + cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(1417.3), cpu_ms: Some(655.8), why: "measured on the first frames" }); + assert_eq!(cx.inflight(), Inflight { gpu: None, cpu: Some(3) }, "3 GiB of 6 GiB free at 1 GiB a frame"); + assert_eq!(cx.inflight().cpu, Some(3)); + assert_eq!(reads.load(Ordering::SeqCst), 1, "settled once per job"); + }); + // Without a free-memory reading the job renders uncapped, as before. + let job = Job { options: Default::default(), ..job }; + let cx = Cx::new(&job); + cx.frame_on(cx.comp().unwrap(), 0, crate::JobRenderer::Gpu); + cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(2.0), cpu_ms: Some(1.0), why: "measured on the first frames" }); + assert_eq!(cx.inflight().cpu, None); + } + #[test] fn cpu_inflight_is_off_unless_a_positive_number() { assert_eq!(cpu_inflight(None), None); diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 3d1384a90..0233f834c 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -65,6 +65,7 @@ impl Exporter for FileExporter { caches: job.caches.map(|c| effectcraft_export::JobCaches { gpu: &c.gpu, cpu: &c.cpu }), renderer: job.renderer, gpu_frames: job.gpu_frames, + free_memory: Some(&free_memory), }, nested_switches: job.nested_switches, }; @@ -87,6 +88,11 @@ impl Exporter for FileExporter { } } +/// The memory the system can hand out now, for an export's Auto CPU frames in flight (#1615). +fn free_memory() -> Option { + effectcraft_engine::sysinfo::memory().map(|m| m.available) +} + /// A new session with media, import, expressions, scripting and export enabled. pub fn session() -> Session { let mut s = Session { diff --git a/docs/preferences.md b/docs/preferences.md index 86c4599b3..c35d2c793 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -100,10 +100,13 @@ Commands (CLI, MCP, control channel): one; `EFFECTCRAFT_GPU_MEMCAP_MB` sets it), keeping room for the upload cache to fill. It renders one frame at a time where the memory can't be measured (Metal, WebGPU, GL, or the cap turned off) and once frames have had to wait for memory. The render log names the count. - `EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting: they keep one - frame per thread in flight, so their memory grows with the core count. Set - `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the frames in - flight of every Render Queue render and export, `effectcraft-cli render` included + `EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting. A job on GPU + Acceleration that renders on the CPU (it measured the CPU faster, or the GPU stopped) fits + its CPU frames in flight into half the memory the system has free, at the size a GPU frame + measured, from 1 to one per thread; the app log names the count. Other CPU renders (Software + Rendering projects) keep one frame per thread in flight, so their memory grows with the core + count. Set `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the + frames in flight of every Render Queue render and export, `effectcraft-cli render` included ([#1615](https://github.com/storytold/effectcraft/issues/1615)); previews don't read it. One exception: while a job on GPU Acceleration times both compositors on its first frames, it renders them under the cap but keeps them all (up to 12) until it has chosen. That timing From 230b3920de18d6da6cbeadfcfc1646ac0ca67724 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:57:18 +0100 Subject: [PATCH 113/227] Release: EffectCraft v0.24.0 (#3494) --- Cargo.lock | 78 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37233ef58..a69346b9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "cpal", @@ -1448,7 +1448,7 @@ dependencies = [ [[package]] name = "effectcraft-aep" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1462,7 +1462,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1476,14 +1476,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.23.1" +version = "0.24.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.23.1" +version = "0.24.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1502,7 +1502,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.23.1" +version = "0.24.0" dependencies = [ "proptest", "serde", @@ -1510,7 +1510,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "effectcraft-color", @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "craft-docpath", @@ -1567,7 +1567,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1598,7 +1598,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.23.1" +version = "0.24.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1615,7 +1615,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.23.1" +version = "0.24.0" dependencies = [ "proptest", "serde", @@ -1623,7 +1623,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1646,7 +1646,7 @@ dependencies = [ [[package]] name = "effectcraft-helper-host" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "effectcraft-effects", @@ -1657,14 +1657,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.23.1" +version = "0.24.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1691,7 +1691,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1710,7 +1710,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1722,7 +1722,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1740,7 +1740,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1773,7 +1773,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-geom", "image", @@ -1786,14 +1786,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.23.1" +version = "0.24.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1805,7 +1805,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1821,7 +1821,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1833,7 +1833,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-ecproj-format", "effectcraft-color", @@ -1847,14 +1847,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.23.1" +version = "0.24.0" dependencies = [ "miniz_oxide 0.9.1", ] [[package]] name = "effectcraft-raster" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1864,7 +1864,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1887,7 +1887,7 @@ dependencies = [ [[package]] name = "effectcraft-sam2" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "candle-core", @@ -1902,7 +1902,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.23.1" +version = "0.24.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1920,7 +1920,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.23.1" +version = "0.24.0" dependencies = [ "miniz_oxide 0.9.1", "ndarray", @@ -1929,7 +1929,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1941,7 +1941,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1953,7 +1953,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.23.1" +version = "0.24.0" dependencies = [ "proptest", "serde", @@ -1961,7 +1961,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1973,7 +1973,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "chrono", @@ -2014,11 +2014,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.23.1" +version = "0.24.0" [[package]] name = "effectcraft-web" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -7835,7 +7835,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 6f96cb012..18a92e409 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.23.1" +version = "0.24.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From a6fba29c3e881e9dd187552d07df0043b7b761b3 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 21:42:54 +0530 Subject: [PATCH 114/227] #3087: report a Photoshop layer whose framing the item's size cannot tell An item as big as its layer's bounds was taken for Retain Layer Sizes. When the layer is also as big as the document but not at its corner, a document-framed item has the same size, so it may show shifted. The opti record holds the document's size at bytes 32 and 36 (1000, 1000 in every record of the issue's project, the .psd's size); with it, that case is reported. The layer still shows at its own bounds. --- crates/aep/src/container/footage.rs | 25 ++++++++++++++++++++++--- crates/aep/src/import/build.rs | 13 +++++++++++-- crates/aep/tests/it/import/synthetic.rs | 22 ++++++++++++++++++++-- docs/aep-format.md | 4 ++-- docs/aep-import.md | 4 ++-- 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/crates/aep/src/container/footage.rs b/crates/aep/src/container/footage.rs index 7a3782d79..8fca7c27b 100644 --- a/crates/aep/src/container/footage.rs +++ b/crates/aep/src/container/footage.rs @@ -52,8 +52,9 @@ pub struct Footage { /// One layer of a Photoshop file shown by a footage item, from its `opti` (tag `8BPS`, /// docs/aep-format.md 3.3): the layer record's index as a big-endian `i32` at byte 14 (-1 for the -/// merged image), its bounds top, left, bottom, right as little-endian `i32` at 78, 82, 86 and -/// 90, and its name (UTF-8, NUL terminated) from byte 344. +/// merged image), the document's sides as little-endian `u32` at 32 and 36, its bounds top, left, +/// bottom, right as little-endian `i32` at 78, 82, 86 and 90, and its name (UTF-8, NUL +/// terminated) from byte 344. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PsdLayer { /// The layer record's position in the file (bottom of the stack = 0). @@ -62,6 +63,9 @@ pub struct PsdLayer { /// Top, left, bottom, right in document pixels; all 0 for a layer without pixels (an /// adjustment layer). pub bounds: [i32; 4], + /// The document's two sides (bytes 32 and 36; which one is the width is not sampled: the one + /// file read has a square document). 0 when not stored. + pub document: [u32; 2], } /// Most bytes of a Photoshop layer name read. @@ -76,6 +80,12 @@ impl PsdLayer { Some((w, h)) } + /// Whether the document is `size` big, its sides in either order. + pub fn document_is(&self, size: (u32, u32)) -> bool { + let [a, b] = self.document; + a > 0 && b > 0 && ((a, b) == size || (b, a) == size) + } + /// The layer an `opti` names; `None` for another tag, the merged image or a cut record. fn read(opti: &[u8]) -> Option { if opti.get(..4) != Some(&b"8BPS"[..]) { @@ -85,7 +95,8 @@ impl PsdLayer { let le_i32 = |at| le_u32(opti, at).map(|v| i32::from_le_bytes(v.to_le_bytes())); // (After Effects writes 602 bytes; a longer hostile name is cut.) let name = opti.get(344..).map(|b| text(until_nul(b.get(..MAX_NAME).unwrap_or(b)))).unwrap_or_default(); - Some(Self { index, name, bounds: [le_i32(78)?, le_i32(82)?, le_i32(86)?, le_i32(90)?] }) + let document = [le_u32(opti, 32).unwrap_or(0), le_u32(opti, 36).unwrap_or(0)]; + Some(Self { index, name, bounds: [le_i32(78)?, le_i32(82)?, le_i32(86)?, le_i32(90)?], document }) } } @@ -444,6 +455,14 @@ mod tests { assert_eq!(PsdLayer::read(&opti(2, [0; 4], b"")).unwrap().size(), None); assert_eq!(PsdLayer::read(&opti(2, [i32::MIN, i32::MIN, i32::MAX, i32::MAX], b"")).unwrap().size(), Some((u32::MAX, u32::MAX))); assert_eq!(PsdLayer::read(&opti(2, [i32::MAX, 0, i32::MIN, 1], b"")).unwrap().size(), None); + // The document's size, its sides in either order; none when not stored. + let mut doc = opti(0, [0, 0, 1000, 800], b""); + doc[32..36].copy_from_slice(&800u32.to_le_bytes()); + doc[36..40].copy_from_slice(&1000u32.to_le_bytes()); + let l = PsdLayer::read(&doc).unwrap(); + assert_eq!(l.document, [800, 1000]); + assert!(l.document_is((800, 1000)) && l.document_is((1000, 800)) && !l.document_is((800, 800))); + assert!(!PsdLayer::read(&opti(0, [0; 4], b"")).unwrap().document_is((0, 0))); } #[test] diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 9da8bb39a..2804d365b 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -352,14 +352,23 @@ fn is_placed_page_path(path: &str) -> bool { /// The Photoshop layer a footage item shows (#3087): at its own bounds when the item is as big /// as they are (Retain Layer Sizes), else on the document. A layer without pixels (an adjustment -/// layer) keeps the merged image and is reported. +/// layer) keeps the merged image and is reported. So is a layer as big as the document but not at +/// its corner: the item's size fits both framings, and its bounds are used. fn source_layer(f: &crate::Footage, name: &str, col: &mut Collector) -> Option { let l = f.psd_layer.as_ref()?; let Some(size) = l.size() else { col.lose(LossCategory::UnsupportedItems, format!("footage '{name}': a Photoshop layer without pixels (an adjustment layer), the merged image used")); return None; }; - Some(SourceLayer { index: l.index, name: l.name.clone(), layer_size: size == (f.width, f.height), ..SourceLayer::default() }) + let layer_size = size == (f.width, f.height); + let [top, left, ..] = l.bounds; + if layer_size && (top, left) != (0, 0) && l.document_is(size) { + col.lose( + LossCategory::UnsupportedItems, + format!("footage '{name}': a Photoshop layer as big as the document but at ({left}, {top}), shown at its own bounds (Retain Layer Sizes assumed)"), + ); + } + Some(SourceLayer { index: l.index, name: l.name.clone(), layer_size, ..SourceLayer::default() }) } /// The name After Effects shows for a footage item that stores none: the file's name, or for a diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 0c9e79446..0c00088a5 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -844,12 +844,14 @@ fn a_default_state_colour_record_holds_the_value() { } /// The project with its png item made one layer of a Photoshop file `art.psd`: the `opti` of -/// docs/aep-format.md 3.3 (tag `8BPS`, the layer index at 14, its bounds at 78-94, its name at -/// 344) and the item `size` big. +/// docs/aep-format.md 3.3 (tag `8BPS`, the layer index at 14, a 1000 x 1000 document at 32 and 36, +/// its bounds at 78-94, its name at 344) and the item `size` big. fn as_psd_layer(index: i32, bounds: [i32; 4], name: &str, size: (u32, u32)) -> Vec { let mut opti = vec![0u8; 602]; opti[..4].copy_from_slice(b"8BPS"); opti[14..18].copy_from_slice(&index.to_be_bytes()); + opti[32..36].copy_from_slice(&1000u32.to_le_bytes()); + opti[36..40].copy_from_slice(&1000u32.to_le_bytes()); for (i, v) in bounds.iter().enumerate() { opti[78 + 4 * i..82 + 4 * i].copy_from_slice(&v.to_le_bytes()); } @@ -896,6 +898,22 @@ fn a_photoshop_layer_item_shows_its_layer() { } } +/// A layer as big as the document but off its corner fits both framings: shown at its own bounds +/// and reported. One at the corner, or bigger than the document, reads one way and is not. +#[test] +fn a_photoshop_layer_as_big_as_the_document_off_its_corner_is_reported() { + let framing = |bounds, size| { + let (p, r) = run(&as_psd_layer(3, bounds, "Moved", size), true); + let found = losses(&r.unwrap(), LossCategory::UnsupportedItems); + (the_footage(&p).layer.map(|l| l.layer_size), found.iter().any(|d| d.contains("'Moved/art.psd'") && d.contains("as big as the document"))) + }; + assert_eq!(framing([5, 10, 1005, 1010], (1000, 1000)), (Some(true), true)); + assert_eq!(framing([0, 0, 1000, 1000], (1000, 1000)), (Some(true), false)); + assert_eq!(framing([9, -346, 1083, 1086], (1432, 1074)), (Some(true), false)); + // An item of another size frames the layer on the document: no ambiguity. + assert_eq!(framing([5, 10, 1005, 1010], (800, 800)), (Some(false), false)); +} + /// #3087: a Photoshop layer without pixels (an adjustment layer) keeps the merged image and is /// reported; the merged image itself (index -1) is no layer. #[test] diff --git a/docs/aep-format.md b/docs/aep-format.md index 2b4dae27f..0b73dcfd5 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -1,6 +1,6 @@ # After Effects project format (`.aep`) -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** footage: the Photoshop layer an item shows (`opti` tag `8BPS`, section 3.3, issue 3087); before that: Puppet (`ADBE FreePin3`, 3.7): the property tree from an external scripting listing, **?** until a byte sample exists (#3093); before that: footage: an image sequence without a filename prefix stores only the suffix `Utf8`, and one imported with Force alphabetical order stores its file list in `LIST StVc` / `StVS` (section 3, footage); before that: versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** footage: the Photoshop layer an item shows and its document's size (`opti` tag `8BPS`, section 3.3, issue 3087); before that: Puppet (`ADBE FreePin3`, 3.7): the property tree from an external scripting listing, **?** until a byte sample exists (#3093); before that: footage: an image sequence without a filename prefix stores only the suffix `Utf8`, and one imported with Force alphabetical order stores its file list in `LIST StVc` / `StVS` (section 3, footage); before that: versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026 What `crates/aep` ([`effectcraft-aep`](../crates/aep/README.md)) reads and writes, and the evidence for each statement. Everything here was observed on project files we generated ourselves; no @@ -263,7 +263,7 @@ property of `L` after 229 and 230). The corpus manifest calls the classic render | footage size | `Pin /sspc` | 30, 34 | u32, u32 | width, height in pixels | 000 (1920 x 1080), 022 (100 x 100 null), 126 (8 x 8), 211, 212, 259 | V | | footage name | `Pin /opti` | 26 (solid), 58 (file) | ASCII, NUL terminated | the item's own `Utf8` name is empty for footage | 000 (`L`), 022 (`Null 1`), 126 (`test8x8.png`), 211 (`S100`), 259 (`Blue`) | V | | **solid colour** | `Pin /opti` | 10, 14, 18, 22 | 4 x f32 | ARGB, 0..1: red [1, 0, 0] is `1, 1, 0, 0`, blue [0, 0, 1] is `1, 0, 0, 1` | 000, 259 | V | -| **Photoshop layer** | `Pin /opti` (tag `8BPS`) | 14; 78, 82, 86, 90; 344 | i32 BE; 4 x i32 **LE**; UTF-8, NUL terminated | an item made from one layer of a `.psd`: the layer record's index in the file (bottom of the stack = 0; -1 is the merged image), the layer's bounds top, left, bottom, right in document pixels (all 0 for a layer without pixels, such as an adjustment layer) and the layer's name. The item's size (`sspc` 30 / 34) is the bounds' size with Retain Layer Sizes, else the document's. `Footage::psd_layer` | the project of issue 3087: ten layers, each index, bounds and name equal to the `.psd`'s layer record at that position (`Layer 1` `[9, -346, 1083, 1086]`, item 1432 x 1074; `test` `[749, 800, 764, 848]`, item 48 x 15) | V | +| **Photoshop layer** | `Pin /opti` (tag `8BPS`) | 14; 32, 36; 78, 82, 86, 90; 344 | i32 BE; 2 x u32 **LE**; 4 x i32 **LE**; UTF-8, NUL terminated | an item made from one layer of a `.psd`: the layer record's index in the file (bottom of the stack = 0; -1 is the merged image), the document's two sides (which is the width: **?**, the one sample is square), the layer's bounds top, left, bottom, right in document pixels (all 0 for a layer without pixels, such as an adjustment layer) and the layer's name. The item's size (`sspc` 30 / 34) is the bounds' size with Retain Layer Sizes, else the document's. `Footage::psd_layer` | the project of issue 3087: ten layers, each index, bounds and name equal to the `.psd`'s layer record at that position (`Layer 1` `[9, -346, 1083, 1086]`, item 1432 x 1074; `test` `[749, 800, 764, 848]`, item 48 x 15; 32 and 36 hold 1000, 1000 in every record, the `.psd`'s size) | V | | file path | `Pin /Als2/alas` | key `fullpath` | JSON string | alias JSON of a file footage item | 126 (the fixture holds a neutral path) | V | | precomp linkage | `Layr/ldta` | 40 | u32 | the layer's source id is the id of the precomp's item | 124 | V | diff --git a/docs/aep-import.md b/docs/aep-import.md index d2a418b85..9f9f8b2b0 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -70,7 +70,7 @@ outside the sampled ones keeps the current value and is reported as `project set | Shape layers | groups (nested, with anchor, position, scale, skew, skew axis, rotation, opacity), rectangle (size, position, roundness), ellipse (size, position), polystar (star or polygon, points, position, rotation, radii, roundness), bezier path (vertices and tangents in layer pixels, path keys), fill (colour, opacity, fill rule), stroke (colour, opacity, width, cap, join, miter limit), gradient fill and stroke (linear or radial, start and end point; the default white-to-black colours), trim paths (start, end, offset, trim multiple shapes), round corners (radius), merge paths (mode; 707, 708), offset paths (amount, line join, miter limit, copies, copy offset; 710; After Effects offsets copy *i* by Amount x (*i* + Copy Offset), default 1, EffectCraft by Amount x (*i* + 1 + copyOffset), so the copy offset arrives 1 less, also its keys; an expression on it is kept and reported; 826-828, 828 renders equal to After Effects on every grid point), repeater (copies, offset, composite, transform; 709), in file order, with keyframes and expressions, the operators' defaults as After Effects reads them back (731); the layer anchor in pixels; 238-242, 330-333, 451-484, 519, 705-710, 731 | | Markers | layer and composition markers: time (layer time for layer markers, exact frames), duration (1/600 s units, 509, 510), comment, chapter, URL, frame target, label, cue point (name, event or navigation, parameters); 122, 123, 249, 347-349, 509, 510 | | Time Remapping | an enabled remap becomes the `timeRemap` property (source seconds, keys in layer time with their interpolation and ease, expression), first in the layer's properties as Layer > Time > Enable Time Remapping puts it; 319, 354 | -| Footage | a link to the stored path; **missing-footage placeholder** when the path does not exist where the import runs (listed in `missingFootage`), a normal link when it does. The importer never opens footage. **An image sequence** (its alias is the folder) becomes a sequence of its files, named from the stored numbering (`shot_0101.exr` .. `shot_0103.exr` in 603, `seq_001.png` .. in 353), at its native frame rate; the first file is the footage's path and the one checked. Sequences and clips take the stored native frame rate and duration (603, 606). A sequence of more than 100 000 frames, or beyond a million file names in one import, links only its first file and is reported. **One layer of a Photoshop file** shows that layer (`Footage::layer`: its index and name; at its own size with Retain Layer Sizes, else on the document) and is named `/` unless the item has a name; a layer without pixels (an adjustment layer) keeps the merged image and is reported | +| Footage | a link to the stored path; **missing-footage placeholder** when the path does not exist where the import runs (listed in `missingFootage`), a normal link when it does. The importer never opens footage. **An image sequence** (its alias is the folder) becomes a sequence of its files, named from the stored numbering (`shot_0101.exr` .. `shot_0103.exr` in 603, `seq_001.png` .. in 353), at its native frame rate; the first file is the footage's path and the one checked. Sequences and clips take the stored native frame rate and duration (603, 606). A sequence of more than 100 000 frames, or beyond a million file names in one import, links only its first file and is reported. **One layer of a Photoshop file** shows that layer (`Footage::layer`: its index and name; at its own size with Retain Layer Sizes, else on the document) and is named `/` unless the item has a name; a layer without pixels (an adjustment layer) keeps the merged image and is reported; so is a layer as big as the document but not at its corner, which the item's size does not tell apart (shown at its own size) | Units, the "absent = default" rule and the keyframe templates are the reader's ([aep-format.md](aep-format.md), sections 3.3–3.6). The importer converts them to EffectCraft's: @@ -345,7 +345,7 @@ for the `unlisted` ones. The categories: | `parent_links` | a parent that was not imported (a skipped camera, a missing layer) or that forms a loop | | `track_mattes` | a track matte of unknown mode, whose matte layer was not imported or does not exist, a layer matted by itself, or a matte of the older "layer above" form on the top layer | | `unsupported_layers` | an unknown layer type, a layer without a usable source, a precomposition loop (the layer is cut) | -| `unsupported_items` | a project item of an unknown kind; an item made from a Photoshop layer without pixels (it shows the merged image) | +| `unsupported_items` | a project item of an unknown kind; an item made from a Photoshop layer without pixels (it shows the merged image), or from one as big as the document but not at its corner (shown at its own size) | | `damaged_data` | a header, key or value that could not be read, numbers that are not finite or beyond ±1e9 (default used); an unreadable text document (the text layer is imported with empty text), damaged style runs | `limitations` states what the reader cannot see at all, so it can be neither imported nor counted: From 14d04536aafb34b41e6eff305f197cd2ae938438 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 22:02:46 +0530 Subject: [PATCH 115/227] #3087: report a Photoshop layer item as big as neither its layer nor the document Such an item is shown on the document, which the size does not confirm; it is now reported in unsupported_items, when the opti stores the document's size. --- crates/aep/src/import/build.rs | 9 ++++++++- crates/aep/tests/it/import/synthetic.rs | 17 ++++++++++------- docs/aep-import.md | 4 ++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 2804d365b..f6ac69cda 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -353,7 +353,8 @@ fn is_placed_page_path(path: &str) -> bool { /// The Photoshop layer a footage item shows (#3087): at its own bounds when the item is as big /// as they are (Retain Layer Sizes), else on the document. A layer without pixels (an adjustment /// layer) keeps the merged image and is reported. So is a layer as big as the document but not at -/// its corner: the item's size fits both framings, and its bounds are used. +/// its corner (the item's size fits both framings; its bounds are used), and an item as big as +/// neither (the document is used). fn source_layer(f: &crate::Footage, name: &str, col: &mut Collector) -> Option { let l = f.psd_layer.as_ref()?; let Some(size) = l.size() else { @@ -361,6 +362,12 @@ fn source_layer(f: &crate::Footage, name: &str, col: &mut Collector) -> Option 0) && !l.document_is((f.width, f.height)) { + col.lose( + LossCategory::UnsupportedItems, + format!("footage '{name}': {} x {} is neither the Photoshop layer's size nor the document's, shown on the document", f.width, f.height), + ); + } let [top, left, ..] = l.bounds; if layer_size && (top, left) != (0, 0) && l.document_is(size) { col.lose( diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 0c00088a5..d1c030401 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -899,19 +899,22 @@ fn a_photoshop_layer_item_shows_its_layer() { } /// A layer as big as the document but off its corner fits both framings: shown at its own bounds -/// and reported. One at the corner, or bigger than the document, reads one way and is not. +/// and reported. One at the corner, or bigger than the document, reads one way and is not. An item +/// as big as neither is shown on the document and reported. #[test] fn a_photoshop_layer_as_big_as_the_document_off_its_corner_is_reported() { let framing = |bounds, size| { let (p, r) = run(&as_psd_layer(3, bounds, "Moved", size), true); let found = losses(&r.unwrap(), LossCategory::UnsupportedItems); - (the_footage(&p).layer.map(|l| l.layer_size), found.iter().any(|d| d.contains("'Moved/art.psd'") && d.contains("as big as the document"))) + let said = |what: &str| found.iter().any(|d| d.contains("'Moved/art.psd'") && d.contains(what)); + (the_footage(&p).layer.map(|l| l.layer_size), said("as big as the document"), said("neither the Photoshop layer's size")) }; - assert_eq!(framing([5, 10, 1005, 1010], (1000, 1000)), (Some(true), true)); - assert_eq!(framing([0, 0, 1000, 1000], (1000, 1000)), (Some(true), false)); - assert_eq!(framing([9, -346, 1083, 1086], (1432, 1074)), (Some(true), false)); - // An item of another size frames the layer on the document: no ambiguity. - assert_eq!(framing([5, 10, 1005, 1010], (800, 800)), (Some(false), false)); + assert_eq!(framing([5, 10, 1005, 1010], (1000, 1000)), (Some(true), true, false)); + assert_eq!(framing([0, 0, 1000, 1000], (1000, 1000)), (Some(true), false, false)); + assert_eq!(framing([9, -346, 1083, 1086], (1432, 1074)), (Some(true), false, false)); + assert_eq!(framing([749, 800, 764, 848], (1000, 1000)), (Some(false), false, false)); + // An item as big as neither the layer nor the document: shown on the document, reported. + assert_eq!(framing([5, 10, 1005, 1010], (800, 800)), (Some(false), false, true)); } /// #3087: a Photoshop layer without pixels (an adjustment layer) keeps the merged image and is diff --git a/docs/aep-import.md b/docs/aep-import.md index 9f9f8b2b0..1adc88153 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -70,7 +70,7 @@ outside the sampled ones keeps the current value and is reported as `project set | Shape layers | groups (nested, with anchor, position, scale, skew, skew axis, rotation, opacity), rectangle (size, position, roundness), ellipse (size, position), polystar (star or polygon, points, position, rotation, radii, roundness), bezier path (vertices and tangents in layer pixels, path keys), fill (colour, opacity, fill rule), stroke (colour, opacity, width, cap, join, miter limit), gradient fill and stroke (linear or radial, start and end point; the default white-to-black colours), trim paths (start, end, offset, trim multiple shapes), round corners (radius), merge paths (mode; 707, 708), offset paths (amount, line join, miter limit, copies, copy offset; 710; After Effects offsets copy *i* by Amount x (*i* + Copy Offset), default 1, EffectCraft by Amount x (*i* + 1 + copyOffset), so the copy offset arrives 1 less, also its keys; an expression on it is kept and reported; 826-828, 828 renders equal to After Effects on every grid point), repeater (copies, offset, composite, transform; 709), in file order, with keyframes and expressions, the operators' defaults as After Effects reads them back (731); the layer anchor in pixels; 238-242, 330-333, 451-484, 519, 705-710, 731 | | Markers | layer and composition markers: time (layer time for layer markers, exact frames), duration (1/600 s units, 509, 510), comment, chapter, URL, frame target, label, cue point (name, event or navigation, parameters); 122, 123, 249, 347-349, 509, 510 | | Time Remapping | an enabled remap becomes the `timeRemap` property (source seconds, keys in layer time with their interpolation and ease, expression), first in the layer's properties as Layer > Time > Enable Time Remapping puts it; 319, 354 | -| Footage | a link to the stored path; **missing-footage placeholder** when the path does not exist where the import runs (listed in `missingFootage`), a normal link when it does. The importer never opens footage. **An image sequence** (its alias is the folder) becomes a sequence of its files, named from the stored numbering (`shot_0101.exr` .. `shot_0103.exr` in 603, `seq_001.png` .. in 353), at its native frame rate; the first file is the footage's path and the one checked. Sequences and clips take the stored native frame rate and duration (603, 606). A sequence of more than 100 000 frames, or beyond a million file names in one import, links only its first file and is reported. **One layer of a Photoshop file** shows that layer (`Footage::layer`: its index and name; at its own size with Retain Layer Sizes, else on the document) and is named `/` unless the item has a name; a layer without pixels (an adjustment layer) keeps the merged image and is reported; so is a layer as big as the document but not at its corner, which the item's size does not tell apart (shown at its own size) | +| Footage | a link to the stored path; **missing-footage placeholder** when the path does not exist where the import runs (listed in `missingFootage`), a normal link when it does. The importer never opens footage. **An image sequence** (its alias is the folder) becomes a sequence of its files, named from the stored numbering (`shot_0101.exr` .. `shot_0103.exr` in 603, `seq_001.png` .. in 353), at its native frame rate; the first file is the footage's path and the one checked. Sequences and clips take the stored native frame rate and duration (603, 606). A sequence of more than 100 000 frames, or beyond a million file names in one import, links only its first file and is reported. **One layer of a Photoshop file** shows that layer (`Footage::layer`: its index and name; at its own size with Retain Layer Sizes, else on the document) and is named `/` unless the item has a name; a layer without pixels (an adjustment layer) keeps the merged image and is reported; so is a layer as big as the document but not at its corner, which the item's size does not tell apart (shown at its own size), and an item as big as neither the layer nor the document (shown on the document) | Units, the "absent = default" rule and the keyframe templates are the reader's ([aep-format.md](aep-format.md), sections 3.3–3.6). The importer converts them to EffectCraft's: @@ -345,7 +345,7 @@ for the `unlisted` ones. The categories: | `parent_links` | a parent that was not imported (a skipped camera, a missing layer) or that forms a loop | | `track_mattes` | a track matte of unknown mode, whose matte layer was not imported or does not exist, a layer matted by itself, or a matte of the older "layer above" form on the top layer | | `unsupported_layers` | an unknown layer type, a layer without a usable source, a precomposition loop (the layer is cut) | -| `unsupported_items` | a project item of an unknown kind; an item made from a Photoshop layer without pixels (it shows the merged image), or from one as big as the document but not at its corner (shown at its own size) | +| `unsupported_items` | a project item of an unknown kind; an item made from a Photoshop layer without pixels (it shows the merged image), or from one as big as the document but not at its corner (shown at its own size), or an item as big as neither the layer nor the document (shown on the document) | | `damaged_data` | a header, key or value that could not be read, numbers that are not finite or beyond ±1e9 (default used); an unreadable text document (the text layer is imported with empty text), damaged style runs | `limitations` states what the reader cannot see at all, so it can be neither imported nor counted: From aa080558dfa69ee64e9b2af75d5486aeebc68138 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 12:10:57 -0500 Subject: [PATCH 116/227] ci: run Harden-Runner on every job (#3508) Adds step-security/harden-runner (audit, pinned to v2.22.4 like craft-common-actions) as the first step of each job that did not have it, so every workflow in the org's public repos reports its egress to StepSecurity. Audit, not block: block-mode allow lists come from each job's observed traffic. --- .github/workflows/packaging-lint.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index b903f82ea..ccf74f43a 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -28,6 +28,10 @@ jobs: name: Windows installation policy (no app build) runs-on: windows-latest steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false From dfbd4a8e82bb653cdd101ed4e1a17f8cf3bfc46c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 12:44:05 -0500 Subject: [PATCH 117/227] Updates: take craft-update from craft-libs main at 6e03f65bf --- Cargo.lock | 3 ++- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a69346b9f..eadbdc8fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1184,7 +1184,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "chrono", "dunce", @@ -1194,6 +1194,7 @@ dependencies = [ "image", "log", "plist", + "rustix 1.1.5", "semver", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 18a92e409..6c12d867d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "ca1b5e2edb8568335c6c14f390307bd5beae8238" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } # internal effectcraft-time = { path = "crates/time" } effectcraft-geom = { path = "crates/geom" } From f0e49012dc634232565955daba4c3bd19ad225dc Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:35:19 -0500 Subject: [PATCH 118/227] Tests: oracle and fixture files go in an exclusively owned temporary directory (#3497) --- Cargo.lock | 3 +++ crates/av1enc/Cargo.toml | 1 + crates/av1enc/tests/it/ffmpeg_oracle.rs | 4 ++-- crates/export/Cargo.toml | 1 + crates/export/tests/it/opus_roundtrip.rs | 3 ++- crates/export/tests/it/render_settings.rs | 4 ++-- crates/export/tests/it/vp9_roundtrip.rs | 8 ++++---- crates/host/tests/it/plugins.rs | 4 ++-- crates/media/Cargo.toml | 1 + crates/media/tests/it/footage.rs | 4 ++-- 10 files changed, 20 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eadbdc8fd..991b06154 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1480,6 +1480,7 @@ name = "effectcraft-av1enc" version = "0.24.0" dependencies = [ "filmcraft-av1", + "tempfile", ] [[package]] @@ -1593,6 +1594,7 @@ dependencies = [ "image", "log", "rayon", + "tempfile", "thiserror 2.0.21", "web-time", ] @@ -1769,6 +1771,7 @@ dependencies = [ "log", "rayon", "serde_json", + "tempfile", "thiserror 2.0.21", ] diff --git a/crates/av1enc/Cargo.toml b/crates/av1enc/Cargo.toml index 2a4c7ddfe..c70b885ce 100644 --- a/crates/av1enc/Cargo.toml +++ b/crates/av1enc/Cargo.toml @@ -12,4 +12,5 @@ workspace = true [dependencies] [dev-dependencies] +tempfile = { workspace = true } filmcraft-av1 = { workspace = true } diff --git a/crates/av1enc/tests/it/ffmpeg_oracle.rs b/crates/av1enc/tests/it/ffmpeg_oracle.rs index b35cbf496..ba206a680 100644 --- a/crates/av1enc/tests/it/ffmpeg_oracle.rs +++ b/crates/av1enc/tests/it/ffmpeg_oracle.rs @@ -57,8 +57,8 @@ fn check(name: &str, cfg: EncoderConfig, pics: &[Pic]) -> Vec { recons.push(enc.last_reconstruction().expect("reconstruction")); levels.push(enc.last_loop_filter_level().expect("level")); } - let dir = std::env::temp_dir().join(format!("effectcraft-av1enc-{}-{name}", std::process::id())); - std::fs::create_dir_all(&dir).expect("temp dir"); + let guard = tempfile::tempdir().expect("temp dir"); + let dir = guard.path().to_path_buf(); let input = dir.join("in.ivf"); let output = dir.join("out.yuv"); std::fs::write(&input, ivf(w, h, &packets)).expect("write ivf"); diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index c477d2787..e0be45551 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -31,6 +31,7 @@ thiserror = { workspace = true } log = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } effectcraft-media = { workspace = true } effectcraft-effects = { workspace = true } effectcraft-color = { workspace = true } diff --git a/crates/export/tests/it/opus_roundtrip.rs b/crates/export/tests/it/opus_roundtrip.rs index 3cd1edeee..4dfbc32ff 100644 --- a/crates/export/tests/it/opus_roundtrip.rs +++ b/crates/export/tests/it/opus_roundtrip.rs @@ -342,7 +342,8 @@ fn ffmpeg_oracle_decodes_ogg_opus() { } } let (enc, packets) = encode(&pcm, channels, rate); - let path = std::env::temp_dir().join(format!("effectcraft-opusenc-{}-{channels}.opus", std::process::id())); + let guard = tempfile::tempdir().unwrap(); + let path = guard.path().join("out.opus"); std::fs::write(&path, ogg_opus(&enc, &packets, len)).unwrap(); let out = Command::new("ffmpeg") .args(["-v", "error", "-i"]) diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index 39a7e88ff..99e3d9c19 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -389,8 +389,8 @@ fn wav_and_aiff_channels_and_formats() { #[test] fn cancelled_export_removes_only_files_it_opened() { use effectcraft_export::ExportError; - let dir = std::env::temp_dir().join(format!("ec-cancel-owned-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); struct Overflow; impl StorageQuota for Overflow { diff --git a/crates/export/tests/it/vp9_roundtrip.rs b/crates/export/tests/it/vp9_roundtrip.rs index 1432e3892..c1c6c142c 100644 --- a/crates/export/tests/it/vp9_roundtrip.rs +++ b/crates/export/tests/it/vp9_roundtrip.rs @@ -130,8 +130,8 @@ fn ffmpeg_decodes_our_stream() { ivf.extend_from_slice(&(frame.len() as u32).to_le_bytes()); ivf.extend_from_slice(&0u64.to_le_bytes()); ivf.extend_from_slice(&frame); - let dir = std::env::temp_dir().join(format!("effectcraft-vp9-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); let path = dir.join("a.ivf"); std::fs::write(&path, &ivf).unwrap(); let out = std::process::Command::new("ffmpeg") @@ -291,8 +291,8 @@ fn ffmpeg_matches(w: usize, h: usize, frames: &[Vec], recon: &[[Vec; 3]] ivf.extend_from_slice(&(t as u64).to_le_bytes()); ivf.extend_from_slice(f); } - let dir = std::env::temp_dir().join(format!("effectcraft-vp9-{name}-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let guard = tempfile::Builder::new().prefix(&format!("effectcraft-vp9-{name}-")).tempdir().unwrap(); + let dir = guard.path().to_path_buf(); let path = dir.join("a.ivf"); std::fs::write(&path, &ivf).unwrap(); let out = std::process::Command::new("ffmpeg") diff --git a/crates/host/tests/it/plugins.rs b/crates/host/tests/it/plugins.rs index 33748d366..a5fc0b8e1 100644 --- a/crates/host/tests/it/plugins.rs +++ b/crates/host/tests/it/plugins.rs @@ -276,8 +276,8 @@ pub(super) fn wat_plugin(id: &str, api: i32, render: &str) -> String { #[test] fn plugin_loading_reports_bad_modules_and_survives_failing_renders() { - let dir = std::env::temp_dir().join(format!("effectcraft-host-plugins-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); let files = [ ("a-good.wasm", wat_plugin("org.test.host.good", 1, "i32.const 0").into_bytes()), ("b-garbage.wasm", b"\0asm\x01\0\0\0garbage".to_vec()), diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 4947d928a..2fa54e0eb 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -38,5 +38,6 @@ thiserror = { workspace = true } log = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } # (tests render comps that read layers through effects; already a dependency of effectcraft-render) effectcraft-effects = { workspace = true } diff --git a/crates/media/tests/it/footage.rs b/crates/media/tests/it/footage.rs index 7f0de6ae4..ed152541c 100644 --- a/crates/media/tests/it/footage.rs +++ b/crates/media/tests/it/footage.rs @@ -747,8 +747,8 @@ fn sequence_gaps_hold_the_previous_frame() { /// parallax project's 21120-px plates were decoded and resampled for every frame at Quarter). #[test] fn stills_at_reduced_sizes_are_resampled_once() { - let dir = std::env::temp_dir().join(format!("ec-still-size-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("temp dir"); + let guard = tempfile::tempdir().expect("temp dir"); + let dir = guard.path().to_path_buf(); let p = dir.join("plate.png"); let px = |x: u32, y: u32| { image::Rgba([(x * 7 % 256) as u8, (y * 13 % 256) as u8, ((x ^ y) % 256) as u8, if (x + y).is_multiple_of(5) { 0 } else { 255 - (x % 128) as u8 }]) From f9111a0d8f9c41c5c965ebc2e7ed2ba684f0dd6a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:35:34 -0500 Subject: [PATCH 119/227] Vector import: split a document at its images in one traversal (#3514) --- crates/engine/src/interchange/vector.rs | 124 ++++++++++++++++++++---- 1 file changed, 105 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/interchange/vector.rs b/crates/engine/src/interchange/vector.rs index b52517cb3..569e8e5bc 100644 --- a/crates/engine/src/interchange/vector.rs +++ b/crates/engine/src/interchange/vector.rs @@ -477,35 +477,51 @@ pub fn split_at_images(doc: &Doc) -> Vec { }) .sum() } - /// Keep the shapes between images `seg − 1` and `seg`; `seen` counts images passed. - fn prune(g: &effectcraft_svg::Group, seg: usize, seen: &mut usize) -> effectcraft_svg::Group { - let mut out = g.clone(); - out.children = vec![]; + /// `g` without its children, holding `children` instead (nothing below `g` is copied). + fn shell(g: &effectcraft_svg::Group, children: Vec) -> effectcraft_svg::Group { + effectcraft_svg::Group { + name: g.name.clone(), + transform: g.transform, + opacity: g.opacity, + children, + clip: g.clip.clone(), + blend: g.blend, + mask: g.mask.clone(), + isolated: g.isolated, + knockout: g.knockout, + } + } + /// One traversal for every segment: the pieces of `g` as (segment, group), by ascending + /// segment; `seen` counts images passed. Each shape is copied once, into its own segment. + fn split(g: &effectcraft_svg::Group, seen: &mut usize) -> Vec<(usize, effectcraft_svg::Group)> { + let mut by: Vec<(usize, Vec)> = vec![]; + let mut put = |seg: usize, node: Node| match by.last_mut() { + Some(last) if last.0 == seg => last.1.push(node), + _ => by.push((seg, vec![node])), + }; for c in &g.children { match c { Node::Image(_) => *seen += 1, - Node::Shape(_) => { - if *seen == seg { - out.children.push(c.clone()); - } - } + Node::Shape(_) => put(*seen, c.clone()), Node::Group(s) => { - let p = prune(s, seg, seen); - if !p.children.is_empty() { - out.children.push(Node::Group(p)); + for (seg, part) in split(s, seen) { + put(seg, Node::Group(part)); } } } } - out + by.into_iter().map(|(seg, children)| (seg, shell(g, children))).collect() } let n = count(&doc.root); - (0..=n) - .map(|seg| { - let mut d = doc.clone(); - d.root = prune(&doc.root, seg, &mut 0); - d - }) + let mut roots: Vec> = vec![None; n + 1]; + for (seg, part) in split(&doc.root, &mut 0) { + if let Some(slot) = roots.get_mut(seg) { + *slot = Some(part); + } + } + roots + .into_iter() + .map(|root| Doc { width: doc.width, height: doc.height, root: root.unwrap_or_else(|| shell(&doc.root, vec![])), skipped: doc.skipped.clone() }) .collect() } @@ -655,3 +671,73 @@ pub fn masks_layer(proj: &mut Project, comp: &Comp, name: &str, outlines: Vec Node { + let geom = Geom::Rect { x: 0.0, y: 0.0, w: 1.0, h: 1.0, rx: 0.0, ry: 0.0 }; + Node::Shape(Shape { + name: name.into(), + geom, + transform: Affine::IDENTITY, + fill: None, + fill_opacity: 1.0, + fill_rule: effectcraft_svg::FillRule::NonZero, + stroke: None, + opacity: 1.0, + }) + } + fn image() -> Node { + Node::Image(Image { name: "i".into(), transform: Affine::IDENTITY, width: 1, height: 1, rgba: std::sync::Arc::new(vec![0; 4]), opacity: 1.0 }) + } + fn group(name: &str, children: Vec) -> Node { + let mut g = Group::new(name); + g.children = children; + Node::Group(g) + } + fn names(g: &Group, out: &mut Vec) { + for c in &g.children { + match c { + Node::Shape(s) => out.push(s.name.clone()), + Node::Group(s) => { + out.push(format!("<{}", s.name)); + names(s, out); + out.push(format!("{}>", s.name)); + } + Node::Image(_) => out.push("IMG".into()), + } + } + } + + /// Segment `k` keeps the shapes between image `k - 1` and `k`, with the groups that hold them. + #[test] + fn split_keeps_each_segments_shapes_and_their_groups() { + let mut root = Group::new("root"); + root.children = vec![ + shape("a"), + image(), + group("g1", vec![shape("b"), image(), shape("c"), group("g2", vec![shape("d"), image(), shape("e")])]), + image(), + group("empty", vec![image()]), + shape("f"), + ]; + let doc = Doc { width: 10.0, height: 10.0, root, skipped: vec![] }; + let segs = split_at_images(&doc); + assert_eq!(segs.len(), 6); + let got: Vec> = segs + .iter() + .map(|d| { + let mut v = vec![]; + names(&d.root, &mut v); + v + }) + .collect(); + let want: Vec> = + vec![vec!["a"], vec![""], vec!["", "g1>"], vec!["", "g1>"], vec![], vec!["f"]]; + assert_eq!(got, want); + assert!(segs.iter().all(|d| d.root.name == "root" && d.width == 10.0)); + } +} From 6ef7a5ec6f91431cbcb3b8aef9f321cb6d696826 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:35:48 -0500 Subject: [PATCH 120/227] Variable stroke: first curve approximation scales its tolerance with the path's extent (#3510) * Variable stroke: the first curve approximation's tolerance scales with the path's extent * Variable stroke: cut only curves with huge extents, leaving small curves at the pixel tolerance --- crates/path/src/varstroke.rs | 89 +++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/crates/path/src/varstroke.rs b/crates/path/src/varstroke.rs index 61e6a1ab7..da00b02d0 100644 --- a/crates/path/src/varstroke.rs +++ b/crates/path/src/varstroke.rs @@ -220,6 +220,56 @@ fn disc(out: &mut BezPath, c: Point, r: f64) { } } +/// `path` with each curve whose extent is over 10⁷ × `tol` replaced by lines flattened at 10⁻⁷ of +/// that extent. `None` when a coordinate is not finite. +fn coarsen_huge(path: &BezPath, tol: f64) -> Option { + use kurbo::Shape as _; + let mut out = BezPath::new(); + let (mut cur, mut start) = (Point::ZERO, Point::ZERO); + for el in path.elements() { + let seg = match *el { + PathEl::MoveTo(p) => { + (cur, start) = (p, p); + out.push(*el); + continue; + } + PathEl::LineTo(p) => { + cur = p; + out.push(*el); + continue; + } + PathEl::ClosePath => { + cur = start; + out.push(*el); + continue; + } + PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => { + let mut one = BezPath::new(); + one.move_to(cur); + one.push(*el); + (one, p) + } + }; + let (one, end) = seg; + let bb = one.bounding_box(); + let extent = bb.width().max(bb.height()); + if !extent.is_finite() { + return None; + } + if extent * 1e-7 > tol { + kurbo::flatten(one.elements().iter().copied(), extent * 1e-7, |e| { + if let PathEl::LineTo(p) = e { + out.line_to(p); + } + }); + } else { + out.push(*el); + } + cur = end; + } + Some(out) +} + /// Outline of a tapered / waved stroke (local space; `res_scale` = device/local scale). pub fn outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option { if style.width <= 0.0 { @@ -230,7 +280,11 @@ pub fn outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option let half = style.width / 2.0; for path in paths { // Fine enough for the width profile: ½ device pixel, at most ~20 000 samples per path. - let rough = flatten(path, 0.1 / rs, f64::INFINITY); + // Curves with huge extents are first cut into lines at a tolerance of 10⁻⁷ of their own + // extent, so none subdivides into billions of points before the step below applies; + // every other segment (small curves in a large path included) keeps the ½-pixel tolerance. + let Some(path) = coarsen_huge(path, 0.1 / rs) else { continue }; + let rough = flatten(&path, 0.1 / rs, f64::INFINITY); for poly0 in rough { let len0 = poly0.len(); if len0 <= 0.0 { @@ -396,4 +450,37 @@ mod tests { let area: f32 = m.data.iter().sum(); assert!(area > 240.0 * 6.0 * 0.8 && area < 240.0 * 6.0 * 1.05, "{area}"); } + + /// A curve with enormous coordinates is approximated within a bounded number of points. + #[test] + fn huge_coordinates_do_not_explode_the_first_approximation() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.curve_to((1e13, 0.0), (-1e13, 1e13), (1e13, 1e13)); + let st = StrokeStyle { + width: 4.0, + taper: Some(Taper { percent: true, start_len: 50.0, end_len: 50.0, start_width: 0.0, end_width: 0.0, start_ease: 0.0, end_ease: 0.0 }), + ..Default::default() + }; + let t = std::time::Instant::now(); + let o = outline(&[p], &st, 1.0).unwrap(); + assert!(o.elements().len() < 200_000, "{}", o.elements().len()); + assert!(t.elapsed().as_secs() < 20); + } + + /// Small curves keep their shape in a path that also has a huge segment. + #[test] + fn small_curves_survive_a_huge_segment() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.curve_to((100.0, 0.0), (0.0, 100.0), (0.0, 0.0)); + p.line_to((1e10, 0.0)); + let c = coarsen_huge(&p, 0.1).unwrap(); + assert_eq!(c.elements().len(), p.elements().len(), "nothing here is huge enough to cut"); + let mut q = BezPath::new(); + q.move_to((0.0, 0.0)); + q.curve_to((1e13, 0.0), (-1e13, 1e13), (1e13, 1e13)); + let c = coarsen_huge(&q, 0.1).unwrap(); + assert!(c.elements().iter().skip(1).all(|e| matches!(e, PathEl::LineTo(_))) && c.elements().len() < 10_000); + } } From 25d9a6c05f14ed6c6233733969c0f527bb262997 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:36:02 -0500 Subject: [PATCH 121/227] Mask Interpolation: shapes past 4096 vertices skip shape contexts and search the start vertex coarsely (#3512) --- crates/path/src/interp.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/path/src/interp.rs b/crates/path/src/interp.rs index 4087b26b5..89e7edc51 100644 --- a/crates/path/src/interp.rs +++ b/crates/path/src/interp.rs @@ -311,6 +311,9 @@ fn frame(v: &[P]) -> (P, f64) { (c, r) } +/// Vertex counts above this skip the shape contexts (quadratic to build) and search the starting +/// vertex coarsely; no mask comes near it (the Add Vertices option itself stops at 2000). +const MAX_CONTEXT_VERTICES: usize = 4096; const RBINS: usize = 5; const ABINS: usize = 12; @@ -412,10 +415,18 @@ pub fn correspond(a: &ShapePath, b: &ShapePath, o: &InterpOpts) -> (ShapePath, S } if !o.first_vertices_match { let n = ra.len(); - let use_sc = o.quality > 0.0; + let large = n > MAX_CONTEXT_VERTICES; + let use_sc = o.quality > 0.0 && !large; let (sa, sb) = if use_sc { (Some(shape_contexts(&ra.vertices)), Some(shape_contexts(&rb.vertices))) } else { (None, None) }; // Quality limits the offsets examined exhaustively (coarse step), then refines. - let step = if o.quality >= 50.0 { 1 } else { ((n as f64 / 24.0).ceil() as usize).max(1) }; + // (A very large shape tries about 3 √n offsets, not n, each costing a pass over it.) + let step = if large { + (n as f64).sqrt().ceil() as usize + } else if o.quality >= 50.0 { + 1 + } else { + ((n as f64 / 24.0).ceil() as usize).max(1) + }; let mut best = (f64::MAX, 0usize); let mut k = 0; while k < n { @@ -592,4 +603,22 @@ mod tests { let l2 = (lin.vertices[1][0] - lin.vertices[0][0]).hypot(lin.vertices[1][1] - lin.vertices[0][1]); assert!(l2 < 80.0); } + + /// A shape with tens of thousands of vertices is matched without the quadratic shape contexts + /// and exhaustive offset search. + #[test] + fn very_large_shapes_are_matched_in_bounded_time() { + let n = 30_000; + let circle = |rot: f64| -> ShapePath { + let pts: Vec

= (0..n) + .map(|i| [100.0 * (rot + i as f64 * std::f64::consts::TAU / n as f64).cos(), 100.0 * (rot + i as f64 * std::f64::consts::TAU / n as f64).sin()]) + .collect(); + ShapePath::polygon(&pts, true) + }; + let o = InterpOpts { one_to_one: true, quality: 100.0, ..Default::default() }; + let t = std::time::Instant::now(); + let (a, b) = correspond(&circle(0.0), &circle(1.0), &o); + assert_eq!((a.len(), b.len()), (n, n)); + assert!(t.elapsed().as_secs() < 30, "{:?}", t.elapsed()); + } } From 98a8d65f086537b76d9ee6ce8293f4db74f1645e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:36:17 -0500 Subject: [PATCH 122/227] Warp Stabilizer: subspace smoothing visits only the trajectories seen on each frame (#3507) --- crates/track/src/subspace.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/track/src/subspace.rs b/crates/track/src/subspace.rs index 9c7d5ceeb..d68615892 100644 --- a/crates/track/src/subspace.rs +++ b/crates/track/src/subspace.rs @@ -494,16 +494,27 @@ fn gauss(lo: usize, hi: usize, t: usize, sigma: f64) -> Vec { /// subspace dimension (9 in the paper). Frames without enough trajectories get no pairs. pub fn smooth_trajectories(tracks: &[Track2D], frames: usize, sigma: f64, rank: usize) -> Vec> { let max_r = ((3.0 * sigma).ceil() as usize).clamp(1, 30); + // The trajectories seen on each frame (in track order): a frame only ever uses those, so none + // scans the whole collection (every frame did, once per window shrink). + let mut seen: Vec> = vec![Vec::new(); frames]; + for (i, tr) in tracks.iter().enumerate() { + let (a, b) = (tr.start as usize, (tr.end() as usize).min(frames)); + for list in seen.iter_mut().take(b).skip(a) { + list.push(i as u32); + } + } (0..frames) .into_par_iter() .map(|t| { let t32 = t as u32; + let on_frame = &seen[t]; let mut r = max_r; // The window around t: shrink it until enough trajectories span it. let (lo, hi, complete) = loop { let lo = t.saturating_sub(r); let hi = (t + r).min(frames.saturating_sub(1)); - let complete: Vec<&Track2D> = tracks.iter().filter(|tr| tr.start as usize <= lo && tr.end() as usize > hi).collect(); + let complete: Vec<&Track2D> = + on_frame.iter().map(|&i| &tracks[i as usize]).filter(|tr| tr.start as usize <= lo && tr.end() as usize > hi).collect(); if complete.len() >= 8 || r <= 2 { break (lo, hi, complete); } @@ -556,7 +567,7 @@ pub fn smooth_trajectories(tracks: &[Track2D], frames: usize, sigma: f64, rank: // Incomplete trajectories seen on t: coefficients by least squares over the frames // they cover in the window. let need = (k + 3).max(m / 2); - for tr in tracks.iter().filter(|tr| tr.at(t32).is_some() && !(tr.start as usize <= lo && tr.end() as usize > hi)) { + for tr in on_frame.iter().map(|&i| &tracks[i as usize]).filter(|tr| !(tr.start as usize <= lo && tr.end() as usize > hi)) { let cols: Vec = (0..m).filter(|c| tr.at((lo + c) as u32).is_some()).collect(); if cols.len() < need { continue; @@ -601,6 +612,22 @@ pub fn smooth_trajectories(tracks: &[Track2D], frames: usize, sigma: f64, rank: mod tests { use super::*; + /// Smoothing visits only the trajectories seen on each frame: tracks that start after the clip + /// (or on other frames) change nothing and cost nothing, however many there are. + #[test] + fn smoothing_ignores_trajectories_off_the_frame() { + let frames = 60usize; + let wob = |k: usize, f: usize| [10.0 + k as f32 * 3.0 + (f as f32 * 0.2).sin() * 4.0, 20.0 + (k % 7) as f32 * 5.0 + f as f32 * 0.5]; + let mut tracks: Vec = (0..30).map(|k| Track2D { id: k as u32, start: 0, pts: (0..frames).map(|f| wob(k, f)).collect() }).collect(); + // Partial tracks: some seen on only part of the clip. + tracks.extend((0..10).map(|k| Track2D { id: 100 + k as u32, start: 5 * k as u32, pts: (5 * k..frames.min(5 * k + 30)).map(|f| wob(k, f)).collect() })); + let base = smooth_trajectories(&tracks, frames, 3.0, 9); + assert!(base.iter().any(|v| v.len() >= 30), "the fixture gives frames with pairs"); + let mut more = tracks.clone(); + more.extend((0..200_000u32).map(|i| Track2D { id: 1000 + i, start: frames as u32 + i % 50, pts: vec![[1.0, 2.0]] })); + assert_eq!(smooth_trajectories(&more, frames, 3.0, 9), base); + } + #[test] fn mesh_identity_sample_and_forward() { let m = Mesh::identity(4, 3, [100.0, 60.0]); From 5d2524c00bcdae10ffcac8122b333b260ea4649f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:36:31 -0500 Subject: [PATCH 123/227] Logging: continue a message's line breaks on indented lines (#3009) * fix(logging): continue a message's line breaks on indented lines * address review: normalize CRLF before stripping the trailing newline --- crates/engine/src/system/logging.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/system/logging.rs b/crates/engine/src/system/logging.rs index 2bb3518a1..da9d0f4f6 100644 --- a/crates/engine/src/system/logging.rs +++ b/crates/engine/src/system/logging.rs @@ -125,7 +125,13 @@ fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) { /// One log line (without the newline): ` [] : `. fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { - let message = message.strip_suffix('\n').unwrap_or(message); + // CRLF becomes LF first, so a message ending in "\r\n" loses its final break like one ending + // in "\n" (and its carriage return is not taken for a lone one). + let message = message.replace("\r\n", "\n"); + let message = message.strip_suffix('\n').unwrap_or(&message); + // A message's own line breaks (a path or name from a project or a script) continue it on + // indented lines, so they can't pass for records of their own. + let message = message.replace('\r', "\\r").replace('\n', "\n\t"); format!("{ts} {level:<5} [{thread}] {target}: {message}") } @@ -523,4 +529,22 @@ mod tests { "2026-10-08T07:59:17.728Z WARN [main] effectcraft_engine::autosave: disk full" ); } + + #[test] + fn line_breaks_in_a_message_continue_it_on_indented_lines() { + let forged = "cache at /tmp/x\n2026-10-08T07:59:18.000Z ERROR [main] effectcraft_engine: forged\rend"; + let line = format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "t", forged); + assert_eq!(line.lines().count(), 2); + assert!(line.lines().nth(1).unwrap().starts_with('\t'), "{line}"); + assert!(!line.contains('\r'), "{line}"); + } + + #[test] + fn a_trailing_crlf_is_one_line_break_not_a_lone_carriage_return() { + let line = |m| format_line("t", log::Level::Info, "main", "t", m); + assert_eq!(line("cache\r\n"), line("cache")); + assert_eq!(line("a\r\nb\r\n"), line("a\nb")); + // A lone one, even at the end, is still escaped. + assert!(line("cache\r").ends_with("cache\\r")); + } } From e18f48fbf42e4b6db99d921ad39f7d80d9b33aab Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:36:44 -0500 Subject: [PATCH 124/227] fix(lottie): import indexes precomp sizes and layers, borrows shape groups, bounds font names and warnings (#3007) --- crates/lottie/src/anim.rs | 9 +++++-- crates/lottie/src/import.rs | 50 ++++++++++++++++++++++--------------- crates/lottie/src/shapes.rs | 9 ++++--- crates/lottie/src/tests.rs | 47 ++++++++++++++++++++++++++++++++++ crates/lottie/src/text.rs | 3 ++- 5 files changed, 91 insertions(+), 27 deletions(-) diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs index 5b6dabf50..40a56a7eb 100644 --- a/crates/lottie/src/anim.rs +++ b/crates/lottie/src/anim.rs @@ -21,6 +21,9 @@ use serde_json::{Value as Json, json}; use crate::Timebase; +/// Most distinct warnings an import or export reports. +const MAX_WARNINGS: usize = 200; + /// Export state shared by the whole document. pub(crate) struct Ex { pub tb: Timebase, @@ -31,7 +34,8 @@ pub(crate) struct Ex { impl Ex { pub fn warn(&mut self, w: impl Into) { let w = w.into(); - if !self.warnings.contains(&w) { + // Each warning is looked for among the earlier ones: past a screenful, more add nothing. + if self.warnings.len() < MAX_WARNINGS && !self.warnings.contains(&w) { self.warnings.push(w); } } @@ -46,7 +50,8 @@ pub(crate) struct Im { impl Im { pub fn warn(&mut self, w: impl Into) { let w = w.into(); - if !self.warnings.contains(&w) { + // Each warning is looked for among the earlier ones: past a screenful, more add nothing. + if self.warnings.len() < MAX_WARNINGS && !self.warnings.contains(&w) { self.warnings.push(w); } } diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 57ef6427a..1e1430e12 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -1,6 +1,6 @@ //! Lottie document → EffectCraft compositions (the inverse of [`crate::export`]). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use effectcraft_color::Label; @@ -17,6 +17,9 @@ use crate::anim::{self, Im, import_prop}; use crate::export::{fx_by_id, fx_by_type, fx_params, mask_mode_from}; use crate::{LottieError, Timebase, shapes, text}; +/// Longest font family or style name kept (each text key using the font holds a copy). +const MAX_FONT_NAME_CHARS: usize = 256; + /// An embedded image asset handed to the caller to store (the returned path becomes the /// footage item's file). #[derive(Clone, Debug)] @@ -129,9 +132,12 @@ pub fn import( Ctx { im: Im { tb, warnings: vec![] }, assets: BTreeMap::new(), fonts: vec![], store, base_dir, items: vec![], folder: None, name: name.clone() }; if let Some(list) = doc.get("fonts").and_then(|f| f.get("list")).and_then(Json::as_array) { for fo in list { + // Every text key referring to the font copies its family and style: keep them to + // a font name's length. + let short = |v: &str| v.chars().take(MAX_FONT_NAME_CHARS).collect::(); let n = s(fo, "fName").unwrap_or("").to_string(); - let fam = s(fo, "fFamily").unwrap_or(&n).to_string(); - let st = s(fo, "fStyle").unwrap_or("Regular").to_string(); + let fam = short(s(fo, "fFamily").unwrap_or(&n)); + let st = short(s(fo, "fStyle").unwrap_or("Regular")); cx.fonts.push((n, fam, st)); } } @@ -150,11 +156,12 @@ pub fn import( let duration = tb.tick(op); // Pass 1: asset items (precomps empty for now, images stored). let assets = doc.get("assets").and_then(Json::as_array).cloned().unwrap_or_default(); + let layer_sizes = precomp_layer_sizes(&doc, &assets); for a in &assets { let Some(id) = s(a, "id") else { continue }; if a.get("layers").is_some() { let folder = cx.folder(project); - let layer = precomp_layer_size(&doc, &assets, id); + let layer = layer_sizes.get(id).copied(); let aw = f(a, "w").map(|v| v as u32).or(layer.map(|l| l.0)).unwrap_or(w); let ah = f(a, "h").map(|v| v as u32).or(layer.map(|l| l.1)).unwrap_or(h); let arate = f(a, "fr").map(FrameRate::from_f64).unwrap_or(rate); @@ -255,17 +262,20 @@ pub fn import( Ok(ImportResult { comp: id, items: cx.items, warnings: cx.im.warnings }) } -/// The `w`/`h` of the first precomp layer, in the main comp or in any asset, that shows asset `id`. -/// lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its clipping rect. -fn precomp_layer_size(doc: &Json, assets: &[Json], id: &str) -> Option<(u32, u32)> { +/// By asset id, the `w`/`h` of the first precomp layer, in the main comp or in any asset, that +/// shows it. lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its +/// clipping rect. One pass over all layers, not one per asset. +fn precomp_layer_sizes<'a>(doc: &'a Json, assets: &'a [Json]) -> HashMap<&'a str, (u32, u32)> { + let mut out = HashMap::new(); let lists = doc.get("layers").into_iter().chain(assets.iter().filter_map(|a| a.get("layers"))); - lists.filter_map(Json::as_array).flatten().find_map(|l| { - if l.get("ty").and_then(Json::as_u64) != Some(0) || s(l, "refId") != Some(id) { - return None; + for l in lists.filter_map(Json::as_array).flatten() { + if l.get("ty").and_then(Json::as_u64) != Some(0) { + continue; } - let (lw, lh) = f(l, "w").zip(f(l, "h"))?; - Some((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32)) - }) + let (Some(id), Some((lw, lh))) = (s(l, "refId"), f(l, "w").zip(f(l, "h"))) else { continue }; + out.entry(id).or_insert((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32)); + } + out } fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) { @@ -411,8 +421,8 @@ fn layer_source(cx: &mut Ctx, project: &mut Project, comp: &Comp, j: &Json) -> O fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[Json]) { let tb = cx.im.tb; let mut by_ind: BTreeMap = BTreeMap::new(); - // (layer id, parent ind, matte: (kind, explicit tp ind), json position) - let mut pending: Vec<(LayerId, Option, Option<(MatteKind, Option)>, usize)> = vec![]; + // (layer id, its index in `comp.layers`, parent ind, matte: (kind, explicit tp ind), json position) + let mut pending: Vec<(LayerId, usize, Option, Option<(MatteKind, Option)>, usize)> = vec![]; let mut pos_ind: Vec> = vec![]; for (ji, j) in layers.iter().enumerate() { let ind = j.get("ind").and_then(Json::as_i64); @@ -463,8 +473,8 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ let mut ids = Ids(&mut next); match &source { LayerSource::Shape => { - let items = j.get("shapes").and_then(Json::as_array).cloned().unwrap_or_default(); - let groups = shapes::import_items(&mut cx.im, &mut ids, &items); + let mut items = j.get("shapes").and_then(Json::as_array).into_iter().flatten(); + let groups = shapes::import_items(&mut cx.im, &mut ids, &mut items); if let Some(c) = l.props.sub_mut("contents") { c.children.extend(groups.into_iter().map(Node::Group)); } @@ -507,10 +517,10 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ _ => None, } .map(|k| (k, j.get("tp").and_then(Json::as_i64))); - pending.push((l.id, parent, matte, ji)); + pending.push((l.id, comp.layers.len(), parent, matte, ji)); comp.layers.push(l); } - for (id, parent, matte, ji) in pending { + for (id, at, parent, matte, ji) in pending { let parent_id = parent.and_then(|p| by_ind.get(&p).copied()).filter(|p| *p != id); let matte_id = matte.and_then(|(k, tp)| { let target = match tp { @@ -520,7 +530,7 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ }; target.filter(|t| *t != id).map(|t| TrackMatte { layer: t, kind: k }) }); - if let Some(l) = comp.layer_mut(id) { + if let Some(l) = comp.layers.get_mut(at) { l.parent = parent_id; l.track_matte = matte_id; } diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index fab8d3646..6cd1f09cc 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -436,7 +436,7 @@ fn import_shape_transform(im: &Im, j: &Json, tr: &mut PropGroup) { } /// Import Lottie shape items into shape contents groups. -pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec { +pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &mut dyn Iterator) -> Vec { let mut out = vec![]; for j in items { let ty = j.get("ty").and_then(Json::as_str).unwrap_or(""); @@ -447,9 +447,10 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec { - let list = j.get("it").and_then(Json::as_array).cloned().unwrap_or_default(); - let inner: Vec = list.iter().filter(|i| i.get("ty").and_then(Json::as_str) != Some("tr")).cloned().collect(); - let children = import_items(im, ids, &inner); + // Borrowed, not cloned: each nesting level copying its subtree costs memory + // quadratic in the depth. + let list = j.get("it").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default(); + let children = import_items(im, ids, &mut list.iter().filter(|i| i.get("ty").and_then(Json::as_str) != Some("tr"))); let mut g = build::shape_group(ids, "Group 1", children); if let Some(tr) = list.iter().find(|i| i.get("ty").and_then(Json::as_str) == Some("tr")) && let Some(t) = g.sub_mut("transform") diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index b941b4f61..8656852de 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -997,6 +997,53 @@ fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() { assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); } +#[test] +fn many_layers_and_precomps_import_with_their_parents_and_sizes() { + // 5 000 null layers each parented to the previous, and 2 000 empty precomp assets: the + // relationship and size passes index the layers instead of searching them per layer. (Each + // has its own name: `Comp::unique_layer_name` numbering one repeated name is a separate cost.) + let n = 5_000; + let layers: Vec = (1..=n).map(|i| json!({"ty": 3, "nm": format!("N{i}"), "ind": i, "parent": i - 1, "ip": 0, "op": 30, "st": 0, "ks": {}})).collect(); + let mut assets: Vec = (0..2_000).map(|i| json!({"id": format!("c{i}"), "layers": []})).collect(); + assets.push(json!({"id": "last", "layers": []})); + let mut layers = layers; + layers.push(json!({"ty": 0, "ind": n + 1, "refId": "last", "w": 50, "h": 40, "ip": 0, "op": 30, "st": 0, "ks": {}})); + let doc = json!({"w": 200, "h": 120, "fr": 30, "ip": 0, "op": 30, "assets": assets, "layers": layers}); + let mut q = Project::default(); + let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp; + let c = q.comp(nc).unwrap(); + assert_eq!(c.layers.len(), n as usize + 1); + assert_eq!(c.layers.get(0).unwrap().parent, None); + assert_eq!(c.layers.get(1).unwrap().parent, Some(c.layers.get(0).unwrap().id)); + let LayerSource::Comp { item } = c.layers.get(n as usize).unwrap().source else { panic!("not a precomp") }; + let pc = q.comp(item).unwrap(); + assert_eq!((pc.width, pc.height), (50, 40)); +} + +#[test] +fn deeply_nested_shape_groups_and_long_font_names_import() { + let mut g = json!({"ty": "rc", "s": {"a": 0, "k": [10, 10]}, "p": {"a": 0, "k": [0, 0]}, "r": {"a": 0, "k": 0}}); + // 50 levels: each is two levels of JSON, within serde_json's recursion limit of 128. + for _ in 0..50 { + g = json!({"ty": "gr", "it": [g, {"ty": "tr", "o": {"a": 0, "k": 100}}]}); + } + let family = "F".repeat(100_000); + let keys: Vec = (0..50).map(|i| json!({"t": i, "s": {"t": "x", "f": "A", "s": 10}})).collect(); + let doc = json!({"w": 200, "h": 120, "fr": 30, "ip": 0, "op": 60, + "fonts": {"list": [{"fName": "A", "fFamily": family, "fStyle": "Regular"}]}, + "layers": [ + {"ty": 4, "ind": 1, "ip": 0, "op": 60, "st": 0, "ks": {}, "shapes": [g]}, + {"ty": 5, "ind": 2, "ip": 0, "op": 60, "st": 0, "ks": {}, "t": {"d": {"k": keys}}} + ]}); + let mut q = Project::default(); + let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp; + let c = q.comp(nc).unwrap(); + let st = c.layers.get(1).unwrap().props.prop("text/sourceText").unwrap(); + assert_eq!(st.keys.len(), 50); + let Value::Text(d) = &st.keys[0].value else { panic!("not text") }; + assert_eq!(d.font.len(), 256); +} + /// A footage item called a still whose file is not an image is not embedded. #[test] fn only_image_bytes_are_embedded() { diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs index 125835c2b..4e50b461d 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -310,7 +310,8 @@ pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGro st.value = Value::Text(Box::new(first.clone())); } if docs.len() > 1 { - st.keys = docs.iter().map(|(f, d)| effectcraft_keyframe::Keyframe::new(im.tb.tick(*f), Value::Text(Box::new(d.clone()))).hold()).collect(); + // Moved into the keys, not cloned: the documents are not kept twice. + st.keys = docs.into_iter().map(|(f, d)| effectcraft_keyframe::Keyframe::new(im.tb.tick(f), Value::Text(Box::new(d))).hold()).collect(); } } if let Some(m) = t.get("m") From eece065426ec282920c63b7a6fca003040cefa02 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:36:58 -0500 Subject: [PATCH 125/227] UI: ScriptUI images decoded within a size limit; effect detection answers each comp once (#3005) * UI: ScriptUI images decoded within a size limit; effect detection answers each comp once ScriptUI decoded any image and converted it to full RGBA without a size check; images are decoded through image's limits (4096 px per side and a matching allocation cap). comp_has_effects walked a nested comp again for every layer using it; each comp is now answered once. * address review: effect detection by breadth-first search so depth cannot hide a comp --- crates/ui-egui/src/frames.rs | 30 ++++++++++---- crates/ui-egui/src/frames/tests.rs | 48 ++++++++++++++++++++++ crates/ui-egui/src/panels/scriptui_view.rs | 17 +++++++- 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 2b2b3d4b7..a0c2f94b5 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -632,14 +632,30 @@ pub trait RemoteFrames: Send + Sync { /// Whether a comp (or a comp it nests) has a layer with enabled effects. pub fn comp_has_effects(project: &Project, comp: ItemId) -> bool { - fn walk(p: &Project, comp: ItemId, depth: usize) -> bool { - let Some(c) = p.comp(comp) else { return false }; - c.layers.iter().any(|l| { - l.switches.effects && l.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled)) - || depth < 16 && matches!(l.source, effectcraft_engine::project::LayerSource::Comp { item } if walk(p, item, depth + 1)) - }) + // Breadth first with a visited set: each comp is looked at once, at its shallowest level, so a + // comp first met deep in one chain is still found by a shorter route (a result memoised by + // comp alone would depend on the depth it was first reached at). + let mut seen = std::collections::HashSet::from([comp]); + let mut level = vec![comp]; + // Comps nested up to 16 levels below the first are searched. + for _ in 0..=16 { + let mut next = vec![]; + for id in level { + let Some(c) = project.comp(id) else { continue }; + for l in &c.layers { + if l.switches.effects && l.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled)) { + return true; + } + if let effectcraft_engine::project::LayerSource::Comp { item } = l.source + && seen.insert(item) + { + next.push(item); + } + } + } + level = next; } - walk(project, comp, 0) + false } pub struct Frames { diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index baf6342e7..45398de21 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -3373,3 +3373,51 @@ fn gpu_preview_lane_waits_for_admission_and_resets_its_marker() { assert!(!DRAINING_LANE.get()); }); } + +#[test] +fn effect_detection_answers_each_comp_once() { + // Sixteen levels, each comp holding eight layers of the one below and no effects: 8^16 walks + // without a memo. Answered at once. + use effectcraft_engine::project::{Comp, ItemKind, LayerSource, build}; + use effectcraft_engine::time::{FrameRate, Tick}; + let mut p = Project::default(); + let base = Comp::new(8, 8, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + let mut below = p.add_item("C", effectcraft_engine::color::Label::Sandstone, None, ItemKind::Comp(base.clone().into())); + for _ in 0..16 { + let layers: Vec<_> = (0..8).map(|_| build::layer(&mut p, &base, "a", LayerSource::Comp { item: below }, (0, 0), None)).collect(); + let mut c = base.clone(); + c.layers = layers.into(); + below = p.add_item("C", effectcraft_engine::color::Label::Sandstone, None, ItemKind::Comp(c.into())); + } + let start = std::time::Instant::now(); + assert!(!comp_has_effects(&p, below)); + assert!(start.elapsed() < std::time::Duration::from_secs(5)); +} + +#[test] +fn an_effect_found_by_a_short_route_is_not_hidden_by_a_deep_one() { + use effectcraft_engine::Session; + use serde_json::json; + let mut s = Session::default(); + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let comp = |s: &mut Session| ItemId(id(s.execute("comp.new", json!({"name": "C", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp")); + let nest = |s: &mut Session, outer: ItemId, inner: ItemId| { + s.execute("layer.addItem", json!({"comp": outer.0, "item": inner.0, "time": 0.0})).unwrap(); + }; + // `fx` holds a layer with an effect; seventeen comps stack above it, one level too many to be found. + let fx = comp(&mut s); + let l = id(s.execute("layer.newSolid", json!({"comp": fx.0, "color": "#ff0000"})).unwrap(), "layer"); + s.execute("effect.apply", json!({"comp": fx.0, "layers": [l], "effect": "ec.vr.converter"})).unwrap(); + let mut chain = fx; + for _ in 0..17 { + let up = comp(&mut s); + nest(&mut s, up, chain); + chain = up; + } + assert!(!comp_has_effects(&s.project, chain), "too deep to be found"); + // A root that nests the chain first and `fx` directly after it. + let root = comp(&mut s); + nest(&mut s, root, chain); + nest(&mut s, root, fx); + assert!(comp_has_effects(&s.project, root)); +} diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 753b2884e..038ed5e15 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -329,7 +329,7 @@ fn image_texture(ctx: &egui::Context, img: &ImageRef, services: &dyn Services) - if let Some(cached) = ctx.data(|d| d.get_temp::>(id)) { return cached; } - let tex = img.bytes(services).and_then(|b| image::load_from_memory(&b).ok()).map(|d| { + let tex = img.bytes(services).and_then(|b| decode_ui_image(&b)).map(|d| { let rgba = d.to_rgba8(); let ci = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()); ctx.load_texture(format!("scriptui-image-{}", img.name), ci, egui::TextureOptions::LINEAR) @@ -338,6 +338,21 @@ fn image_texture(ctx: &egui::Context, img: &ImageRef, services: &dyn Services) - tex } +/// The largest side of a ScriptUI image (icons and banners are far smaller). +const MAX_UI_IMAGE_SIDE: u32 = 4096; + +/// A ScriptUI image decoded within [`MAX_UI_IMAGE_SIDE`] per side and a matching allocation +/// limit, checked from the header before the pixels (and their RGBA copy) are allocated. +fn decode_ui_image(bytes: &[u8]) -> Option { + let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_UI_IMAGE_SIDE); + limits.max_image_height = Some(MAX_UI_IMAGE_SIDE); + limits.max_alloc = Some(u64::from(MAX_UI_IMAGE_SIDE) * u64::from(MAX_UI_IMAGE_SIDE) * 16); + r.limits(limits); + r.decode().ok() +} + /// An image control's or icon button's image, fitted (aspect kept) and centred in `r`. fn paint_image(ui: &egui::Ui, r: Rect, img: &ImageRef, services: &dyn Services) -> bool { let Some(tex) = image_texture(ui.ctx(), img, services) else { return false }; From 82ea2e7c3e85400bbe63843ecc22aac78e8ee064 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:37:14 -0500 Subject: [PATCH 126/227] Project panel: draw vector footage thumbnails at the thumbnail's size (#2998) * fix(engine): draw vector footage thumbnails at the thumbnail's size * address review: shrink a one-pixel-over vector thumbnail to the requested size --- crates/engine/src/session/render.rs | 17 +++++--- crates/engine/src/tests/vector_import.rs | 55 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 2dd8a3e7f..c3740196c 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -103,15 +103,22 @@ impl Session { effectcraft_project::ItemKind::Footage(f) if f.has_video => { let f = f.clone(); Some(Box::new(move || { - 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)); + // Vector footage is drawn at the thumbnail's size: its declared size can be a + // 16384² canvas, gigabytes of pixels to point-sample down to a few dozen. + let scale = (max_side.max(1) as f64 / f.width.max(f.height).max(1) as f64).min(1.0); + let src = footage.vector_frame(item, &f, scale).or_else(|| footage.frame(item, &f, Tick(0)))?; + let img = effectcraft_render::cineon_frame(src, &f); + // Point-sample down to at most `max_side` on the long side (a drawn size one + // pixel over it, from rounding, is shrunk by that pixel, not halved). + let (long, max) = (u64::from(img.width.max(img.height)), u64::from(max_side.max(1))); + let fit = |side: u32| if long <= max { side } else { ((u64::from(side) * max + long / 2) / long).max(1) as u32 }; + let (w, h) = (fit(img.width), fit(img.height)); let full = img.to_rgba8(); let mut px = Vec::with_capacity((w * h * 4) as usize); for y in 0..h { for x in 0..w { - let i = (((y * step) * img.width + x * step) * 4) as usize; + let (sx, sy) = (u64::from(x) * u64::from(img.width) / u64::from(w), u64::from(y) * u64::from(img.height) / u64::from(h)); + let i = ((sy * u64::from(img.width) + sx) * 4) as usize; px.extend_from_slice(&full[i..i + 4]); } } diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 10c07c2ad..2f41b16b2 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -383,3 +383,58 @@ fn psd_with_damaged_layer_section_imports_the_flattened_image() { let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; assert!(f.layer.is_none(), "the merged image, not a Photoshop layer"); } + +/// A vector footage thumbnail is drawn at the thumbnail's size, never at its declared size. +#[test] +fn vector_footage_thumbnails_are_drawn_at_thumbnail_size() { + use std::sync::{Arc, Mutex}; + + use effectcraft_project::{Footage, ItemId}; + use effectcraft_raster::Image; + use effectcraft_time::Tick; + + struct Vector(Mutex>); + impl crate::render::FootageSource for Vector { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + panic!("the full-size frame was decoded"); + } + fn vector_frame(&self, _: ItemId, f: &Footage, scale: f64) -> Option> { + self.0.lock().unwrap().push(scale); + Some(Arc::new(Image::new((f.width as f64 * scale).ceil() as u32, (f.height as f64 * scale).ceil() as u32))) + } + } + let source = Arc::new(Vector(Mutex::new(vec![]))); + let mut s = Session { footage: source.clone(), ..Default::default() }; + let f = Footage { path: "/m/huge.svg".into(), codec: "SVG".into(), width: 16_000, height: 8_000, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("huge.svg", effectcraft_color::Label::Sandstone, None, ItemKind::Footage(f)); + let (w, h, _) = (s.thumbnail_job(item, Tick::ZERO, 64).unwrap())().unwrap(); + assert_eq!((w, h), (64, 32)); + assert_eq!(*source.0.lock().unwrap(), [64.0 / 16_000.0]); +} + +/// A vector footage thumbnail whose drawn size is a pixel over the requested one is shrunk by +/// that pixel, not halved by an integer step. +#[test] +fn a_vector_thumbnail_one_pixel_over_is_not_halved() { + use std::sync::Arc; + + use effectcraft_project::{Footage, ItemId}; + use effectcraft_raster::Image; + use effectcraft_time::Tick; + + struct Over; + impl crate::render::FootageSource for Over { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn vector_frame(&self, _: ItemId, _: &Footage, _: f64) -> Option> { + Some(Arc::new(Image::new(64, 64))) + } + } + let mut s = Session { footage: Arc::new(Over), ..Default::default() }; + let f = Footage { path: "/m/sq.svg".into(), codec: "SVG".into(), width: 121, height: 121, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("sq.svg", effectcraft_color::Label::Sandstone, None, ItemKind::Footage(f)); + let (w, h, px) = (s.thumbnail_job(item, Tick::ZERO, 63).unwrap())().unwrap(); + assert_eq!((w, h), (63, 63)); + assert_eq!(px.len(), 63 * 63 * 4); +} From 20ed10da5fe2923215956812d583f4982603de91 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:37:29 -0500 Subject: [PATCH 127/227] Shortcuts: list each binding once and cap the conflict list (#2997) * fix(shortcuts): list each binding once and cap the conflict list * address review: compute the shortcut bindings once per keyboard draw --- crates/engine/src/settings/shortcuts.rs | 17 ++++++++++++++++- crates/engine/src/tests/settings.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/panels/shortcut_editor.rs | 9 +++++---- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/settings/shortcuts.rs b/crates/engine/src/settings/shortcuts.rs index 44e2261a2..2d3996d57 100644 --- a/crates/engine/src/settings/shortcuts.rs +++ b/crates/engine/src/settings/shortcuts.rs @@ -550,6 +550,10 @@ impl Keymaps { } } +/// Most conflicts [`ShortcutTable::all_conflicts`] lists: a preset binding every command to one +/// key would otherwise list every pair of them. +pub const MAX_CONFLICTS: usize = 10_000; + /// The active preset resolved against every bindable command. #[derive(Clone, Debug, Default)] pub struct ShortcutTable { @@ -584,8 +588,15 @@ impl ShortcutTable { } /// Every active binding: (keys, bindable). + /// Each bindable's keys once: an imported preset can repeat a binding any number of times. pub fn bindings(&self) -> Vec<(&str, &Bindable)> { - self.bindables.iter().flat_map(|b| self.keys.get(&b.key).into_iter().flatten().map(move |k| (k.as_str(), b))).collect() + self.bindables + .iter() + .flat_map(|b| { + let mut seen = std::collections::HashSet::new(); + self.keys.get(&b.key).into_iter().flatten().filter(move |k| seen.insert(k.as_str())).map(move |k| (k.as_str(), b)) + }) + .collect() } /// Other bindables using `keys` in an overlapping scope. @@ -607,6 +618,7 @@ impl ShortcutTable { /// Every pair of commands sharing keys in the same scope, or in two contexts (which can be /// active at once): only one of the two can run, or neither ([`pick`]). (Other pairs in /// different scopes are intended, see [`Conflict::overrides`].) + /// At most [`MAX_CONFLICTS`] are listed. pub fn all_conflicts(&self) -> Vec<(String, Conflict)> { let mut by_keys: BTreeMap<&str, Vec<&Bindable>> = BTreeMap::new(); for (k, b) in self.bindings() { @@ -618,6 +630,9 @@ impl ShortcutTable { for (k, bs) in by_keys { for (i, a) in bs.iter().enumerate() { for b in &bs[i + 1..] { + if out.len() >= MAX_CONFLICTS { + return out; + } if a.scope == b.scope || (is_context(&a.scope) && is_context(&b.scope)) { out.push(( a.key.clone(), diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 0f8d6ffa2..42bcefc97 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -788,6 +788,26 @@ fn context_shortcuts_on_the_same_keys_conflict() { assert!(t.conflicts(b, "F9").iter().any(|c| c.key == "test.paint" && !c.overrides)); } +/// A preset repeating one binding many times lists it once, and conflicts stay bounded. +#[test] +fn repeated_bindings_are_one_binding() { + let mut s = Session::default(); + s.set_ui_commands(vec![ + crate::shortcuts::UiCommand { id: "test.a".into(), label: "A".into(), shortcut: Some("F9".into()), scope: Some("Timeline".into()) }, + crate::shortcuts::UiCommand { id: "test.b".into(), label: "B".into(), shortcut: Some("F9".into()), scope: Some("Timeline".into()) }, + ]); + let mut t = s.shortcuts().clone(); + t.keys.insert("test.a".into(), vec!["F9".into(); 10_000]); + assert_eq!(t.bindings().iter().filter(|(_, b)| b.key == "test.a").count(), 1); + assert_eq!(t.all_conflicts().iter().filter(|(a, c)| a == "test.a" && c.key == "test.b").count(), 1); + // Many commands on one key: the listing stops at its cap. + for i in 0..200 { + t.bindables.push(crate::shortcuts::Bindable { key: format!("test.many{i}"), ..t.find("test.b").unwrap().clone() }); + t.keys.insert(format!("test.many{i}"), vec!["F9".into()]); + } + assert_eq!(t.all_conflicts().len(), crate::shortcuts::MAX_CONFLICTS); +} + // ---------------------------------------------------------------- auto-save / recovery /// The names in `d`, sorted. diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index e4bf313ac..80011613d 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -244,11 +244,13 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mods = if held.iter().any(|m| *m) && !st.recording { held } else { st.mods }; let table = app.session.shortcuts().clone(); + // Once per frame: the keyboard draw reads it for every key. + let bindings = table.bindings(); let presets = app.session.keymaps.names(); let active = app.session.keymaps.active.clone(); // key name → (app?, panel?) for the current modifiers. let mut keymap: std::collections::HashMap = Default::default(); - for (sc, b) in table.bindings() { + for (sc, b) in bindings.iter().copied() { let (m, k) = split(sc); if m == mods { let e = keymap.entry(k).or_default(); @@ -395,8 +397,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } } if !name.starts_with('#') { - let who: Vec = - table.bindings().into_iter().filter(|(sc, _)| split(sc) == (mods, name.to_string())).map(|(_, b)| b.label.clone()).collect(); + let who: Vec = bindings.iter().copied().filter(|(sc, _)| split(sc) == (mods, name.to_string())).map(|(_, b)| b.label.clone()).collect(); if !who.is_empty() { resp.on_hover_text(who.join("\n")); } @@ -428,7 +429,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { p.text(info.left_top() + vec2(8.0, 6.0), Align2::LEFT_TOP, tr_args("Key: {}", &[&k]), Tokens::semibold(12.0), t.tab_text_active); let mut yy = info.min.y + 26.0; let mut list: Vec<(String, String, String)> = - table.bindings().into_iter().filter(|(sc, _)| split(sc).1 == *k).map(|(sc, b)| (sc.to_string(), b.label.clone(), b.scope.clone())).collect(); + bindings.iter().copied().filter(|(sc, _)| split(sc).1 == *k).map(|(sc, b)| (sc.to_string(), b.label.clone(), b.scope.clone())).collect(); list.sort(); if list.is_empty() { p.text(pos2(info.min.x + 8.0, yy), Align2::LEFT_TOP, tr("Not assigned"), Tokens::ui(11.5), t.text_dim); From 004dcda1c7c52e7243d40c819e4e4697b2677d85 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:37:43 -0500 Subject: [PATCH 128/227] fix(engine): name allocators resume each base's suffix instead of retrying from the start (#2996) --- crates/engine/src/cmd/file_more.rs | 19 ++++++--- crates/engine/src/cmd/frame_export.rs | 47 ++++++++++++++++----- crates/engine/src/home/templates.rs | 9 ++-- crates/engine/src/tests/export_overwrite.rs | 14 ++++++ 4 files changed, 71 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs index 168529c47..3088e8828 100644 --- a/crates/engine/src/cmd/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -395,6 +395,9 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { // reads (other footage, a proxy, the project file itself), and then nothing is written. let protected = s.protected_files(); let mut used_names = BTreeSet::new(); + // Where each file name's numbering resumes: a name repeated many times is numbered from its + // own file name, not by prefixing the previous candidate again. + let mut next_k: BTreeMap = BTreeMap::new(); let mut plan: Vec<(ItemId, Vec<(String, String)>)> = vec![]; for it in s.project.items.values() { let ItemKind::Footage(f) = &it.kind else { continue }; @@ -404,11 +407,17 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { let files: Vec<&String> = if f.sequence.is_empty() { vec![&f.path] } else { f.sequence.iter().collect() }; let mut pairs = vec![]; for src in files { - let mut name = std::path::Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "footage".into()); - let mut k = 2; - while !used_names.insert(name.clone()) { - name = format!("{k}_{name}"); - k += 1; + let file = std::path::Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "footage".into()); + let mut name = file.clone(); + if !used_names.insert(name.clone()) { + let k = next_k.entry(file.clone()).or_insert(2); + loop { + name = format!("{k}_{file}"); + *k += 1; + if used_names.insert(name.clone()) { + break; + } + } } let dst = format!("{folder}/(Footage)/{name}"); // A file that is already where its copy goes stays (it is not copied onto itself). diff --git a/crates/engine/src/cmd/frame_export.rs b/crates/engine/src/cmd/frame_export.rs index d6f491fb0..61e1ac82d 100644 --- a/crates/engine/src/cmd/frame_export.rs +++ b/crates/engine/src/cmd/frame_export.rs @@ -9,6 +9,8 @@ //! premultiplied): the composite as `R`, `G`, `B`, `A` plus one `.R/G/B/A` channel //! set per layer (the layer-prefixed channel convention multi-layer EXR readers use). +use std::collections::{HashMap, HashSet}; + use effectcraft_color::BlendMode; use effectcraft_project::{ItemId, LayerId, Project}; use effectcraft_raster::Image; @@ -141,17 +143,22 @@ pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec /// A unique EXR layer name (no dots: they separate the layer from the channel). Printable ASCII only: /// the `exr` crate panics on characters it cannot store in a channel name (#1702). -fn exr_name(name: &str, used: &mut Vec) -> String { +/// `used` holds the names given so far, `next` the suffix each base's search resumes at: many +/// layers of one name take a name each without retrying every suffix before theirs. +fn exr_name(name: &str, used: &mut HashSet, next: &mut HashMap) -> String { let base: String = name.chars().map(|c| if c == '.' || !(c == ' ' || c.is_ascii_graphic()) { '_' } else { c }).collect(); let base = if base.trim().is_empty() { "Layer".to_string() } else { base }; - let mut n = base.clone(); - let mut k = 2; - while used.contains(&n) { - n = format!("{base} {k}"); - k += 1; + if used.insert(base.clone()) { + return base; + } + let k = next.entry(base.clone()).or_insert(2); + loop { + let n = format!("{base} {k}"); + *k += 1; + if used.insert(n.clone()) { + return n; + } } - used.push(n.clone()); - n } /// The multi-layer EXR bytes of a frame and the channel names. @@ -186,9 +193,9 @@ pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec } }; push("", &full); - let mut used = vec![]; + let (mut used, mut next) = (HashSet::new(), HashMap::new()); for l in &layers { - let n = exr_name(&l.name, &mut used); + let n = exr_name(&l.name, &mut used, &mut next); push(&n, &l.image); } let layer = Layer::new((w, h), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, AnyChannels::sort(channels.into())); @@ -248,3 +255,23 @@ pub fn specs() -> Vec { ), ] } + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use super::exr_name; + + #[test] + fn many_layers_of_one_name_take_a_name_each_in_order() { + let (mut used, mut next) = (HashSet::new(), HashMap::new()); + // `A 3` is taken by a layer of its own before the repeats reach it. + let names: Vec = ["A", "A", "A 3", "A", "A.b"].iter().map(|n| exr_name(n, &mut used, &mut next)).collect(); + assert_eq!(names, ["A", "A 2", "A 3", "A 4", "A_b"]); + // 20 000 layers of one name: each found without retrying the suffixes before it. + for _ in 0..20_000 { + exr_name("Same", &mut used, &mut next); + } + assert_eq!(next["Same"], 20_001); + } +} diff --git a/crates/engine/src/home/templates.rs b/crates/engine/src/home/templates.rs index 0b15baaf5..f411fa5bd 100644 --- a/crates/engine/src/home/templates.rs +++ b/crates/engine/src/home/templates.rs @@ -751,6 +751,9 @@ fn footage_dir(s: &Session, name: &str, footage_dir: Option<&str>, project_path: fn extract_footage(s: &Session, proj: &mut Project, entries: &[(String, Vec)], dir: &str) -> Result> { let mut written = std::collections::HashMap::new(); let mut used = std::collections::HashSet::new(); + // Where each (item, file) pair's numbering resumes, so repeated names don't retry every + // number before theirs. + let mut next = std::collections::HashMap::new(); for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) { // `media//` → one folder; a name another item took gets the item's id. let file = safe_file_name(&base_name(k)); @@ -763,10 +766,10 @@ fn extract_footage(s: &Session, proj: &mut Project, entries: &[(String, Vec) // case-insensitive volume (Windows and macOS as they come), and Win32 drops a trailing // dot or space, so `a.png.` is `a.png` there. The name written stays as it is. let mut name = file.clone(); - let mut n = 0usize; + let n = next.entry((item, same_file_key(&file))).or_insert(0usize); while !used.insert(same_file_key(&name)) { - n += 1; - name = if n == 1 { format!("{item}-{file}") } else { format!("{item}-{n}-{file}") }; + *n += 1; + name = if *n == 1 { format!("{item}-{file}") } else { format!("{item}-{n}-{file}") }; } let out = format!("{dir}/{name}"); s.services.store_file(&out, v).map_err(|e| EngineError::Other(format!("cannot write {out}: {e}")))?; diff --git a/crates/engine/src/tests/export_overwrite.rs b/crates/engine/src/tests/export_overwrite.rs index 5cc65b1d5..507a9be4b 100644 --- a/crates/engine/src/tests/export_overwrite.rs +++ b/crates/engine/src/tests/export_overwrite.rs @@ -331,3 +331,17 @@ fn background_pre_render_movies_are_checked_again_before_publication() { assert!(listing(&f.dir).iter().all(|name| !name.starts_with(".effectcraft-prerender-"))); } } + +/// Footage files sharing a name are numbered from that name (`3_a.mov`, not `3_2_a.mov`). +#[test] +fn collect_files_numbers_repeated_names_from_the_name() { + let mut f = collecting("collect-repeated", "a.mov"); + for d in ["in2", "in3"] { + std::fs::create_dir_all(f.path(d)).unwrap(); + f.footage(&format!("{d}/a.mov")); + } + collect(&mut f).unwrap(); + let mut names = listing(&f.path("out/(Footage)")); + names.sort(); + assert_eq!(names, ["2_a.mov", "3_a.mov", "a.mov"]); +} From 063a7b950247d1defdad8729e40eac20c1d53297 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:37:58 -0500 Subject: [PATCH 129/227] Automation: a confined batch can't take a step's params from a reference, and renders refuse folders with outward links (#2930) * automation: confine batch steps whose params are a reference, and sequence render folders holding outward links A confined engine.batch checked each step's file parameters before the batch ran, but a step whose whole params was a `$N` reference became an earlier step's result only at run time, so a file path in that result (for example a manifest from essential.templateInfo) reached file.saveAs unchecked. Such steps are now refused while file access is confined. An image-sequence render writes numbered frame names that the check of its output template never resolved, and each frame is created through any link already at its name. A render output whose folder holds a link leading outside the write roots (or one that cannot be resolved) is now refused when it is queued and again when the queue renders. * address review: check numbered render folders for outward links * address review: fail closed when the numbered-folder scan cannot list its parent; unix-only symlink test * address review: render-folder link scan fails closed on unreadable folders, entries and file types --- crates/automation/src/confine.rs | 198 ++++++++++++++++++++++++++++++- 1 file changed, 197 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 371085938..0407ba756 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -75,6 +75,9 @@ const REFUSED: &[&str] = &[ "library.preview", ]; +/// Most numbered folders `check_render_folder` inspects for one output; more are refused. +const MAX_NUMBERED_FOLDERS: usize = 4096; + /// Commands that read files named by these parameters. const READS: &[(&str, &[&str])] = &[ ("file.open", &["path"]), @@ -202,6 +205,80 @@ impl Confinement { if self.roots(access).any(|r| real.starts_with(r)) { Ok(real) } else { Err(denied("it is outside the automation roots")) } } + /// A render output's folder holds no symbolic link that leads outside the write roots. An + /// image sequence is written to numbered names (`out_[#####].png` → `out_00001.png`, and a + /// sequence output without `#` gets `_00001` too) that the check of its template never + /// resolved, and each frame file is created through whatever link already sits at its name, + /// truncating the file it points to. A link that can't be resolved counts as outside: the + /// render would create its target. When the frame number is in a folder name, the same goes for + /// each existing numbered folder and the folder it holds the frames in. + fn check_render_folder(&self, output: &str) -> Result<()> { + let real = self.check(output, Access::Write)?; + if let Some(dir) = real.parent() { + self.check_no_outward_links(dir, output)?; + } + // The frame number may sit in a folder name (`shots_[##]/f_[#####].png`): the folders the + // frames go to are the numbered ones that exist, which the template's own parent is not. + let (before, _, after) = effectcraft_engine::project::render_queue::sequence_parts(output); + let sep = ['/', '\\']; + let Some((dir_tail, rest)) = after.split_once(sep) else { return Ok(()) }; + let (parent, prefix) = before.rsplit_once(sep).unwrap_or(("", &before)); + // A parent that doesn't exist has no numbered folders; one that can't be listed might. + let scan = if parent.is_empty() { Path::new("/") } else { Path::new(parent) }; + let entries = match std::fs::read_dir(scan) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(Error::BadArgs(format!("cannot render to `{output}`: its numbered folders in `{}` can't be checked: {e}", scan.display()))), + }; + let mut seen = 0; + for e in entries { + let e = + e.map_err(|e| Error::BadArgs(format!("cannot render to `{output}`: its numbered folders in `{}` can't be checked: {e}", scan.display())))?; + let name = e.file_name().to_string_lossy().into_owned(); + let Some(digits) = name.strip_prefix(prefix).and_then(|n| n.strip_suffix(dir_tail)) else { continue }; + if !digits.strip_prefix('-').unwrap_or(digits).bytes().all(|b| b.is_ascii_digit()) || digits.is_empty() { + continue; + } + seen += 1; + if seen > MAX_NUMBERED_FOLDERS { + return Err(Error::BadArgs(format!("cannot render to `{output}`: more than {MAX_NUMBERED_FOLDERS} numbered folders already exist to check"))); + } + // The numbered folder itself may be a link out; `check` resolves every existing part. + let frame_file = e.path().join(rest); + let frame_file = frame_file.to_string_lossy(); + self.check(&frame_file, Access::Write)?; + if let Some(dir) = Path::new(&*frame_file).parent().and_then(resolve) { + self.check_no_outward_links(&dir, output)?; + } + } + Ok(()) + } + + /// `dir` holds no symbolic link that leads outside the write roots. + fn check_no_outward_links(&self, dir: &Path, output: &str) -> Result<()> { + // A folder that does not exist yet holds no link; one that can't be listed might. + let cannot = |e: std::io::Error| Error::BadArgs(format!("cannot render to `{output}`: its folder `{}` can't be checked for links: {e}", dir.display())); + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(cannot(e)), + }; + for e in entries { + let e = e.map_err(cannot)?; + if !e.file_type().map_err(cannot)?.is_symlink() { + continue; + } + let link = e.path(); + if !link.canonicalize().is_ok_and(|r| self.write.iter().any(|w| r.starts_with(w))) { + return Err(Error::BadArgs(format!( + "cannot render to `{output}`: its folder holds `{}`, a link that leads outside the automation write roots, where a numbered frame could be written", + link.display() + ))); + } + } + Ok(()) + } + /// 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> { @@ -221,6 +298,12 @@ impl Confinement { for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() { let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue }; let inner_params = step.get("params").cloned().unwrap_or(Value::Null); + // A whole `params` given as a `$N` reference becomes an earlier step's result + // only as the batch runs, so none of its file paths would have been checked + // (`essential.templateInfo` returns a manifest naming any file at all). + if !matches!(inner_params, Value::Null | Value::Object(_)) { + return Err(Error::BadArgs(format!("`{inner}` in a batch: `params` must be an object while file access is confined"))); + } let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]); let mixed = keys_of(MIXED_WRITES, inner).unwrap_or(&[]); // A step's `$N` reference is resolved only as the batch runs: never for a file. @@ -258,6 +341,9 @@ impl Confinement { Some(s) => { gave_a_file |= v.is_string(); self.check(s, access)?; + if QUEUES_AN_OUTPUT.contains(&id) || id.starts_with("renderQueue.") { + self.check_render_folder(s)?; + } } None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))), } @@ -321,11 +407,15 @@ impl Confinement { let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default(); for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) { let extra = it.get("extraOutputs").and_then(Value::as_array).into_iter().flatten().map(|o| o.get("outputPath")); - for p in std::iter::once(it.get("outputPath")).chain(extra).chain([it.get("logPath")]).flatten() { + for p in std::iter::once(it.get("outputPath")).chain(extra).flatten() { if let Some(p) = p.as_str() { self.check(p, Access::Write)?; + self.check_render_folder(p)?; } } + if let Some(p) = it.get("logPath").and_then(Value::as_str) { + self.check(p, Access::Write)?; + } } Ok(()) } @@ -757,6 +847,112 @@ mod tests { ] { assert!(c.check_command("effect.plugins.install", ¶ms).is_err(), "{params}"); } + } + + #[test] + fn a_batch_step_whose_params_are_a_reference_is_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-batch-ref-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write) = (base.join("in"), base.join("out")); + for d in [&read, &write] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let info = read.join("t.ectemplate").to_string_lossy().into_owned(); + // Step 2's params are step 1's result, which can name any `path`. + let steps = serde_json::json!({"steps": [ + {"command": "essential.templateInfo", "params": {"path": info}}, + {"command": "file.saveAs", "params": "$1"}, + ]}); + let e = c.check_command("engine.batch", &steps).unwrap_err().to_string(); + assert!(e.contains("must be an object"), "{e}"); + for params in [serde_json::json!("$1.manifest"), serde_json::json!(["$1"]), serde_json::json!(7)] { + let steps = serde_json::json!({"steps": [{"command": "file.saveAs", "params": params}]}); + assert!(c.check_command("engine.batch", &steps).is_err(), "{params}"); + } + // Object params (or none) are checked as before. + let saved = write.join("p.ecproj").to_string_lossy().into_owned(); + let steps = serde_json::json!({"steps": [{"command": "file.saveAs", "params": {"path": saved}}, {"command": "edit.undo"}]}); + assert!(c.check_command("engine.batch", &steps).is_ok()); + let _ = std::fs::remove_dir_all(&base); + } + + #[cfg(unix)] + #[test] + fn a_render_folder_with_a_link_out_of_the_write_roots_is_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-seq-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere")); + for d in [&read, &write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let template = write.join("frames_[#####].png").to_string_lossy().into_owned(); + let add = serde_json::json!({"output": template}); + let queue = serde_json::json!({"items": [{"outputPath": template}]}); + assert!(c.check_command("renderQueue.add", &add).is_ok()); + assert!(c.check_lookup(&Lookup::RenderOutputs, &queue).is_ok()); + // A link inside the write root is fine. + std::os::unix::fs::symlink(write.join("kept.png"), write.join("frames_00002.png")).unwrap(); + std::fs::write(write.join("kept.png"), b"x").unwrap(); + assert!(c.check_lookup(&Lookup::RenderOutputs, &queue).is_ok()); + // Frame 1's name leads outside: rendering would truncate that file. + std::os::unix::fs::symlink(outside.join("victim"), write.join("frames_00001.png")).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_err()); + assert!(c.check_command("render.preRender", &add).is_err()); + let e = c.check_lookup(&Lookup::RenderOutputs, &queue).unwrap_err().to_string(); + assert!(e.contains("frames_00001.png"), "{e}"); + let _ = std::fs::remove_dir_all(&base); + } + + #[cfg(unix)] + #[test] + fn a_render_folder_that_cannot_be_listed_is_refused() { + use std::os::unix::fs::PermissionsExt; + let base = std::env::temp_dir().join(format!("ec-confine-seq-nolist-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let write = base.join("out"); + std::fs::create_dir_all(&write).unwrap(); + let c = Confinement::new(&[], std::slice::from_ref(&write)).unwrap(); + let template = write.join("frames_[#####].png").to_string_lossy().into_owned(); + let add = serde_json::json!({"output": template}); + // Writable and searchable but not listable: a link in it could not be seen. + std::fs::set_permissions(&write, std::fs::Permissions::from_mode(0o300)).unwrap(); + // A user who can list it anyway (root) sees no difference to test. + if std::fs::read_dir(&write).is_err() { + assert!(c.check_command("renderQueue.add", &add).is_err()); + } + std::fs::set_permissions(&write, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_ok()); + let _ = std::fs::remove_dir_all(&base); + } + + #[cfg(unix)] + #[test] + fn numbered_render_folders_with_links_out_of_the_write_roots_are_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-seq-dir-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (write, outside) = (base.join("out"), base.join("elsewhere")); + for d in [&write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(&[], std::slice::from_ref(&write)).unwrap(); + let template = write.join("shot_[###]/f_[#####].png").to_string_lossy().into_owned(); + let add = serde_json::json!({"output": template}); + assert!(c.check_command("renderQueue.add", &add).is_ok(), "no numbered folder exists yet"); + // A numbered folder inside the roots holding a link out is refused. + std::fs::create_dir(write.join("shot_001")).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_ok()); + std::os::unix::fs::symlink(outside.join("victim"), write.join("shot_001/f_00001.png")).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_err()); + std::fs::remove_file(write.join("shot_001/f_00001.png")).unwrap(); + // A numbered folder that is itself a link out is refused. + std::os::unix::fs::symlink(&outside, write.join("shot_002")).unwrap(); + assert!(c.check_command("render.preRender", &add).is_err()); + // Unrelated names are left alone. + std::fs::remove_file(write.join("shot_002")).unwrap(); + std::os::unix::fs::symlink(&outside, write.join("shot_notes")).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_ok()); let _ = std::fs::remove_dir_all(&base); } } From f8b9c8244677cf3188806fc54b9ad9d5d572454b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:38:11 -0500 Subject: [PATCH 130/227] Interchange: export ends on folder and precomp cycles; import finds each clip's transitions without scanning the track (#2932) * interchange: end export walks on folder and precomp cycles, and index each clip's transitions on import Export followed a folder's parent chain recursively before caching it, so a project whose folders loop back to themselves overflowed the stack. The walk is now iterative and a repeated folder ends it (the cycle's top becomes a top-level bin, with a warning). comp_has_audio recursed into nested comps with no visited set: a precomp cycle never ended and a chain of comps that each use the next twice took 2^depth steps. It now walks with a stack and a visited set. Import scanned every transition of a track for every clip on it, quadratic in a long flat edit. Each track now indexes its transitions by clip once, and the per-transition warnings are said once per track. Warning de-duplication uses a set instead of a linear search. * interchange: reuse a folder's cached bin on export Exporter::bin returned None when the folder itself was already cached, so every item after the first in a folder was exported at the project root. Return the cached bin when the walk starts at a cached folder. --- crates/interchange/src/export.rs | 105 +++++++++++++++++++++++++++---- crates/interchange/src/import.rs | 78 ++++++++++++++--------- crates/interchange/src/tests.rs | 95 ++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 42 deletions(-) diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs index 375c1bf1b..fd396b00b 100644 --- a/crates/interchange/src/export.rs +++ b/crates/interchange/src/export.rs @@ -303,16 +303,41 @@ impl Exporter<'_> { } /// The bin mirroring a Project panel folder. + /// + /// Walks up the folder's parents without recursion: a damaged or hostile project can hold a + /// parent cycle (or a very long chain), which recursed until the stack ran out. A folder seen + /// twice in one walk ends it, and the cycle's topmost folder becomes a top-level bin. fn bin(&mut self, folder: Option) -> Option { let f = folder?; - if let Some(b) = self.bins.get(&f) { - return Some(*b); + // The uncached folders from `f` up, and the bin they hang from. + let mut chain: Vec = vec![]; + let mut in_chain = std::collections::HashSet::new(); + let mut parent = None; + let mut at = Some(f); + while let Some(id) = at { + if let Some(b) = self.bins.get(&id) { + parent = Some(*b); + break; + } + if !in_chain.insert(id) { + self.warn("a folder that contains itself was exported as a top-level bin"); + break; + } + let Some(it) = self.p.item(id) else { break }; + chain.push(id); + at = it.parent; } - let it = self.p.item(f)?; - let parent = self.bin(it.parent); - let b = self.fp.add_bin(&it.name, parent); - self.bins.insert(f, b); - Some(b) + if chain.is_empty() { + // `f` is cached already (`parent` is its bin), or is not an item (`None`). + return parent; + } + for id in chain.into_iter().rev() { + let name = self.p.item(id).map(|it| it.name.clone()).unwrap_or_default(); + let b = self.fp.add_bin(&name, parent); + self.bins.insert(id, b); + parent = Some(b); + } + parent } fn footage_item(&mut self, item: ItemId, f: &Footage) -> fp::ItemId { @@ -615,12 +640,34 @@ impl Exporter<'_> { } } +/// Whether `c` or a comp nested in it (at any depth) has a layer of footage with audio. +/// +/// A walk with a stack and a visited set, as in `Project::comp_contains`: recursion never ended +/// on a precomp cycle, and re-walked a comp shared by many layers once per path (exponential +/// for a chain of comps that each use the next one twice). fn comp_has_audio(p: &Project, c: &Comp) -> bool { - c.layers.iter().any(|l| match &l.source { - LayerSource::Footage { item } => matches!(p.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio), - LayerSource::Comp { item } => p.comp(*item).is_some_and(|c| comp_has_audio(p, c)), - _ => false, - }) + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![c]; + while let Some(c) = stack.pop() { + for l in &c.layers { + match &l.source { + LayerSource::Footage { item } => { + if matches!(p.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio) { + return true; + } + } + LayerSource::Comp { item } => { + if seen.insert(*item) + && let Some(nested) = p.comp(*item) + { + stack.push(nested); + } + } + _ => {} + } + } + } + false } fn differs(v: &Value, def: [f64; 2]) -> bool { @@ -703,3 +750,37 @@ fn media_info(name: &str, kind: MediaKind, duration: Tick, video: Option fp::MediaClip { fp::MediaClip { media, info, interpret: Default::default(), mark_in: None, mark_out: None, markers: vec![], offline: false, proxy: None, identity: None } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A folder's bin is the same one each time it is asked for (a cached folder once answered + /// `None`, so a folder's second item went to the project root); a non-item has no bin. + #[test] + fn folder_bins_are_reused_once_cached() { + let mut p = Project::default(); + let a = p.add_item("A", effectcraft_color::Label::None, None, ItemKind::Folder); + let b = p.add_item("B", effectcraft_color::Label::None, Some(a), ItemKind::Folder); + let (opts, pre) = (ExportOptions::default(), HashMap::new()); + let mut ex = Exporter { + p: &p, + opts: &opts, + pre: &pre, + fp: fp::Project::new("x"), + out: ExportOutput::default(), + seqs: HashMap::new(), + media: HashMap::new(), + bins: HashMap::new(), + stack: vec![], + }; + let bb = ex.bin(Some(b)); + assert!(bb.is_some()); + assert_eq!(ex.bin(Some(b)), bb); + let ba = ex.bin(Some(a)); + assert!(ba.is_some() && ba != bb); + assert_eq!(ex.bin(Some(a)), ba); + assert_eq!(ex.bin(Some(ItemId(u64::MAX))), None); + assert_eq!(ex.bin(None), None); + } +} diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 22e22b4f2..6faadd91a 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -177,10 +177,12 @@ pub fn import( } let folder_name = if opts.name.is_empty() { "Imported Timeline".to_string() } else { opts.name.clone() }; res.folder = project.add_item(&folder_name, Label::None, None, ItemKind::Folder); + let warned = res.warnings.iter().cloned().collect(); let mut im = Importer { src: &imported.project, project, res, + warned, items: HashMap::new(), solids: HashMap::new(), solids_folder: None, @@ -273,6 +275,8 @@ struct Importer<'a> { src: &'a fp::Project, project: &'a mut Project, res: ImportResult, + /// The warnings in `res`, to say each once without searching them all every time. + warned: HashSet, /// Source item → created item (footage, comp). items: HashMap, /// Generator / adjustment solids by (source item, width, height). @@ -290,7 +294,7 @@ struct Importer<'a> { impl Importer<'_> { fn warn(&mut self, m: impl Into) { let m = m.into(); - if !self.res.warnings.contains(&m) { + if self.warned.insert(m.clone()) { self.res.warnings.push(m); } } @@ -429,23 +433,8 @@ impl Importer<'_> { comp.markers = seq.markers.iter().map(marker_in).collect(); // Video: the top track's clips first (layer #1); later clips of a track above earlier ones // so incoming clips of dissolves cover outgoing ones. - for track in seq.video_tracks.iter().rev() { - let mut items: Vec<&fp::TrackItem> = track.items.iter().collect(); - items.sort_by_key(|i| std::cmp::Reverse(i.start)); - for ti in items { - if let Some(l) = self.clip_layer(&comp, seq, track, ti) { - comp.layers.push(l); - } - } - } - for track in &seq.audio_tracks { - let mut items: Vec<&fp::TrackItem> = track.items.iter().collect(); - items.sort_by_key(|i| std::cmp::Reverse(i.start)); - for ti in items { - if let Some(l) = self.clip_layer(&comp, seq, track, ti) { - comp.layers.push(l); - } - } + for track in seq.video_tracks.iter().rev().chain(&seq.audio_tracks) { + self.track_layers(&mut comp, seq, track); } if !seq.caption_tracks.is_empty() { self.warn("caption tracks are not imported"); @@ -508,7 +497,45 @@ impl Importer<'_> { } } - fn clip_layer(&mut self, comp: &Comp, seq: &fp::Sequence, track: &fp::Track, ti: &fp::TrackItem) -> Option { + /// A track's clips as layers of `comp`, the latest first. + fn track_layers(&mut self, comp: &mut Comp, seq: &fp::Sequence, track: &fp::Track) { + let mut items: Vec<&fp::TrackItem> = track.items.iter().collect(); + items.sort_by_key(|i| std::cmp::Reverse(i.start)); + // Each clip's transitions, found once per track: scanning every transition of the track + // for every clip made a long flat edit (many clips and many transitions) quadratic. + let mut transitions: HashMap> = HashMap::new(); + for tr in &track.transitions { + for clip in [tr.from, tr.to].into_iter().flatten() { + let list = transitions.entry(clip).or_default(); + // A transition from and to the same clip still counts once. + if !list.last().is_some_and(|last| std::ptr::eq(*last, tr)) { + list.push(tr); + } + } + } + let mut any = false; + for ti in items { + let own = transitions.get(&ti.id).map(Vec::as_slice).unwrap_or_default(); + if let Some(l) = self.clip_layer(comp, seq, track, ti, own) { + comp.layers.push(l); + any = true; + } + } + // What the track's transitions become, said once a clip of the track was imported. + if any && track.kind != fp::TrackKind::Audio { + for tr in &track.transitions { + let id = tr.effect.effect.as_str(); + if !matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "film_dissolve" | "additive_dissolve" | "non_additive_dissolve") { + self.warn(format!("transition \"{}\" was imported as a cross dissolve", tr.effect.def().map(|d| d.name).unwrap_or(id))); + } + } + } + if any && track.transitions.iter().any(|tr| tr.effect.effect == "dip_to_white") { + self.warn("Dip to White was imported as a dip to the background"); + } + } + + fn clip_layer(&mut self, comp: &Comp, seq: &fp::Sequence, track: &fp::Track, ti: &fp::TrackItem, transitions: &[&fp::Transition]) -> Option { let audio_track = track.kind == fp::TrackKind::Audio; let (source, size, has_audio) = self.source_of(ti, comp.width, comp.height)?; let is_adjustment = matches!(self.src.item(ti.item).map(|i| &i.kind), Some(fp::ItemKind::AdjustmentLayer { .. })); @@ -561,18 +588,9 @@ impl Importer<'_> { } // Transitions extend the layer over their handles. let mut fades: Vec<(Tick, Tick, f64, f64)> = vec![]; - for tr in &track.transitions { + for tr in transitions { let (ts, te) = (tick_in(tr.start), tick_in(tr.end())); - let id = tr.effect.effect.as_str(); - let dip = id.starts_with("dip_to"); - if !audio_track - && !matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "film_dissolve" | "additive_dissolve" | "non_additive_dissolve") - { - self.warn(format!("transition \"{}\" was imported as a cross dissolve", tr.effect.def().map(|d| d.name).unwrap_or(id))); - } - if dip && id == "dip_to_white" { - self.warn("Dip to White was imported as a dip to the background"); - } + let dip = tr.effect.effect.starts_with("dip_to"); let mid = ts + Tick((te - ts).0 / 2); if tr.to == Some(ti.id) { l.in_point = l.in_point.min(ts); diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index b451ecf81..4ce892c15 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -787,3 +787,98 @@ fn deeply_nested_documents_error_instead_of_overflowing_the_stack() { assert_eq!(super::import::xml_depth(b"]]>"), 2); assert_eq!(super::import::json_depth(br#"{"a": "[[[{{", "b": [[1], {"c": "\\\"["}]}"#), 3); } + +/// A hostile project: footage in a folder whose parents loop back to it. Export walked the +/// folder's parents recursively and never reached the cache insert, so the stack ran out. +#[test] +fn export_survives_a_folder_parent_cycle() { + let (mut p, cid) = ae_project(); + let a = p.add_item("A", effectcraft_color::Label::None, None, ItemKind::Folder); + let b = p.add_item("B", effectcraft_color::Label::None, Some(a), ItemKind::Folder); + p.items.get_mut(&a).unwrap().parent = Some(b); + let footage: Vec = p.items.values().filter(|i| matches!(i.kind, ItemKind::Footage(_))).map(|i| i.id).collect(); + for f in footage { + p.items.get_mut(&f).unwrap().parent = Some(a); + } + let out = export(&p, cid, &ExportOptions::default(), &HashMap::new()).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("contains itself")), "{:?}", out.warnings); +} + +/// Precomps that use each other, and a chain of comps that each use the next one twice: the +/// audio check recursed forever on the first and 2^depth times on the second. +#[test] +fn export_audio_check_ends_on_precomp_cycles_and_shared_chains() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let comp = || Comp::new(W, H, FrameRate::new(25, 1), t(2.0)); + // A chain of 60 comps, each using the next twice; the last one is silent footage. + let mut next = { + let mut c = comp(); + let l = effectcraft_project::build::layer(&mut p, &c, "a", LayerSource::Footage { item: a }, (W, H), None); + c.layers.push(l); + comp_item(&mut p, "Leaf", c) + }; + for i in 0..60 { + let mut c = comp(); + for n in 0..2 { + let l = effectcraft_project::build::layer(&mut p, &c, &format!("use {n}"), LayerSource::Comp { item: next }, (W, H), None); + c.layers.push(l); + } + next = comp_item(&mut p, &format!("Chain {i}"), c); + } + let started = std::time::Instant::now(); + export(&p, next, &ExportOptions::default(), &HashMap::new()).unwrap(); + assert!(started.elapsed() < std::time::Duration::from_secs(30), "{:?}", started.elapsed()); + // Two comps nesting each other. + let x = comp_item(&mut p, "X", comp()); + let mut y = comp(); + let l = effectcraft_project::build::layer(&mut p, &y, "x", LayerSource::Comp { item: x }, (W, H), None); + y.layers.push(l); + let y = comp_item(&mut p, "Y", y); + let mut xc = comp(); + let l = effectcraft_project::build::layer(&mut p, &xc, "y", LayerSource::Comp { item: y }, (W, H), None); + xc.layers.push(l); + if let Some(ItemKind::Comp(c)) = p.items.get_mut(&x).map(|i| &mut i.kind) { + *c = Arc::new(xc); + } + export(&p, x, &ExportOptions::default(), &HashMap::new()).unwrap(); +} + +/// A flat edit with many clips and many transitions: each clip finds its own transitions (it +/// scanned every transition of the track per clip), and they still extend and fade it. +#[test] +fn import_finds_each_clips_transitions_without_scanning_the_track() { + let mut p = fp::Project::new("Flat"); + let a = media(&mut p, "a.png", "/media/a.mov", filmcraft_media::MediaKind::Movie, true, false, None); + let settings = fp::SequenceSettings { width: W, height: H, frame_rate: rate(), ..Default::default() }; + let main = p.new_sequence("Main", settings, 1, 0, None); + const N: usize = 500; + let mut items = Vec::with_capacity(N); + let mut transitions = Vec::with_capacity(N); + for i in 0..N { + let ti = item(&mut p, a, fp::TrackKind::Video, i as f64 * 0.4, 0.0, 0.4); + if let Some(prev) = items.last().map(|x: &fp::TrackItem| x.id) { + transitions.push(fp::Transition { + id: fp::TransitionId(p.alloc_id()), + effect: fp::find_effect("cross_dissolve").unwrap().instance(), + start: secs(i as f64 * 0.4 - 0.08), + duration: secs(0.16), + from: Some(prev), + to: Some(ti.id), + align: fp::TransitionAlign::CenterAtCut, + reverse: false, + }); + } + items.push(ti); + } + let seq = p.sequence_mut(main).unwrap(); + seq.video_tracks[0].items = items; + seq.video_tracks[0].transitions = transitions; + let (bytes, _) = fc::export(&p, main, fc::Format::Otio, &Default::default()).unwrap(); + let (q, r) = import_doc(&bytes, "Flat.otio", &mut probe); + let m = q.comp(r.comps[0]).unwrap(); + assert_eq!(m.layers.len(), N); + // A middle clip reaches over both of its transitions' handles. + let mid = m.layers.iter().find(|l| near(l.in_point, t(100.0 - 0.08))).expect("clip 250 extended into its incoming transition"); + assert!(near(mid.out_point, t(100.4 + 0.08)), "{}", mid.out_point.seconds()); +} From dad07b8d5de0feb65baa43216022cb8cddcf9a52 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:38:26 -0500 Subject: [PATCH 131/227] Puppet: clamp an imported Expansion before padding the outline grid (#2940) * Puppet: clamp an imported Expansion before padding the outline grid * address review: non-finite expansion compares with zero expansion in test * address review: trace over-budget puppet outlines at a coarser resolution; NaN expansion test --- crates/effects/src/puppet/mesh.rs | 67 ++++++++++++++++++++++++++++-- crates/effects/src/puppet/tests.rs | 36 ++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/puppet/mesh.rs b/crates/effects/src/puppet/mesh.rs index f01ee8f78..d5fd66dae 100644 --- a/crates/effects/src/puppet/mesh.rs +++ b/crates/effects/src/puppet/mesh.rs @@ -614,11 +614,72 @@ pub fn triangulate(outline: &[[f64; 2]], opts: &MeshOpts) -> Mesh { Mesh { verts, tris, outline: poly } } +/// The Expansion range the outline honours, in layer pixels (the property's slider range). A +/// project file can store any number; outside this range it is clamped, so the padded grid +/// below stays a few hundred pixels larger than the buffer. +pub const EXPANSION_RANGE: (f64, f64) = (-100.0, 200.0); +/// Most buffer pixels of padding the outline grid takes on each side (Expansion × buffer scale). +const MAX_PAD: f64 = 4096.0; +/// Most cells the padded outline grid may have (it is expanded with two `f64` distance buffers). +const MAX_GRID_CELLS: usize = 1 << 25; +/// Most the alpha is shrunk by, per axis, to make the padded grid fit; a layer needing more has no +/// outline. +const MAX_DOWNSAMPLE: usize = 8; + /// The outline of the alpha region under `seed` (buffer pixels → layer space via `scale` and -/// `offset`), expanded by `expansion` layer pixels. +/// `offset`), expanded by `expansion` layer pixels (clamped to [`EXPANSION_RANGE`]). When the +/// padded grid would pass [`MAX_PAD`] or [`MAX_GRID_CELLS`] (a high-scale layer with a large +/// Expansion), the alpha is traced at a coarser resolution instead of being skipped. pub fn outline(alpha: &[f32], w: usize, h: usize, scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64) -> Vec<[f64; 2]> { - // Pad so expansion can grow past the buffer edge. - let pad = (expansion.max(0.0) * scale).ceil() as usize + 2; + let expansion = if expansion.is_finite() { expansion.clamp(EXPANSION_RANGE.0, EXPANSION_RANGE.1) } else { 0.0 }; + if alpha.len() < w.saturating_mul(h) { + return vec![]; + } + let want = (expansion.max(0.0) * scale).ceil(); + if !want.is_finite() { + return vec![]; + } + for k in 1..=MAX_DOWNSAMPLE { + let pad = (want / k as f64).ceil(); + let (kw, kh) = (w.div_ceil(k), h.div_ceil(k)); + let fits = pad <= MAX_PAD && { + let pad = pad as usize + 2; + kw.checked_add(2 * pad).zip(kh.checked_add(2 * pad)).and_then(|(pw, ph)| pw.checked_mul(ph)).is_some_and(|c| c <= MAX_GRID_CELLS) + }; + if !fits { + continue; + } + if k == 1 { + return outline_padded(alpha, [w, h], scale, offset, seed, expansion, pad as usize + 2); + } + let small = shrink(alpha, w, h, k); + let kf = k as f64; + return outline_padded(&small, [kw, kh], scale / kf, [offset[0] / kf, offset[1] / kf], seed, expansion, pad as usize + 2); + } + vec![] +} + +/// `alpha` (`w × h`) with every `k × k` block averaged into one pixel. +fn shrink(alpha: &[f32], w: usize, h: usize, k: usize) -> Vec { + let (kw, kh) = (w.div_ceil(k), h.div_ceil(k)); + let mut out = vec![0.0f32; kw * kh]; + for y in 0..kh { + for x in 0..kw { + let (x1, y1) = (((x + 1) * k).min(w), ((y + 1) * k).min(h)); + let (mut sum, mut n) = (0.0f32, 0u32); + for yy in y * k..y1 { + for xx in x * k..x1 { + sum += alpha[yy * w + xx]; + n += 1; + } + } + out[y * kw + x] = sum / n.max(1) as f32; + } + } + out +} + +fn outline_padded(alpha: &[f32], [w, h]: [usize; 2], scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64, pad: usize) -> Vec<[f64; 2]> { let (pw, ph) = (w + 2 * pad, h + 2 * pad); let mut on = vec![false; pw * ph]; for y in 0..h { diff --git a/crates/effects/src/puppet/tests.rs b/crates/effects/src/puppet/tests.rs index 10426d46c..c2db999e1 100644 --- a/crates/effects/src/puppet/tests.rs +++ b/crates/effects/src/puppet/tests.rs @@ -106,6 +106,42 @@ fn expansion_grows_the_outline() { assert!((am - pi * 225.0).abs() < 60.0, "{am}"); } +/// An imported Expansion far past the property's range is clamped instead of padding the grid +/// by that many pixels (a 1e12 expansion would ask for terabytes). +#[test] +fn hostile_expansion_is_clamped() { + let (w, h) = (40, 40); + let alpha = disc_alpha(w, h, [20.0, 20.0], 10.0); + let max = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 20.0], EXPANSION_RANGE.1)).abs(); + // A non-finite Expansion is taken as zero (the unexpanded outline), not clamped to the maximum. + let zero = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 20.0], 0.0)).abs(); + for e in [1e12, f64::MAX, f64::INFINITY, f64::NAN] { + let a = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 20.0], e)).abs(); + let want = if e.is_finite() { max } else { zero }; + assert!((a - want).abs() < 1.0, "{e}: {a} vs {want}"); + } + assert!(outline(&alpha, w, h, 1e9, [0.0; 2], [20.0, 20.0], 100.0).is_empty()); + let _ = outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 20.0], -1e12); +} + +/// A high-scale layer with a large Expansion is traced at a coarser resolution, not skipped, and a +/// tiny buffer with a huge padded grid stays within the grid budget. +#[test] +fn high_scale_expansion_is_traced_coarser_instead_of_skipped() { + let (w, h) = (40, 40); + let alpha = disc_alpha(w, h, [20.0, 20.0], 10.0); + // Scale 32, Expansion 130: 4160 px of padding, past MAX_PAD, so it is shrunk by 2. + let scale = 32.0; + let o = outline(&alpha, w, h, scale, [0.0; 2], [20.0, 20.0], 130.0); + let area = signed_area(&o).abs(); + // The disc has radius 10 / 32 layer px; expanded by 130. + let r = 10.0 / scale + 130.0; + assert!((area - std::f64::consts::PI * r * r).abs() < 0.03 * std::f64::consts::PI * r * r, "{area}"); + // One pixel at scale 20.48 and Expansion 1e12 (clamped): its 8197 px square grid is over budget. + let o = outline(&[1.0], 1, 1, 20.48, [0.0; 2], [0.5, 0.5], 1e12); + assert!(o.len() >= 3); +} + fn rect_mesh(w: f64, h: f64, tris: f64) -> Mesh { triangulate(&[[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]], &MeshOpts { triangles: tris, density: 50.0, expansion: 0.0 }) } From 9aa679f6ad699343905c8cf56a696eed18e8065f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:38:39 -0500 Subject: [PATCH 132/227] Roto Brush, Object Matte: decode frozen mattes only at the layer's full-resolution grid (#2941) * Roto Brush, Object Matte: decode frozen mattes only at the layer's full-resolution grid * address review: frozen matte size mismatch falls back to computed matte * address review: frozen-size-mismatch test compares against a render with no cache --- crates/effects/src/object_matte.rs | 9 ++++- crates/effects/src/roto.rs | 56 +++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index 8a9750282..86534a178 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -301,7 +301,8 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: let fr = fc.frames.get(&f)?; let [fw, fh] = fc.size; let expected = fw.checked_mul(fh)?; - if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES { + // Only the layer's own full-resolution grid (see Roto Brush's frozen mattes). + if fc.size != roto::frozen_grid(layer_size) || fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES { return None; } let a = rle::decode_bounded(&fr.a, expected)?; @@ -719,6 +720,12 @@ mod tests { p.values.insert("objectPoint".into(), Value::Vec2([40.0, 40.0])); p.values.insert(FROZEN.into(), Value::Str(fc2.to_json())); assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); + // A compact plane claiming a size other than the layer's grid is not decoded. + let mut fc3 = fc.clone(); + fc3.size = [4000, 4000]; + fc3.frames.insert(2, roto::freeze_frame(&vec![1.0; 4000 * 4000], &[])); + p.values.insert(FROZEN.into(), Value::Str(fc3.to_json())); + assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); } /// Mattes frozen with a model stay usable in a session where that model is not loaded (a diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index f9c26f417..733535b21 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -378,10 +378,22 @@ pub fn purge() { c.bytes = 0; } +/// The pixel size of the layer-space grid at `scale` (`round(size · scale)`, at least 1). +fn grid_size(layer_size: [f64; 2], scale: f64) -> (u32, u32) { + (((layer_size[0] * scale).round() as u32).max(1), ((layer_size[1] * scale).round() as u32).max(1)) +} + +/// The full-resolution layer grid frozen mattes are stored at. A frozen cache claiming any +/// other size did not come from freezing this layer and is not decoded. +pub fn frozen_grid(layer_size: [f64; 2]) -> [usize; 2] { + let (w, h) = grid_size(layer_size, 1.0); + [w as usize, h as usize] +} + /// The layer-space grid (`round(size · scale)` pixels) of a buffer. pub fn layer_grid(buf: &Buf, layer_size: [f64; 2]) -> Image { let s = if buf.scale > 0.0 { buf.scale } else { 1.0 }; - let (w, h) = (((layer_size[0] * s).round() as u32).max(1), ((layer_size[1] * s).round() as u32).max(1)); + let (w, h) = grid_size(layer_size, s); let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64); let mut img = Image::new(w, h); img.rows_mut().for_each(|(y, row)| { @@ -1212,8 +1224,11 @@ pub fn freeze_frame(alpha: &[f32], band: &[u8]) -> FrozenFrame { fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc, scale: f64) -> Option<(Vec, Vec, f64)> { let (w, h) = (img.width as usize, img.height as usize); // Frozen mattes. + // Only the layer's own full-resolution grid: a project cannot make a tiny layer decode (and + // convert to floats) planes as large as the RLE allows. Any other size falls through to a computed matte. if let Some(fc) = frozen(ctx.params) && frozen_applies(&fc, ctx.params, ctx.layer_size, ctx.fps()) + && fc.size == frozen_grid(ctx.layer_size) && let Some(fr) = fc.frames.get(&f) { let [fw, fh] = fc.size; @@ -1409,6 +1424,33 @@ mod tests { params } + #[test] + fn mismatched_frozen_size_falls_back_to_a_computed_matte() { + let mut d = RotoData::default(); + d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0], [45.0, 40.0]], object: 0 }, 20, [0, 24]); + let mut params = params_with(&d); + let fc = + FrozenCache { key: frozen_key(¶ms, [100.0, 80.0], 25.0), size: [10, 8], frames: BTreeMap::from([(5, freeze_frame(&[1.0; 80], &[0; 80]))]) }; + let host = Seq; + let render = |params: &Params| { + let ctx = EffectCtx { + params, + time: 5.0 / 25.0 + 0.001, + layer_size: [100.0, 80.0], + seed: 1, + adjustment: false, + env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() }, + }; + crate::apply(&specs()[0], &ctx, Buf { img: frame(5.0 / 25.0 + 0.001), offset: [0.0; 2], scale: 1.0 }).img + }; + let computed = render(¶ms); + params.values.insert(FROZEN.into(), Value::Str(fc.to_json())); + let out = render(¶ms); + assert!(out.data[(40 * 100 + 50) as usize][3] > 0.5, "the disk is missing from the output"); + // The cache's all-opaque matte would leave the background opaque too; the fallback must match a render with no cache. + assert!(out.data == computed.data, "the mismatched frozen cache was used"); + } + #[test] fn renders_the_segmented_disk_on_demand() { let mut d = RotoData::default(); @@ -1980,6 +2022,18 @@ mod tests { } } + /// Frozen mattes are only decoded at the size the freeze pass stores: the layer grid at + /// full resolution. + #[test] + fn frozen_grid_is_the_full_resolution_layer_grid() { + for size in [[100.0, 80.0], [99.6, 0.2], [1.0, 1.0]] { + let buf = Buf { img: Image::new(3, 3), offset: [0.0; 2], scale: 1.0 }; + let g = layer_grid(&buf, size); + assert_eq!(frozen_grid(size), [g.width as usize, g.height as usize]); + } + assert_eq!(frozen_grid([100.0, 80.0]), [100, 80]); + } + #[test] fn saved_tracking_decoders_validate_dimensions_and_plane_lengths() { let valid = rle::encode(&[255, 0, 255, 0]); From 70a05be71a95d6e8e1f18debf248107d59accfc9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:38:53 -0500 Subject: [PATCH 133/227] Simulations: cap the steps a layer time can ask for and bound checkpoints as they are made (#2942) * Simulations: cap the steps a layer time can ask for and bound checkpoints as they are made * address review: keep the requested sim state when trimming checkpoints * address review: drop only the excess sim checkpoints when trimming --- crates/effects/src/sim/engine.rs | 6 ++- crates/effects/src/util.rs | 85 +++++++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/crates/effects/src/sim/engine.rs b/crates/effects/src/sim/engine.rs index beda8c318..8d9914e31 100644 --- a/crates/effects/src/sim/engine.rs +++ b/crates/effects/src/sim/engine.rs @@ -8,9 +8,11 @@ use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, /// Steps per second of the stepped particle simulations. pub(crate) const SPS: f64 = 60.0; -/// Number of whole steps reached at layer time `t`. +/// Number of whole steps reached at layer time `t` (at most [`crate::util::MAX_SIM_STEPS`]: the +/// per-step histories built from it, such as [`rate_history`], stay bounded however late a +/// project places the layer's time). pub(crate) fn steps_at(t: f64) -> u64 { - if t <= 0.0 { 0 } else { (t * SPS + 1e-6).floor() as u64 } + if t <= 0.0 { 0 } else { ((t * SPS + 1e-6).floor() as u64).min(crate::util::MAX_SIM_STEPS) } } #[derive(Clone, Debug)] diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 9c14a6c69..390750c6e 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -562,13 +562,22 @@ struct SimEntry { /// Checkpoint every this many steps. pub const SIM_CHECKPOINT: u64 = 15; +/// Most steps a simulation runs: three hours of layer time at 60 steps per second. The layer +/// time comes from the project (start time, stretch), so without a limit a tiny file could ask +/// for years of steps; past this the state stays where it is. +pub const MAX_SIM_STEPS: u64 = 60 * 60 * 60 * 3; + +/// Most checkpoints kept per simulation (and made by one run, which spaces them further apart +/// as it goes). +const MAX_SIM_CHECKPOINTS: usize = 256; + impl SimCache { pub const fn new(cap: usize) -> SimCache { SimCache { inner: std::sync::Mutex::new(Vec::new()), cap } } - /// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step - /// `i` to `i + 1`. + /// State after `target` steps (at most [`MAX_SIM_STEPS`]). `init` builds step 0; + /// `step(state, i)` advances from step `i` to `i + 1`. pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, step: impl FnMut(&mut S, u64)) -> std::sync::Arc { self.run_where(key, target, init, step, |_, _| true) } @@ -586,6 +595,7 @@ impl SimCache { valid: impl Fn(u64, &S) -> bool, ) -> std::sync::Arc { use std::sync::Arc; + let target = target.min(MAX_SIM_STEPS); let start = { let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; @@ -604,11 +614,17 @@ impl SimCache { if !had_start { fresh.push((0, Arc::new(st.clone()))); } + let mut every = SIM_CHECKPOINT; while i < target { step(&mut st, i); i += 1; - if i % SIM_CHECKPOINT == 0 && i != target { + if i % every == 0 && i != target { fresh.push((i, Arc::new(st.clone()))); + // A long run keeps a bounded number of states: spread them twice as far apart. + if fresh.len() > MAX_SIM_CHECKPOINTS { + every = every.saturating_mul(2); + fresh.retain(|(s, _)| s % every == 0); + } } } let out = Arc::new(st); @@ -628,19 +644,23 @@ impl SimCache { }; let e = &mut g[idx]; e.used = tick; - for (s, st) in fresh { - e.checkpoints.retain(|(c, _)| *c != s); - e.checkpoints.push((s, st)); - } - // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. - if e.checkpoints.len() > 256 { - e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0); + // Fresh checkpoints replace those at the same step (one pass, not one per checkpoint). + let steps: std::collections::HashSet = fresh.iter().map(|(s, _)| *s).collect(); + e.checkpoints.retain(|(c, _)| !steps.contains(c)); + e.checkpoints.extend(fresh); + // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. The + // state just asked for keeps its slot, so asking for it again is answered from the cache. + if e.checkpoints.len() > MAX_SIM_CHECKPOINTS { + e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0 && *s != target); e.checkpoints.sort_by_key(|(s, _)| *s); - let mut k = 0; + // Drop only the excess, evenly spaced, so the rest keep their seek distance. + let (len, excess) = (e.checkpoints.len(), (e.checkpoints.len() + 1).saturating_sub(MAX_SIM_CHECKPOINTS)); + let mut i = 0; e.checkpoints.retain(|_| { - k += 1; - k % 2 == 1 + i += 1; + (i * excess) / len == ((i - 1) * excess) / len }); + e.checkpoints.push((target, out.clone())); } out } @@ -818,6 +838,45 @@ mod tests { assert_eq!(d, e); } + /// Layer time from a project cannot run a simulation without end, and a long run keeps a + /// bounded number of checkpoints that still give the same answer when seeking. + #[test] + fn sim_cache_bounds_steps_and_checkpoints() { + let c: SimCache = SimCache::new(2); + let count = |s: &mut u64, _: u64| *s += 1; + assert_eq!(*c.run(1, u64::MAX, || 0, count), MAX_SIM_STEPS); + let n = c.inner.lock().map(|g| g.iter().map(|e| e.checkpoints.len()).max().unwrap_or(0)).unwrap_or(0); + assert!(n <= MAX_SIM_CHECKPOINTS + 1, "{n} checkpoints"); + let fresh: SimCache = SimCache::new(2); + let step = |s: &mut u64, i: u64| *s = s.wrapping_mul(6364136223846793005).wrapping_add(i); + let far = *fresh.run(1, 100_000, || 7, step); + let mid = *fresh.run(1, 54_321, || 7, step); + let again: SimCache = SimCache::new(2); + assert_eq!(*again.run(1, 54_321, || 7, step), mid); + assert_eq!(*again.run(1, 100_000, || 7, step), far); + } + + /// A request that leaves the cache over its checkpoint cap still finds its own state there. + #[test] + fn sim_cache_keeps_the_requested_state_when_trimming() { + let c: SimCache = SimCache::new(2); + let steps = std::cell::Cell::new(0u64); + let count = |s: &mut u64, _: u64| { + steps.set(steps.get() + 1); + *s += 1; + }; + let target = SIM_CHECKPOINT * (MAX_SIM_CHECKPOINTS as u64 - 1) + SIM_CHECKPOINT - 1; + assert_eq!(*c.run(1, target, || 0, count), target); + let n = c.inner.lock().map(|g| g.iter().map(|e| e.checkpoints.len()).max().unwrap_or(0)).unwrap_or(0); + assert!(n <= MAX_SIM_CHECKPOINTS, "{n} checkpoints"); + steps.set(0); + assert_eq!(*c.run(1, target, || 0, count), target); + assert_eq!(steps.get(), 0, "the second request is answered from the cache"); + // Only the excess is dropped: the other aligned checkpoints stay. + let kept = c.inner.lock().map(|g| g[0].checkpoints.len()).unwrap_or(0); + assert_eq!(kept, MAX_SIM_CHECKPOINTS, "trimmed to the cap, not halved"); + } + #[test] fn poly_helpers() { let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; From 6ff564e7504322b754305ae4399d17d9634c53f4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:39:06 -0500 Subject: [PATCH 134/227] SVG: bound tessellation, kept geometry, attributes, entity expansion and re-rendering (#2986) * SVG: bound tessellation, kept geometry, attributes, entity expansion and re-rendering Circles and rounded rects flattened at a fixed 0.01 tolerance, so a huge radius made unbounded curves; the tolerance now grows with the radius as arcs already did. Every path a copies and every gradient reference's stops were parsed and kept without a document-wide limit; both now share a budget of 8 million items. An element with thousands of attributes made the XML parser's duplicate check quadratic, and repeated entity references could expand attribute text to gigabytes below the node limit; a preflight refuses more than 1,024 attributes per element and entity references that expand past 64 MiB. Nested knockout and non-isolated groups re-rendered their children several times per level; node draws are now held to 64 per node of the tree. * address review: entity_bytes takes the largest declaration of each name * address review: drop tessellation-tolerance, attribute-count and entity-size refusals; keep retained and draw budgets, warn when the draw budget is hit --- Cargo.lock | 1 + crates/svg/Cargo.toml | 1 + crates/svg/src/lib.rs | 27 +++++++++++++++++++++++++++ crates/svg/src/raster.rs | 32 +++++++++++++++++++++++++++++++- crates/svg/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 991b06154..002edc8d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1939,6 +1939,7 @@ dependencies = [ "effectcraft-path", "effectcraft-raster", "kurbo", + "log", "rayon", "roxmltree", ] diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 0e1af7719..03cacaa7b 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -16,3 +16,4 @@ effectcraft-path = { workspace = true } kurbo = { workspace = true } roxmltree = "0.21" rayon = { workspace = true } +log = { workspace = true } diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 95c00a91a..c31617a92 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -603,8 +603,15 @@ struct Parser<'a, 'i> { /// The `` targets being expanded, outermost first: a reference back into one is a /// cycle and is skipped. using: Vec, + /// Path elements and gradient stops kept so far, `` copies and repeated gradient + /// references included; past [`MAX_RETAINED`] the import errs. + retained: std::cell::Cell, } +/// The most path elements and gradient stops an import keeps: a complex path reused many times, +/// or a gradient with many stops referenced by many shapes, is copied for each use. +const MAX_RETAINED: usize = 8_000_000; + fn is_svg_ns(n: &roxmltree::Node) -> bool { n.is_element() } @@ -628,6 +635,13 @@ impl<'a, 'i> Parser<'a, 'i> { out } + /// Charge `n` kept path elements or gradient stops; false once [`MAX_RETAINED`] is passed. + fn charge_retained(&self, n: usize) -> bool { + let r = self.retained.get().saturating_add(n); + self.retained.set(r); + r <= MAX_RETAINED + } + /// Charge `n` units of CSS work; false once the budget is spent. fn charge_css(&self, n: usize) -> bool { let w = self.css_work.get().saturating_add(n); @@ -875,6 +889,10 @@ impl<'a, 'i> Parser<'a, 'i> { // Stops: from the first element in the chain that has any. let mut stops = vec![]; if let Some(src) = chain.iter().find(|e| e.children().any(|c| c.is_element() && c.tag_name().name() == "stop")) { + // Charged before the stops are read: every reference reads and keeps them again. + if !self.charge_retained(src.children().filter(|c| c.is_element()).count()) { + return None; + } let mut last = 0.0f64; for s in src.children().filter(|c| c.is_element() && c.tag_name().name() == "stop") { let d = self.declared(s); @@ -1026,6 +1044,11 @@ impl<'a, 'i> Parser<'a, 'i> { return; } let Some(geom) = self.geometry(n, &st) else { return }; + if let Geom::Path(p) = &geom + && !self.charge_retained(p.elements().len()) + { + return; + } let fill = if matches!(tag, "line") { None } else { st.fill.as_ref().and_then(|p| self.resolve_paint(p, &st)) }; let (fill, fill_alpha) = match fill { Some((p, a)) => (Some(p), a), @@ -1304,6 +1327,7 @@ pub fn parse(bytes: &[u8]) -> Result { work: 0, budget, using: vec![], + retained: std::cell::Cell::new(0), }; let st = Style::default(); let pct_or = |name: &str, axis: char, def: f64| -> f64 { @@ -1344,6 +1368,9 @@ pub fn parse(bytes: &[u8]) -> Result { // Never a silently partial drawing. return Err(Error::Xml(format!("its references expand to more than {} elements", p.budget))); } + if p.retained.get() > MAX_RETAINED { + return Err(Error::Xml(format!("its paths and gradients, with their copies, hold more than {MAX_RETAINED} elements"))); + } if p.css_work.get() > MAX_CSS_WORK { // Never a silently unstyled drawing. return Err(Error::Xml(format!("matching its