From ba04ab70a0274c022c5139dd08b554f83f387235 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 17:47:39 +0300 Subject: [PATCH 01/94] 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 02/94] 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 03/94] 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 04/94] 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 05/94] 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 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 06/94] 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 07/94] 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 d10991eff3b405aa8f1e3171ece47cde0bd764e2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 14:45:39 +0100 Subject: [PATCH 08/94] AEP import: map HDR Highlight Compression, Echo, Linear Wipe, Offset and Color Stabilizer The importer reported these five effects as lost. They now become EffectCraft's effects of the same name with every parameter After Effects stores (ranges, defaults and popup items from our own After Effects 2026 project, documented in docs/aep-format.md 3.7): - ADBE HDR ToneMap -> ec.utility.hdrcompression (Amount) - ADBE Echo -> ec.time.echo (Echo Time, Number Of Echoes, Starting Intensity, Decay, Echo Operator by item name) - ADBE Linear Wipe -> ec.transition.linearwipe - ADBE Offset -> ec.distort.offset (Blend With Original is a stored 0..1 fraction, a percentage here) - ADBE Deflicker -> ec.color.colorstabilizer (fresh Stabilize default Levels, points, Sample Size) A table row can name the range EffectCraft's effect works in (ParamMap::limit): Echo values or keys beyond 255 echoes or a Decay of 1 are imported as stored and reported. Tests build each effect into the committed synthetic.aep (fx_inject.rs, which also checks its pard layout reads back). On the sample project the effect losses go from 75 to 3 (the out-of-range Echo values). Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/container/effect_map.rs | 62 +++- crates/aep/src/import/effects.rs | 16 +- crates/aep/tests/it/import.rs | 2 + crates/aep/tests/it/import/effects.rs | 2 +- .../aep/tests/it/import/effects_quickwins.rs | 149 ++++++++ crates/aep/tests/it/import/fx_inject.rs | 322 ++++++++++++++++++ docs/aep-format.md | 14 + docs/aep-import.md | 17 + 8 files changed, 581 insertions(+), 3 deletions(-) create mode 100644 crates/aep/tests/it/import/effects_quickwins.rs create mode 100644 crates/aep/tests/it/import/fx_inject.rs diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index a7c9bbd48..5b872450f 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -120,6 +120,10 @@ pub struct ParamMap { /// The EffectCraft parameter id. pub target: &'static str, pub conv: Conv, + /// The range EffectCraft's effect works in when it is narrower than the range After Effects + /// accepts (the effect limits the value there): a stored value or key outside it is imported + /// as it is and reported. `None`: no narrower range. + pub limit: Option<(f64, f64)>, } /// Values of an effect that are not ordinary parameters. @@ -147,7 +151,12 @@ pub struct EffectMap { } const fn pm(index: usize, kind: ParamKind, target: &'static str, conv: Conv) -> ParamMap { - ParamMap { index, kind, target, conv } + ParamMap { index, kind, target, conv, limit: None } +} + +/// [`pm`] for a parameter EffectCraft's effect limits to `lo..=hi` (see [`ParamMap::limit`]). +const fn pm_limited(index: usize, kind: ParamKind, target: &'static str, conv: Conv, lo: f64, hi: f64) -> ParamMap { + ParamMap { index, kind, target, conv, limit: Some((lo, hi)) } } const fn fx(match_name: &'static str, effect: &'static str, params: &'static [ParamMap]) -> EffectMap { @@ -339,6 +348,13 @@ pub const MATTE_USE: &[&str] = &["Red Channel", "Green Channel", "Blue Channel", /// 903. pub const SHUTTER_CONTROL: &[&str] = &["Manual", "Automatic"]; +// After Effects' popup items of the #3110 effects as the 2026-11-05 importer block stores them +// (`pdnm`; the `echo-*` and `colstab-*` compositions). +/// Echo's Echo Operator (`echo-05` ... `echo-10`). +pub const ECHO_OPERATOR: &[&str] = &["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"]; +/// Color Stabilizer's Stabilize (`colstab-02`, `colstab-03`; the fresh default is Levels). +pub const STABILIZE: &[&str] = &["Brightness", "Levels", "Curves"]; + /// The effects the importer converts. pub const EFFECTS: &[EffectMap] = &[ // Tint (356-364): Map Black To, Map White To, Amount to Tint; 4 = Swap Colors (a button). @@ -1006,6 +1022,50 @@ pub const EFFECTS: &[EffectMap] = &[ pm(4, K::FloatSlider, "vectorDetail", Conv::Plain { valid_max: 100.0 }), ], ), + // HDR Highlight Compression (importer block 2026-11-05, `hdr-*`): Amount a float slider in + // percent (valid 0..100, default 100). + fx("ADBE HDR ToneMap", "ec.utility.hdrcompression", &[pm(1, K::FloatSlider, "amount", Conv::Plain { valid_max: 100.0 })]), + // Echo (`echo-*`): Echo Time in seconds (valid -30 000..30 000), Number Of Echoes an integer + // (valid 0..30 000), Starting Intensity 0..1, Decay (valid 0..100, slider 0..1), Echo + // Operator. EffectCraft's Echo uses at most 255 echoes and a Decay up to 1. + fx( + "ADBE Echo", + "ec.time.echo", + &[ + pm(1, K::FloatSlider, "echoTime", Conv::Plain { valid_max: 30000.0 }), + pm_limited(2, K::IntSlider, "numberOfEchoes", Conv::Plain { valid_max: 30000.0 }, 0.0, 255.0), + pm(3, K::FloatSlider, "startingIntensity", Conv::Plain { valid_max: 1.0 }), + pm_limited(4, K::FloatSlider, "decay", Conv::Plain { valid_max: 100.0 }, 0.0, 1.0), + pm(5, K::Popup, "echoOperator", Conv::Popup { default: 1, items: ECHO_OPERATOR }), + ], + ), + // Linear Wipe (`linwipe-*`): Transition Completion and Feather fixed sliders (valid 100 and + // 32 000), Wipe Angle in degrees. + fx( + "ADBE Linear Wipe", + "ec.transition.linearwipe", + &[ + pm(1, K::FixedSlider, "completion", Conv::Plain { valid_max: 100.0 }), + pm(2, K::Angle, "angle", Conv::Angle), + pm(3, K::FixedSlider, "feather", Conv::Plain { valid_max: 32000.0 }), + ], + ), + // Offset (`offset-*`): Blend With Original is a 0..1 fraction, a percentage here. + fx("ADBE Offset", "ec.distort.offset", &[pm(1, K::Point, "shift", Conv::Point), pm(2, K::FixedSlider, "blend", Conv::Percent { valid_max: 1.0 })]), + // Color Stabilizer (`colstab-*`): Stabilize (fresh default Levels), the three sample points, + // Sample Size a fixed slider (valid 50, default 3). The reference frame (Set Frame) is not a + // parameter in the file and is not imported. + fx( + "ADBE Deflicker", + "ec.color.colorstabilizer", + &[ + pm(1, K::Popup, "stabilize", Conv::Popup { default: 2, items: STABILIZE }), + pm(2, K::Point, "blackPoint", Conv::Point), + pm(3, K::Point, "midPoint", Conv::Point), + pm(4, K::Point, "whitePoint", Conv::Point), + pm(5, K::FixedSlider, "sampleSize", Conv::Plain { valid_max: 50.0 }), + ], + ), // Layer Control (076, 220, 320, 321): the default is the owning layer, None is unset. fx("ADBE Layer Control", "ec.control.layer", &[pm(1, K::Layer, "layer", Conv::LayerRef { own: true })]), fx("ADBE Slider Control", "ec.control.slider", &[pm(1, K::FloatSlider, "slider", Conv::Plain { valid_max: 1_000_000.0 })]), diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 477a25df4..1d20a9dd1 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -266,6 +266,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_> if let Some(map) = &popup_map { remap_popup_keys(prop, map, &here, &label, col); } + report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), &here, &label, col); } return; } @@ -298,11 +299,24 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_> _ => convert(value, kind), }; match converted { - Some(v) => prop.value = v, + Some(v) => { + report_beyond_limit(m.limit, std::iter::once(&v), &here, &label, col); + prop.value = v; + } None => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?}), default used")), } } +/// Reports a value After Effects accepts but EffectCraft's effect limits (`ParamMap::limit`): it is +/// imported as it is, and the effect renders it as the nearest end of its range. +fn report_beyond_limit<'v>(limit: Option<(f64, f64)>, values: impl Iterator, here: &str, label: &str, col: &mut Collector) { + let Some((lo, hi)) = limit else { return }; + let beyond: Vec = values.filter_map(|v| if let Value::Scalar(x) = v { Some(*x) } else { None }).filter(|x| *x < lo || *x > hi).collect(); + if let Some(x) = beyond.first() { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} is {x}, EffectCraft's effect uses {lo} to {hi} (rendered as the nearest of them)")); + } +} + /// A layer parameter: None stays unset, the owning layer is itself (`own`) or unset, another layer /// is resolved by [`resolve_layer_refs`] once the composition's layers exist. /// `names` is `(here, label)`, the effect and the parameter for the report. diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 95dced570..178dd2bb3 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -11,8 +11,10 @@ mod combined; mod ease_round2; mod effects; mod effects_batch5; +mod effects_quickwins; mod effects_round2; mod effects_round3; +mod fx_inject; mod markers; mod masks; mod mattes; diff --git a/crates/aep/tests/it/import/effects.rs b/crates/aep/tests/it/import/effects.rs index be1293b9a..aa4180567 100644 --- a/crates/aep/tests/it/import/effects.rs +++ b/crates/aep/tests/it/import/effects.rs @@ -72,7 +72,7 @@ fn the_table_names_tint_gradient_ramp_and_exposure() { ] { assert!(t.contains(&want), "{want:?}"); } - assert_eq!(t.len(), 57); + assert_eq!(t.len(), 62); for (_, id) in t { assert!(effectcraft_effects::find(id).is_some(), "{id}"); } diff --git a/crates/aep/tests/it/import/effects_quickwins.rs b/crates/aep/tests/it/import/effects_quickwins.rs new file mode 100644 index 000000000..792e08d74 --- /dev/null +++ b/crates/aep/tests/it/import/effects_quickwins.rs @@ -0,0 +1,149 @@ +//! HDR Highlight Compression, Echo, Linear Wipe, Offset and Color Stabilizer (#3110) arrive as +//! EffectCraft's effects of the same name. Each is built into `synthetic.aep` (`fx_inject`) with +//! the definitions After Effects 2026 wrote for it in the importer block of 2026-11-05 (valid +//! ranges, defaults, popup items) and the values of that block's compositions. + +use crate::import::fx_inject::{Default, Fx, losses, near, num, other, popup, pt, run, slider, v}; +use effectcraft_aep::ParamKind as K; +use effectcraft_aep::import::LossCategory; +use effectcraft_keyframe::Value; + +const ECHO_OPS: &[&str] = &["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"]; + +fn echo(values: Vec<(usize, f64)>) -> Fx { + Fx { + match_name: "ADBE Echo", + name: "Echo", + defs: vec![ + slider(K::FloatSlider, "Echo Time (seconds)", -1.0 / 30.0, (-30000.0, 30000.0)), + slider(K::IntSlider, "Number Of Echoes", 1.0, (0.0, 30000.0)), + slider(K::FloatSlider, "Starting Intensity", 1.0, (0.0, 1.0)), + slider(K::FloatSlider, "Decay", 1.0, (0.0, 100.0)), + popup("Echo Operator", 1, ECHO_OPS), + ], + values, + data: vec![], + } +} + +#[test] +fn hdr_highlight_compression_amount() { + let fx = |values| Fx { + match_name: "ADBE HDR ToneMap", + name: "HDR Highlight Compression", + defs: vec![slider(K::FloatSlider, "Amount", 100.0, (0.0, 100.0))], + values, + data: vec![], + }; + // `hdr-01-set-all`: 90; `hdr-00-defaults`: no record, 100. + for (values, want) in [(vec![(1, 90.0)], 90.0), (vec![], 100.0)] { + let (g, r) = run(&fx(values), "ec.utility.hdrcompression"); + assert_eq!(losses(&r, LossCategory::Effects, "ADBE HDR ToneMap"), Vec::::new()); + near(&g.expect("imported"), "amount", want); + } +} + +#[test] +fn echo_values_and_operator() { + // `echo-01-set-all` (without its Decay 20, below) and `echo-09-pop5-6`. + let (g, r) = run(&echo(vec![(1, 9.967), (2, 14.0), (3, 0.0), (5, 6.0)]), "ec.time.echo"); + assert_eq!(losses(&r, LossCategory::Effects, "ADBE Echo"), Vec::::new()); + let g = g.expect("imported"); + near(&g, "echoTime", 9.967); + near(&g, "numberOfEchoes", 14.0); + near(&g, "startingIntensity", 0.0); + near(&g, "decay", 1.0); + assert_eq!(v(&g, "echoOperator"), Value::Enum(5), "Composite In Front"); + // `echo-00-defaults`: one echo a frame back (1/30 s), Add. + let (g, _) = run(&echo(vec![]), "ec.time.echo"); + let g = g.expect("imported"); + assert!((num(&g, "echoTime") + 1.0 / 30.0).abs() < 1e-6); + assert_eq!((num(&g, "numberOfEchoes"), v(&g, "echoOperator")), (1.0, Value::Enum(0))); +} + +#[test] +fn echo_values_beyond_effectcrafts_range_are_reported() { + // `echo-03-max`: 30 000 echoes and Decay 100; EffectCraft's Echo uses at most 255 and 1. + let (g, r) = run(&echo(vec![(2, 30000.0), (4, 100.0)]), "ec.time.echo"); + let found = losses(&r, LossCategory::Effects, "ADBE Echo"); + assert_eq!(found.len(), 2, "{found:?}"); + assert!(found.iter().any(|d| d.contains("Number Of Echoes") && d.contains("0 to 255")), "{found:?}"); + assert!(found.iter().any(|d| d.contains("Decay") && d.contains("0 to 1")), "{found:?}"); + // Kept as stored. + let g = g.expect("still imported"); + near(&g, "numberOfEchoes", 30000.0); + near(&g, "decay", 100.0); +} + +#[test] +fn linear_wipe_values() { + let fx = |values| Fx { + match_name: "ADBE Linear Wipe", + name: "Linear Wipe", + defs: vec![ + slider(K::FixedSlider, "Transition Completion", 0.0, (0.0, 100.0)), + other(K::Angle, "Wipe Angle", Default::Number(90.0)), + slider(K::FixedSlider, "Feather", 0.0, (0.0, 32000.0)), + ], + values, + data: vec![], + }; + // `linwipe-01-set-all`. + let (g, r) = run(&fx(vec![(1, 10.0), (2, 77.0), (3, 16.0)]), "ec.transition.linearwipe"); + assert_eq!(losses(&r, LossCategory::Effects, "ADBE Linear Wipe"), Vec::::new()); + let g = g.expect("imported"); + near(&g, "completion", 10.0); + near(&g, "angle", 77.0); + near(&g, "feather", 16.0); + // `linwipe-00-defaults`: the Wipe Angle default 90. + near(&run(&fx(vec![]), "ec.transition.linearwipe").0.expect("imported"), "angle", 90.0); +} + +#[test] +fn offset_blend_is_a_fraction_and_the_centre_a_point() { + let fx = |values| Fx { + match_name: "ADBE Offset", + name: "Offset", + defs: vec![other(K::Point, "Shift Center To", Default::Point(0.5, 0.5)), slider(K::FixedSlider, "Blend With Original", 0.0, (0.0, 1.0))], + values, + data: vec![], + }; + // `offset-02-chk-toggled`: Blend With Original stored 1.0 is 100 %. + let (g, r) = run(&fx(vec![(2, 1.0)]), "ec.distort.offset"); + assert_eq!(losses(&r, LossCategory::Effects, "ADBE Offset"), Vec::::new()); + let g = g.expect("imported"); + near(&g, "blend", 100.0); + // The default centre is the layer's centre (synthetic.aep's layer is 8x8). + pt(&g, "shift", [4.0, 4.0]); + near(&run(&fx(vec![(2, 0.25)]), "ec.distort.offset").0.expect("imported"), "blend", 25.0); +} + +#[test] +fn color_stabilizer_points_sample_size_and_stabilize() { + let fx = |values| Fx { + match_name: "ADBE Deflicker", + name: "Color Stabilizer", + defs: vec![ + popup("Stabilize", 2, &["Brightness", "Levels", "Curves"]), + other(K::Point, "Black Point", Default::Point(0.2, 0.5)), + other(K::Point, "Mid Point", Default::Point(0.5, 0.5)), + other(K::Point, "White Point", Default::Point(0.8, 0.5)), + slider(K::FixedSlider, "Sample Size", 3.0, (0.0, 50.0)), + ], + values, + data: vec![], + }; + // `colstab-00-defaults`: Levels, the points at 20 %, 50 % and 80 % of the width. + let (g, r) = run(&fx(vec![]), "ec.color.colorstabilizer"); + assert_eq!(losses(&r, LossCategory::Effects, "ADBE Deflicker"), Vec::::new()); + let g = g.expect("imported"); + assert_eq!(v(&g, "stabilize"), Value::Enum(1), "Levels"); + pt(&g, "blackPoint", [1.6, 4.0]); + pt(&g, "whitePoint", [6.4, 4.0]); + near(&g, "sampleSize", 3.0); + // `colstab-03-pop1-3` and `colstab-27-pop5-25`. + let (g, _) = run(&fx(vec![(1, 3.0), (5, 25.0)]), "ec.color.colorstabilizer"); + let g = g.expect("imported"); + assert_eq!(v(&g, "stabilize"), Value::Enum(2), "Curves"); + near(&g, "sampleSize", 25.0); +} diff --git a/crates/aep/tests/it/import/fx_inject.rs b/crates/aep/tests/it/import/fx_inject.rs new file mode 100644 index 000000000..9687e2d6c --- /dev/null +++ b/crates/aep/tests/it/import/fx_inject.rs @@ -0,0 +1,322 @@ +//! Builds an effect instance into the committed `synthetic.aep` in place of its Glow (the instance +//! on the layer and the project's shared definitions), so an effect mapping can be tested without +//! the external corpus. The parameter definitions are written in the `pard` layout of +//! docs/aep-format.md 3.7 (type at byte 15, name at 16, default at 56, a float slider's valid +//! range as `f32` at 104 / 108 and its slider range at 112 / 116, a fixed or integer slider's valid +//! maximum at 128, a popup's item count at 60); `pard_layout_decodes_as_written` reads them back. +//! The ranges and defaults the tests use are the ones After Effects 2026 wrote in the importer +//! block of 2026-11-05 (`1100-quickwins.aep`, `1300-thirdparty.aep`, own projects). + +use effectcraft_aep::import::{ImportOptions, ImportResult, LossCategory, import}; +use effectcraft_aep::{Body, Chunk, ParamKind, ParamValue, Project as AepProject, parse, write}; +use effectcraft_keyframe::Value; +use effectcraft_project::{Project, PropGroup}; + +const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep"); + +/// A parameter definition. +#[derive(Clone)] +pub struct Def { + pub kind: ParamKind, + pub name: &'static str, + pub default: Default, + /// A float slider's valid range, a fixed or integer slider's valid maximum (`.1`). + pub valid: (f64, f64), + /// A popup's items. + pub items: &'static [&'static str], +} + +/// The default a definition holds at byte 56. +#[derive(Clone, Copy)] +pub enum Default { + None, + Number(f64), + Index(u32), + Point(f64, f64), + /// ARGB 0..255. + Color([u8; 4]), +} + +fn kind_code(k: ParamKind) -> u8 { + match k { + ParamKind::Layer => 0, + ParamKind::IntSlider => 1, + ParamKind::FixedSlider => 2, + ParamKind::Angle => 3, + ParamKind::Checkbox => 4, + ParamKind::Color => 5, + ParamKind::Point => 6, + ParamKind::Popup => 7, + ParamKind::NoData => 9, + ParamKind::FloatSlider => 10, + ParamKind::ArbitraryData => 11, + ParamKind::GroupStart => 13, + ParamKind::GroupEnd => 14, + ParamKind::Button => 15, + _ => 9, + } +} + +fn fixed(v: f64) -> [u8; 4] { + ((v * 65536.0).round() as i32).to_be_bytes() +} + +pub fn pard(d: &Def) -> Chunk<'static> { + let mut b = vec![0u8; 148]; + b[15] = kind_code(d.kind); + b[16..16 + d.name.len()].copy_from_slice(d.name.as_bytes()); + match (d.kind, d.default) { + (ParamKind::FloatSlider, Default::Number(v)) => b[56..64].copy_from_slice(&v.to_be_bytes()), + (ParamKind::FixedSlider | ParamKind::Angle, Default::Number(v)) => b[56..60].copy_from_slice(&fixed(v)), + (ParamKind::IntSlider | ParamKind::Checkbox, Default::Number(v)) => b[56..60].copy_from_slice(&(v as i32).to_be_bytes()), + (ParamKind::Popup, Default::Index(i)) => b[56..60].copy_from_slice(&i.to_be_bytes()), + (ParamKind::Point, Default::Point(x, y)) => { + b[56..60].copy_from_slice(&fixed(x)); + b[60..64].copy_from_slice(&fixed(y)); + } + (ParamKind::Color, Default::Color(c)) => b[56..60].copy_from_slice(&c), + _ => {} + } + match d.kind { + ParamKind::FloatSlider => { + b[104..108].copy_from_slice(&(d.valid.0 as f32).to_be_bytes()); + b[108..112].copy_from_slice(&(d.valid.1 as f32).to_be_bytes()); + b[112..116].copy_from_slice(&(d.valid.0 as f32).to_be_bytes()); + b[116..120].copy_from_slice(&(d.valid.1 as f32).to_be_bytes()); + } + ParamKind::FixedSlider => b[128..132].copy_from_slice(&fixed(d.valid.1)), + ParamKind::IntSlider => b[128..132].copy_from_slice(&(d.valid.1 as i32).to_be_bytes()), + ParamKind::Popup => b[60..62].copy_from_slice(&(d.items.len() as u16).to_be_bytes()), + _ => {} + } + Chunk::data(*b"pard", b) +} + +/// A match name chunk (`tdmn`, 40 bytes). +pub fn tdmn(name: &str) -> Chunk<'static> { + let mut b = name.as_bytes().to_vec(); + b.resize(40, 0); + Chunk::data(*b"tdmn", b) +} + +fn utf8(s: &str) -> Chunk<'static> { + Chunk::data(*b"Utf8", s.as_bytes().to_vec()) +} + +/// A chunk whose body is chunks without being a `LIST` (`pdnm`, `fnam`: `NESTED_IDS`). +fn nested(id: [u8; 4], children: Vec>) -> Chunk<'static> { + Chunk { id, body: Body::Chunks(children), pad: None } +} + +/// A static value record holding `v`, after the one After Effects wrote for Glow's A & B Midpoint +/// (its header; without the expression). +fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> { + let mut kids = Vec::new(); + for c in template.children() { + match &c.body { + Body::Data(d) if c.id == *b"tdsb" || c.id == *b"tdb4" => kids.push(Chunk::data(c.id, d.to_vec())), + Body::Data(_) if c.id == *b"cdat" => { + let mut d = vec![0u8; 40]; + d[..8].copy_from_slice(&v.to_be_bytes()); + kids.push(Chunk::data(*b"cdat", d)); + } + _ => {} + } + } + Chunk::list(*b"tdbs", kids) +} + +/// An effect instance: its match name, display name, parameter definitions (after the input +/// layer, before the built-in entry), the 1-D values stored (`parT` index, value) and the +/// arbitrary data stored (`parT` index, bytes). +pub struct Fx { + pub match_name: &'static str, + pub name: &'static str, + pub defs: Vec, + pub values: Vec<(usize, f64)>, + pub data: Vec<(usize, Vec)>, +} + +fn param_name(fx: &Fx, i: usize) -> String { + format!("{}-{i:04}", fx.match_name) +} + +fn sspc(fx: &Fx, template: &Chunk<'_>) -> Chunk<'static> { + let n = fx.defs.len() + 2; + let mut part = vec![ + Chunk::data(*b"parn", (n as u32).to_be_bytes().to_vec()), + tdmn(¶m_name(fx, 0)), + pard(&Def { kind: ParamKind::Layer, name: "", default: Default::None, valid: (0.0, 0.0), items: &[] }), + ]; + for (i, d) in fx.defs.iter().enumerate() { + part.push(tdmn(¶m_name(fx, i + 1))); + part.push(pard(d)); + if d.kind == ParamKind::Popup { + part.push(nested(*b"pdnm", vec![utf8(&d.items.join("|"))])); + } + } + part.push(tdmn("ADBE Effect Built In Params")); + part.push(pard(&Def { kind: ParamKind::NoData, name: "", default: Default::None, valid: (0.0, 0.0), items: &[] })); + let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec())]; + for (i, v) in &fx.values { + values.push(tdmn(¶m_name(fx, *i))); + values.push(record(template, *v)); + if let Some((_, d)) = fx.data.iter().find(|(j, _)| j == i) { + values.push(Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", d.clone())])); + } + } + Chunk::list(*b"sspc", vec![nested(*b"fnam", vec![utf8(fx.name)]), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)]) +} + +/// The value record of Glow's A & B Midpoint (`ADBE Glo2-0011`): a 1-D static record. +fn find_glow_template(chunks: &[Chunk<'_>]) -> Option> { + for c in chunks { + let kids = c.children(); + if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2-0011\0"))) + && let Some(record) = kids.get(at + 1).filter(|k| k.is_list(b"tdbs")) + { + return Some(Chunk::list( + *b"tdbs", + record + .children() + .iter() + .map(|k| match &k.body { + Body::Data(d) => Chunk::data(k.id, d.to_vec()), + _ => Chunk::data(k.id, Vec::new()), + }) + .collect(), + )); + } + if let Some(t) = find_glow_template(kids) { + return Some(t); + } + } + None +} + +fn replace_glow(chunks: &mut [Chunk<'_>], fx: &Fx, template: &Chunk<'_>) -> usize { + let mut n = 0; + for ch in chunks { + let kids = ch.children_mut(); + if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0"))) + && kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc")) + { + kids[at] = tdmn(fx.match_name); + kids[at + 1] = sspc(fx, template); + n += 1; + } + n += replace_glow(kids, fx, template); + } + n +} + +/// `synthetic.aep` with `fx` in place of the Glow effect. +pub fn bytes_with(fx: &Fx) -> Vec { + let file = parse(SYNTHETIC).unwrap(); + let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record"); + let mut file = parse(SYNTHETIC).unwrap(); + assert_eq!(replace_glow(&mut file.chunks, fx, &template), 2, "the Glow effect and its shared definitions"); + write(&file).unwrap() +} + +/// Imports `synthetic.aep` with `fx` in place of its Glow: the EffectCraft effect `effect` (if +/// imported) and the result. +pub fn run(fx: &Fx, effect: &str) -> (Option, ImportResult) { + let bytes = bytes_with(fx); + let mut project = Project::default(); + let none = |_: &str| false; + let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); + let g = project + .comps() + .flat_map(|(_, c)| c.layers.iter()) + .find_map(|l| l.props.sub("effects").and_then(|fx| fx.children.iter().filter_map(|n| n.as_group()).find(|g| g.match_id == effect).cloned())); + (g, r) +} + +/// The losses of `category` that name the effect `match_name`. +pub fn losses(r: &ImportResult, category: LossCategory, match_name: &str) -> Vec { + let tag = format!("({match_name})"); + r.report.losses.iter().filter(|l| l.category == category).flat_map(|l| l.details.clone()).filter(|d| d.contains(&tag)).collect() +} + +pub fn v(g: &PropGroup, id: &str) -> Value { + g.prop(id).unwrap_or_else(|| panic!("no {id} in {}", g.match_id)).value.clone() +} + +pub fn num(g: &PropGroup, id: &str) -> f64 { + match v(g, id) { + Value::Scalar(x) => x, + other => panic!("{id}: {other:?}"), + } +} + +pub fn near(g: &PropGroup, id: &str, want: f64) { + let got = num(g, id); + assert!((got - want).abs() <= want.abs().max(1.0) * 1e-6, "{} {id}: {got} != {want}", g.match_id); +} + +pub fn pt(g: &PropGroup, id: &str, want: [f64; 2]) { + let Value::Vec2(a) = v(g, id) else { panic!("{id}") }; + assert!((a[0] - want[0]).abs() < 1e-2 && (a[1] - want[1]).abs() < 1e-2, "{} {id}: {a:?} != {want:?}", g.match_id); +} + +/// A slider definition. +pub fn slider(kind: ParamKind, name: &'static str, default: f64, valid: (f64, f64)) -> Def { + Def { kind, name, default: Default::Number(default), valid, items: &[] } +} + +pub fn popup(name: &'static str, default: u32, items: &'static [&'static str]) -> Def { + Def { kind: ParamKind::Popup, name, default: Default::Index(default), valid: (0.0, 0.0), items } +} + +pub fn other(kind: ParamKind, name: &'static str, default: Default) -> Def { + Def { kind, name, default, valid: (0.0, 0.0), items: &[] } +} + +#[test] +fn pard_layout_decodes_as_written() { + // Echo's Decay (float, valid 0..100, default 1), Linear Wipe's Feather (fixed, valid max + // 32 000), Echo's Number Of Echoes (integer, max 30 000, default 1), Offset's centre point, + // Color Stabilizer's Stabilize (default 2 of 3): what the sample dump of 2026-11-05 read. + let fx = Fx { + match_name: "Test FX", + name: "Test FX", + defs: vec![ + slider(ParamKind::FloatSlider, "Decay", 1.0, (0.0, 100.0)), + slider(ParamKind::FixedSlider, "Feather", 0.0, (0.0, 32000.0)), + slider(ParamKind::IntSlider, "Number Of Echoes", 1.0, (0.0, 30000.0)), + other(ParamKind::Point, "Shift Center To", Default::Point(0.5, 0.5)), + popup("Stabilize", 2, &["Brightness", "Levels", "Curves"]), + other(ParamKind::Color, "Shadows Unbalance", Default::Color([0, 0, 0, 0])), + ], + values: vec![(1, 20.0)], + data: vec![], + }; + let bytes = bytes_with(&fx); + let file = parse(&bytes).unwrap(); + let proj = AepProject::new(&file).unwrap(); + let defs = proj.effect_definition_index(); + let found = proj + .all_items() + .into_iter() + .filter_map(|i| i.comp()) + .flat_map(|c| c.layers().collect::>()) + .flat_map(|l| l.effects()) + .find(|e| e.match_name() == "Test FX") + .expect("the effect"); + let p = defs.params(&found); + let by = |i: usize| p.iter().find(|q| q.index == i).expect("parameter").clone(); + let decay = by(1); + assert_eq!( + (decay.kind, decay.name.as_str(), decay.valid_min(), decay.valid_max(), decay.pard_default()), + (ParamKind::FloatSlider, "Decay", Some(0.0), Some(100.0), Some(ParamValue::Number(1.0))) + ); + assert_eq!(decay.stored_value(), Some(ParamValue::Number(20.0))); + assert_eq!((by(2).kind, by(2).valid_max(), by(2).pard_default()), (ParamKind::FixedSlider, Some(32000.0), Some(ParamValue::Number(0.0)))); + assert_eq!((by(3).kind, by(3).valid_max(), by(3).pard_default()), (ParamKind::IntSlider, Some(30000.0), Some(ParamValue::Number(1.0)))); + assert_eq!(by(4).pard_default(), Some(ParamValue::Point([0.5, 0.5]))); + let stab = by(5); + assert_eq!((stab.popup_item_count(), stab.items.clone()), (Some(3), Some(vec!["Brightness".to_string(), "Levels".into(), "Curves".into()]))); + assert_eq!(found.display_name().as_deref(), Some("Test FX")); + assert_eq!(by(6).pard_default(), Some(ParamValue::Argb([0.0, 0.0, 0.0, 0.0]))); +} diff --git a/docs/aep-format.md b/docs/aep-format.md index fcc8f53be..32ec74854 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -519,6 +519,20 @@ popup_selected_item, borrowed_definition}`, `Effect::has_own_definitions`, (356, 357, 361, 363, 364, 366, 368, 369, 371, 374, 377, 378, 380, 382, 383), `tests/effects_exposure.rs` (389-391, 393-397). +**Importer block 2026-11-05, #3110 (`1100-quickwins.aep`, After Effects 26.2.1; V for every +row: defaults from the defaults composition, the valid ranges from the minimum and maximum +compositions, the items from one composition per popup item).** Each set value is stored as set +(Decay 20 is `20.0`, Blend With Original 100 % is `1.0`); a value set to the default leaves no record +(HDR Amount 100): + +| Effect (match name) | Parameters (`parT` index: type, valid range, default) | +|---|---| +| `ADBE HDR ToneMap` (HDR Highlight Compression) | 1 Amount: float, 0..100 (slider 0..100), default 100 | +| `ADBE Echo` | 1 Echo Time (seconds): float, -30 000..30 000 (slider -5..5), default -1/30 (float32); 2 Number Of Echoes: integer, max 30 000, default 1; 3 Starting Intensity: float, 0..1, default 1; 4 Decay: float, **valid 0..100, slider 0..1**, default 1; 5 Echo Operator: popup (Add, Maximum, Minimum, Screen, Composite In Back, Composite In Front, Blend), default 1 | +| `ADBE Linear Wipe` | 1 Transition Completion: fixed, max 100, default 0; 2 Wipe Angle: angle, default 90; 3 Feather: fixed, max 32 000, default 0 | +| `ADBE Offset` | 1 Shift Center To: point, default the layer centre; 2 Blend With Original: fixed, **max 1 (a 0..1 fraction)**, default 0 | +| `ADBE Deflicker` (Color Stabilizer) | 1 Stabilize: popup (Brightness, Levels, Curves), **default 2 (Levels)**; 2 Black Point, 3 Mid Point, 4 White Point: points, defaults 0.2 / 0.5 / 0.8 of the width at half the height; 5 Sample Size: fixed, max 50, default 3. The reference frame the Set Frame button picks is not among the parameters | + **Curves and Mesh Warp** (961-965, 985; applied from presets, the data is not scriptable): their arbitrary-data parameter (type 11) always has a record (`tdbs` with a 4-byte `cdat`) and a `LIST aRbs` / `aRbp`, also at the default. Curves (`ADBE CurvesCustom-0001`, 1644 bytes): `u16` 1, 1; diff --git a/docs/aep-import.md b/docs/aep-import.md index baeb8d0e1..1926274f0 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -178,6 +178,7 @@ parameter gets the file's value: | `ADBE HUE SATURATION` 1 Channel Control, 2 Channel Range data, 6 Colorize, 7 Colorize Hue, 8 Colorize Saturation, 9 Colorize Lightness | `channelControl`; `hue`, `saturation`, `lightness` from the data; `colorize`, `colorizeHue`, `colorizeSaturation`, `colorizeLightness` | popup by name; the master hue (degrees), saturation and lightness are the `i32` of the arbitrary data (the visible Master parameters keep no record); the colour ranges are reported when they are not at their defaults (not sampled) | 443-450 | | `ADBE Easy Levels2` 1 Channel, 2 Histogram data, 8 / 9 Clip To Output Black / White | `channel`; `inBlack`, `inWhite`, `gamma`, `outBlack`, `outWhite` from the data; `clipToOutputBlack`, `clipToOutputWhite` | popups by name (After Effects' items On, Off, Off for 32 bpc Color are in another order here); the RGB values are the `f32` of the arbitrary data; the per-channel values are reported when they are not at their defaults (not sampled) | 511-516 | | Batch 6 (903 defaults, 904 / 905 set values): `ADBE Brightness & Contrast 2`, `ADBE Turbulent Displace`, `ADBE Fractal Noise`, `ADBE Laser` (Beam), `CC Radial Fast Blur`, `ADBE Noise2`, `ADBE Pro Levels2` (Levels (Individual Controls)), `ADBE Corner Pin`, `ADBE Grid`, `ADBE Sharpen`, `ADBE Linear Color Key2`, `ADBE Radial Wipe`, `ADBE Remove Color Matting`, `CC Bend It`, `CC Power Pin`, `ADBE 4ColorGradient`, `ADBE Change Color`, `ADBE Extract`, `ADBE Noise HLS2`, `ADBE Scatter`, `CC Light Sweep`, `ADBE Compound Blur`, `ADBE Displacement Map`, `ADBE Set Matte3`, `ADBE Gaussian Blur` (legacy), `ADBE OFMotionBlur` (Pixel Motion Blur), `ADBE Layer Control` | the EffectCraft effect of the same name, every parameter EffectCraft has | sliders as stored (each checked against its valid maximum); Beam Length, Time and Softness and CC Power Pin Perspective are 0..1 fractions, percentages here; points as layer pixels; popups by item name (Set Matte's `Red Channel` ... is `Red` ...); twirl-down group markers and the Levels histogram carry nothing; **layer parameters** (below) | 903, 904, 905; 220, 320, 321 | +| Importer block 2026-11-05 (`1100-quickwins.aep`: per effect defaults, set values, minimum and maximum, every popup item, a keyed and an expression parameter): `ADBE HDR ToneMap` (HDR Highlight Compression) 1 Amount; `ADBE Echo` 1 Echo Time, 2 Number Of Echoes, 3 Starting Intensity, 4 Decay, 5 Echo Operator; `ADBE Linear Wipe` 1 Transition Completion, 2 Wipe Angle, 3 Feather; `ADBE Offset` 1 Shift Center To, 2 Blend With Original; `ADBE Deflicker` (Color Stabilizer) 1 Stabilize, 2-4 Black / Mid / White Point, 5 Sample Size | `ec.utility.hdrcompression` `amount`; `ec.time.echo` `echoTime`, `numberOfEchoes`, `startingIntensity`, `decay`, `echoOperator`; `ec.transition.linearwipe` `completion`, `angle`, `feather`; `ec.distort.offset` `shift`, `blend`; `ec.color.colorstabilizer` `stabilize`, `blackPoint`, `midPoint`, `whitePoint`, `sampleSize` | percent (float, valid 0..100, default 100); seconds (float, valid ±30 000), integer (valid 30 000), 0..1 (float), Decay as stored (float, valid 0..100), popup by name: EffectCraft's Echo uses at most 255 echoes and a Decay up to 1, a value or key beyond that is imported as stored and **reported**; percent (fixed, 100), degrees, pixels (fixed, 32 000); point in layer pixels, Blend 0..1 x 100 = percent; popup by name (**fresh default Levels**), points in layer pixels, Sample Size as stored (fixed, valid 50, default 3; its unit, pixels here, is not verified). Color Stabilizer's reference frame (Set Frame) is not a parameter in the file and is not imported (EffectCraft's Reference Frame stays 0 s) | `tests/it/import/effects_quickwins.rs`; render comparison below | | `ADBE Slider Control`, `ADBE Angle Control`, `ADBE Checkbox Control`, `ADBE Color Control`, `ADBE Point Control` (parameter 1) | `ec.control.slider` `slider`, `ec.control.angle` `angle`, `ec.control.checkbox` `checkbox`, `ec.control.color` `color`, `ec.control.point` `point` | float (valid max 1 000 000) as stored; degrees; checkbox; colour; point in layer pixels | 071, 073, 217-219, 221, 304, 310 | - **Nothing to import:** the implicit input-layer parameter (index 0), the Swap Colors buttons @@ -308,6 +309,22 @@ frame blending, 3D material options, audio levels, guides, the mask colour, lock between them (363: 50 at 1 s; 364: grey at 1 s; 382: [950, 500] at 1 s), the defaults equal to EffectCraft's, stack order with a reported Gaussian Blur (366), the second instance (383), and lossless imports without `allowLosses`. +- `crates/aep/tests/it/import/effects_quickwins.rs` (#3110): HDR Highlight Compression, Echo, + Linear Wipe, Offset and Color Stabilizer, each built into `synthetic.aep` (`fx_inject.rs`, which + also checks that its `pard` layout reads back) with the definitions and values of the importer + block of 2026-11-05. Render check of that block's 75 compositions (320x180, 32 bpc, working space + None, 0.5 s) against After Effects' frames (its `saveFrameToPng` of a 32 bpc project writes + `floor(value x 25)` per channel, so values are compared at that quantisation): Offset (5), Linear + Wipe defaults / min / max, Echo with the Add, Maximum, Minimum, Screen operators and the keyed and + expression variants, Color Stabilizer with every Stabilize item and Sample Size up to 25 match + within one level. Differences, all in EffectCraft's effects and not in the mapping: HDR + Highlight Compression's curve (up to 6 of 25 levels on 19 % of the pixels at Amount 100, equal + at Amount 0); Echo's Blend operator (After Effects' frame is as bright as Add's, EffectCraft + averages) and the edge of the moving square with Composite In Back / Front (0.5 % of the + pixels); Linear Wipe's feathered edge (0.6 % of the pixels); Color Stabilizer with a Sample Size + over 25 (EffectCraft's samples drift, After Effects' frame stays unchanged) and with keyed + sample points. The renders do not exercise a Decay above 1 (After Effects' set value has + Starting Intensity 0) or what Color Stabilizer corrects (the test card does not flicker). - `crates/aep/tests/it/import/combined.rs`: masks and effects on the same layer, an effect on a text layer, and the same through `.aepx` (built in-test from the fixtures: the effect parade of one fixture grafted onto the layer of another). From 77e91199e88ff4aa353990f08b8b725e601fca47 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 14:55:01 +0100 Subject: [PATCH 09/94] AEP import: map CC Color Neutralizer, EXtractoR and IDentifier The importer reported these three plug-ins as lost effects. They now become EffectCraft's effects of the same name (parameter lists and data layouts from our own After Effects 2026 project, docs/aep-format.md 3.7): - CS Color Neutralizer -> ec.color.cccolorneutralizer: Pinning and Blend w. Original. After Effects' correction is the per-range Red / Green / Blue sliders (the Unbalance colours are pickers that keep no record), plus View and Black / White Point; EffectCraft's effect has none of them, so each is reported when stored. - EXtractoR -> ec.3d.extractor: Black / White Point, UnMult, and the default Channel Info (R, G, B, (copy)) as EffectCraft's R, G, B, A (ExtractorChannels). Other channels are reported: only the default layout is sampled. - IDentifier -> ec.3d.identifier: Display by item name, ID. Its Channel Info names only the channel type (Object ID, identifier_channel_type), not the channel After Effects reads; After Effects left the sampled EXR unchanged where EffectCraft draws the footage's object ID channel, so every instance is reported. On the sample project no effect is lost any more (29 before); the parameters above are reported instead. Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/container.rs | 2 +- crates/aep/src/container/arbitrary.rs | 51 +++++ crates/aep/src/container/effect_map.rs | 42 ++++ crates/aep/src/import/effects.rs | 38 +++- crates/aep/src/lib.rs | 14 +- crates/aep/tests/it/import.rs | 1 + crates/aep/tests/it/import/effects.rs | 2 +- .../aep/tests/it/import/effects_thirdparty.rs | 179 ++++++++++++++++++ docs/aep-format.md | 16 ++ docs/aep-import.md | 13 ++ 10 files changed, 347 insertions(+), 11 deletions(-) create mode 100644 crates/aep/tests/it/import/effects_thirdparty.rs diff --git a/crates/aep/src/container.rs b/crates/aep/src/container.rs index 4aa0f393a..0a4746f68 100644 --- a/crates/aep/src/container.rs +++ b/crates/aep/src/container.rs @@ -36,7 +36,7 @@ mod values; mod write; pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits}; -pub use arbitrary::{CURVE_IDENTITY, CurvesData, HueSaturationData, LevelsData, MeshData, curve_lut}; +pub use arbitrary::{CURVE_IDENTITY, CurvesData, ExtractorChannels, HueSaturationData, LevelsData, MeshData, curve_lut, identifier_channel_type}; 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}; diff --git a/crates/aep/src/container/arbitrary.rs b/crates/aep/src/container/arbitrary.rs index dea70d22c..7b47e83c7 100644 --- a/crates/aep/src/container/arbitrary.rs +++ b/crates/aep/src/container/arbitrary.rs @@ -307,3 +307,54 @@ impl MeshData { Some(out) } } + +/// EXtractoR's Channel Info data (`EXtractoR-0013`, 688 bytes, the plug-in's own little-endian +/// layout; read from the default instances of the importer block of 2026-11-05, where no channel +/// was changed): a `u32` 1 and 28 bytes, then four records of 164 bytes (Red, Green, Blue, Alpha), +/// each a `u32` kind (1 a named channel, 2 the input's channel: `(copy)` on Alpha) and the channel +/// name from byte 35, NUL-terminated. Only the default (`R`, `G`, `B`, `(copy)`) is sampled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractorChannels { + /// Per output channel (red, green, blue, alpha): the kind and the name. + pub channels: [(u32, String); 4], +} + +impl ExtractorChannels { + /// The data's length. + pub const LEN: usize = 688; + const RECORD: usize = 164; + + /// `None` when the data does not have this layout. + pub fn parse(data: &[u8]) -> Option { + if data.len() != Self::LEN || data.get(..4)? != 1u32.to_le_bytes() { + return None; + } + let mut channels: [(u32, String); 4] = Default::default(); + for (k, slot) in channels.iter_mut().enumerate() { + let at = 32usize.checked_add(k.checked_mul(Self::RECORD)?)?; + let rec = data.get(at..at.checked_add(Self::RECORD)?)?; + let kind = u32::from_le_bytes(*rec.first_chunk::<4>()?); + let name = rec.get(35..)?; + let end = name.iter().position(|b| *b == 0)?; + let name = std::str::from_utf8(name.get(..end)?).ok()?; + *slot = (kind, name.to_string()); + } + Some(Self { channels }) + } + + /// The channels of a fresh EXtractoR: Red, Green and Blue from the channels of those names, + /// Alpha copied from the input. + pub fn is_default(&self) -> bool { + let want = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")]; + self.channels.iter().zip(want).all(|((k, n), (wk, wn))| *k == wk && n == wn) + } +} + +/// IDentifier's Channel Info data (`IDentifier-0005`, 148 bytes): the channel type's name, +/// NUL-terminated from byte 19 (`Object ID` in every sample of the importer block of 2026-11-05; +/// the bytes around it differ between instances with the same settings and are not read). +pub fn identifier_channel_type(data: &[u8]) -> Option<&str> { + let s = data.get(19..)?; + let end = s.iter().position(|b| *b == 0)?; + std::str::from_utf8(s.get(..end)?).ok() +} diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index 5b872450f..46fcf39ea 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -138,6 +138,10 @@ pub enum Extra { Curves, /// Mesh Warp's Distortion Mesh (965, 985, `MeshData`). MeshWarp, + /// EXtractoR's Channel Info (`ExtractorChannels`): the default channels only. + Extractor, + /// IDentifier's Channel Info (`identifier_channel_type`): Object ID only. + Identifier, } /// One mapped effect. @@ -354,6 +358,8 @@ pub const SHUTTER_CONTROL: &[&str] = &["Manual", "Automatic"]; pub const ECHO_OPERATOR: &[&str] = &["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"]; /// Color Stabilizer's Stabilize (`colstab-02`, `colstab-03`; the fresh default is Levels). pub const STABILIZE: &[&str] = &["Brightness", "Levels", "Curves"]; +/// IDentifier's Display (`identifier-04` ... `identifier-06`, `1300-thirdparty.aep`). +pub const IDENTIFIER_DISPLAY: &[&str] = &["Colors", "Luma Matte", "Alpha Matte", "Raw"]; /// The effects the importer converts. pub const EFFECTS: &[EffectMap] = &[ @@ -1066,6 +1072,40 @@ pub const EFFECTS: &[EffectMap] = &[ pm(5, K::FixedSlider, "sampleSize", Conv::Plain { valid_max: 50.0 }), ], ), + // CC Color Neutralizer (importer block 2026-11-05, `ccneutral-*`): Pinning and Blend w. + // Original are float sliders in percent (valid 0..100). After Effects' correction is the Red / + // Green / Blue slider of each range (3-5, 9-11, 15-17; the Unbalance colours are pickers that + // set them and keep no record), then View (22) and Black / White Point (23, 24): + // EffectCraft's CC Color Neutralizer has none of them, so they are reported when stored. + fx( + "CS Color Neutralizer", + "ec.color.cccolorneutralizer", + &[pm(19, K::FloatSlider, "pinning", Conv::Plain { valid_max: 100.0 }), pm(20, K::FloatSlider, "blendWOriginal", Conv::Plain { valid_max: 100.0 })], + ), + // EXtractoR (`extractor-*`): 1 Channel Info (arbitrary data, `Extra::Extractor`), 2 / 6 the + // Process group, Black and White Point float sliders (valid ±3.4e38, the float32 range), + // UnMult. EffectCraft's Clip has no counterpart and stays off. + EffectMap { + match_name: "EXtractoR", + effect: "ec.3d.extractor", + params: &[ + pm(3, K::FloatSlider, "blackPoint", Conv::Plain { valid_max: F32_MAX }), + pm(4, K::FloatSlider, "whitePoint", Conv::Plain { valid_max: F32_MAX }), + pm(5, K::Checkbox, "unMult", Conv::Checkbox), + ], + extra: Extra::Extractor, + }, + // IDentifier (`identifier-*`): 1 Channel Info (arbitrary data, `Extra::Identifier`), 2 Display, + // 3 ID an integer slider (valid 0..2 147 483 647). + EffectMap { + match_name: "IDentifier", + effect: "ec.3d.identifier", + params: &[ + pm(2, K::Popup, "display", Conv::Popup { default: 1, items: IDENTIFIER_DISPLAY }), + pm(3, K::IntSlider, "id", Conv::Plain { valid_max: 2_147_483_647.0 }), + ], + extra: Extra::Identifier, + }, // Layer Control (076, 220, 320, 321): the default is the owning layer, None is unset. fx("ADBE Layer Control", "ec.control.layer", &[pm(1, K::Layer, "layer", Conv::LayerRef { own: true })]), fx("ADBE Slider Control", "ec.control.slider", &[pm(1, K::FloatSlider, "slider", Conv::Plain { valid_max: 1_000_000.0 })]), @@ -1079,6 +1119,8 @@ pub const EFFECTS: &[EffectMap] = &[ pub const EXPOSURE: Conv = Conv::Plain { valid_max: 100.0 }; pub const OFFSET: Conv = Conv::Plain { valid_max: 2.0 }; pub const GAMMA: Conv = Conv::Plain { valid_max: 10.0 }; +/// The largest float32, the valid maximum of EXtractoR's Black and White Point. +pub const F32_MAX: f64 = f32::MAX as f64; /// The valid maximum of Glow's A & B Midpoint (16.16 of 0.99, 427). pub const GLOW_MIDPOINT_MAX: f64 = 0.990_005_493_164_062_5; diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 1d20a9dd1..a9fa3c2c7 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -36,8 +36,8 @@ use crate::effect_map::{Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, popup_key}; use crate::{ - CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, HueSaturationData, Layer as AepLayer, LayerRef, LevelsData, MeshData, - ParamKind, ParamValue, + CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, ExtractorChannels, HueSaturationData, Layer as AepLayer, LayerRef, + LevelsData, MeshData, ParamKind, ParamValue, }; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; @@ -465,6 +465,40 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h Some(m) => import_mesh(&m, here, size, group, col), None => col.lose(LossCategory::DamagedData, format!("{here}: the Distortion Mesh is unreadable, defaults used")), }, + Extra::Extractor => match ExtractorChannels::parse(data) { + // The fresh channels: EffectCraft's own R, G, B, A (its A is the input's alpha, After + // Effects' `(copy)`). + Some(c) if c.is_default() => { + for (id, name) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { + if let Some(prop) = group.prop_mut(id) { + prop.value = Value::Str(name.into()); + } + } + } + Some(c) => { + let names: Vec<&str> = c.channels.iter().map(|(_, n)| n.as_str()).collect(); + col.lose( + LossCategory::Effects, + format!("{here}: the channels {} are not imported (only the default channels are sampled), R, G, B, A used", names.join(", ")), + ); + } + None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, R, G, B, A used")), + }, + Extra::Identifier => match crate::identifier_channel_type(data) { + Some("Object ID") => { + if let Some(prop) = group.prop_mut("channelType") { + prop.value = Value::Enum(0); + } + // The data names the type, not the channel After Effects reads: with this Channel + // Info After Effects left the sampled EXR unchanged, where EffectCraft draws the + // footage's object ID channel. + col.lose( + LossCategory::Effects, + format!("{here}: the channel IDentifier reads (Channel Info) is not decoded; EffectCraft uses the footage's object ID channel"), + ); + } + other => col.lose(LossCategory::Effects, format!("{here}: the channel type {other:?} is not imported (only Object ID is sampled), Object ID used")), + }, Extra::None => {} } } diff --git a/crates/aep/src/lib.rs b/crates/aep/src/lib.rs index c8b37d716..dba3136ee 100644 --- a/crates/aep/src/lib.rs +++ b/crates/aep/src/lib.rs @@ -14,11 +14,11 @@ pub mod import; pub use container::{ AepError, AepFile, AutoKern, BlendMode, Body, CURVE_IDENTITY, CameraPosition, Caps, Chunk, Comp, CompHeader, CueType, CurvesData, DEFAULT_CAMERA_ZOOM, - DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, FontBaseline, Footage, 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, is_aepx, parse, parse_any, - parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, write, write_to, + DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, ExtractorChannels, FontBaseline, Footage, 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, }; diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 178dd2bb3..a24f9af26 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -14,6 +14,7 @@ mod effects_batch5; mod effects_quickwins; mod effects_round2; mod effects_round3; +mod effects_thirdparty; mod fx_inject; mod markers; mod masks; diff --git a/crates/aep/tests/it/import/effects.rs b/crates/aep/tests/it/import/effects.rs index aa4180567..fbb28b406 100644 --- a/crates/aep/tests/it/import/effects.rs +++ b/crates/aep/tests/it/import/effects.rs @@ -72,7 +72,7 @@ fn the_table_names_tint_gradient_ramp_and_exposure() { ] { assert!(t.contains(&want), "{want:?}"); } - assert_eq!(t.len(), 62); + assert_eq!(t.len(), 65); for (_, id) in t { assert!(effectcraft_effects::find(id).is_some(), "{id}"); } diff --git a/crates/aep/tests/it/import/effects_thirdparty.rs b/crates/aep/tests/it/import/effects_thirdparty.rs new file mode 100644 index 000000000..20f0b8230 --- /dev/null +++ b/crates/aep/tests/it/import/effects_thirdparty.rs @@ -0,0 +1,179 @@ +//! CC Color Neutralizer, EXtractoR and IDentifier (#3112) arrive as EffectCraft's effects of the +//! same name. Each is built into `synthetic.aep` (`fx_inject`) with the definitions After Effects +//! 2026 wrote for it in the importer block of 2026-11-05 (`1300-thirdparty.aep`) and the values +//! of that block's compositions; the Channel Info data is laid out as those instances store it. + +use crate::import::fx_inject::{Default, Fx, losses, near, other, popup, run, slider, v}; +use effectcraft_aep::import::LossCategory; +use effectcraft_aep::{ExtractorChannels, ParamKind as K, identifier_channel_type}; +use effectcraft_keyframe::Value; + +fn neutralizer(values: Vec<(usize, f64)>) -> Fx { + let range = |name| slider(K::FloatSlider, name, 0.0, (-100.0, 100.0)); + let group = |name| other(K::GroupStart, name, Default::None); + let end = || other(K::GroupEnd, "", Default::None); + Fx { + match_name: "CS Color Neutralizer", + name: "CC Color Neutralizer", + defs: vec![ + other(K::Color, "Shadows Unbalance", Default::Color([0, 0, 0, 0])), + group("Shadows"), + range("Red - Shadows"), + range("Green - Shadows"), + range("Blue - Shadows"), + end(), + other(K::Color, "Midtones Unbalance", Default::Color([255, 127, 127, 127])), + group("Midtones"), + range("Red - Midtones"), + range("Green - Midtones"), + range("Blue - Midtones"), + end(), + other(K::Color, "Highlights Unbalance", Default::Color([255, 255, 255, 255])), + group("Highlights"), + range("Red - Highlights"), + range("Green - Highlights"), + range("Blue - Highlights"), + end(), + slider(K::FloatSlider, "Pinning", 0.0, (0.0, 100.0)), + slider(K::FloatSlider, "Blend w. Original", 0.0, (0.0, 100.0)), + group("Special"), + popup("View", 1, &["Final Result", "Correction B to W", "Correction Stair"]), + slider(K::FloatSlider, "Black Point", 0.0, (0.0, 49.9)), + slider(K::FloatSlider, "White Point", 100.0, (50.1, 100.0)), + end(), + ], + values, + data: vec![], + } +} + +#[test] +fn cc_color_neutralizer_pinning_and_blend() { + // `ccneutral-00-defaults` and the Pinning / Blend of `ccneutral-01-set-all`. + let (g, r) = run(&neutralizer(vec![(19, 16.0), (20, 19.0)]), "ec.color.cccolorneutralizer"); + assert_eq!(losses(&r, LossCategory::Effects, "CS Color Neutralizer"), Vec::::new()); + let g = g.expect("imported"); + near(&g, "pinning", 16.0); + near(&g, "blendWOriginal", 19.0); + // EffectCraft's own (identity) balance stays. + assert_eq!(v(&g, "midtonesUnbalance"), v(&g, "midtonesBalance")); +} + +#[test] +fn cc_color_neutralizer_corrections_without_a_counterpart_are_reported() { + // `ccneutral-03-sec-shadows`, `ccneutral-09-pop22-2`, `ccneutral-06-sec-special`. + let (g, r) = run(&neutralizer(vec![(3, 13.0), (4, 16.0), (5, 19.0), (22, 2.0), (23, 10.0), (24, 87.0)]), "ec.color.cccolorneutralizer"); + let found = losses(&r, LossCategory::Effects, "CS Color Neutralizer"); + for name in ["Red - Shadows", "Green - Shadows", "Blue - Shadows", "View", "Black Point", "White Point"] { + assert!(found.iter().any(|d| d.contains(&format!("parameter {name} (")) && d.contains("not imported")), "{name}: {found:?}"); + } + assert_eq!(found.len(), 6, "{found:?}"); + assert!(g.is_some(), "the effect itself is imported"); +} + +/// EXtractoR's Channel Info data with these record kinds and names. +fn channels(recs: [(u32, &str); 4]) -> Vec { + let mut d = vec![0u8; ExtractorChannels::LEN]; + d[..4].copy_from_slice(&1u32.to_le_bytes()); + for (k, (kind, name)) in recs.iter().enumerate() { + let at = 32 + k * 164; + d[at..at + 4].copy_from_slice(&kind.to_le_bytes()); + d[at + 35..at + 35 + name.len()].copy_from_slice(name.as_bytes()); + } + d +} + +const DEFAULT_CHANNELS: [(u32, &str); 4] = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")]; + +fn extractor(values: Vec<(usize, f64)>, data: Vec) -> Fx { + let mut values = values; + values.insert(0, (1, 0.0)); + Fx { + match_name: "EXtractoR", + name: "EXtractoR", + defs: vec![ + other(K::ArbitraryData, "Channel Info", Default::None), + other(K::GroupStart, "Process", Default::None), + slider(K::FloatSlider, "Black Point", 0.0, (-f64::from(f32::MAX), f64::from(f32::MAX))), + slider(K::FloatSlider, "White Point", 1.0, (-f64::from(f32::MAX), f64::from(f32::MAX))), + other(K::Checkbox, "", Default::Number(0.0)), + other(K::GroupEnd, "", Default::None), + ], + values, + data: vec![(1, data)], + } +} + +#[test] +fn extractor_channel_info_layout() { + let c = ExtractorChannels::parse(&channels(DEFAULT_CHANNELS)).expect("the default layout"); + assert!(c.is_default()); + let c = ExtractorChannels::parse(&channels([(1, "Z"), (1, "G"), (1, "B"), (2, "(copy)")])).expect("parses"); + assert!(!c.is_default()); + assert_eq!(c.channels[0], (1, "Z".to_string())); + assert_eq!(ExtractorChannels::parse(&[0u8; 10]), None); +} + +#[test] +fn extractor_points_unmult_and_default_channels() { + // `extractor-01-set-all`: Black Point 10, White Point 14, UnMult on. + let (g, r) = run(&extractor(vec![(3, 10.0), (4, 14.0), (5, 1.0)], channels(DEFAULT_CHANNELS)), "ec.3d.extractor"); + assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::::new()); + let g = g.expect("imported"); + near(&g, "blackPoint", 10.0); + near(&g, "whitePoint", 14.0); + assert_eq!((v(&g, "unMult"), v(&g, "clip")), (Value::Bool(true), Value::Bool(false))); + // After Effects' `(copy)` Alpha is the input's alpha: EffectCraft's A. + for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { + assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); + } +} + +#[test] +fn extractor_channels_other_than_the_default_are_reported() { + let (g, r) = run(&extractor(vec![], channels([(1, "Z"), (1, "Z"), (1, "Z"), (2, "(copy)")])), "ec.3d.extractor"); + let found = losses(&r, LossCategory::Effects, "EXtractoR"); + assert!(found.len() == 1 && found[0].contains("the channels Z, Z, Z, (copy) are not imported"), "{found:?}"); + assert_eq!(v(&g.expect("imported"), "red"), Value::Str("R".into())); +} + +/// IDentifier's Channel Info: the channel type's name at 19 among bytes that are not read. +fn id_info(name: &str) -> Vec { + let mut d: Vec = (0..148u32).map(|i| (i * 37 % 251) as u8 | 1).collect(); + d[19..19 + name.len()].copy_from_slice(name.as_bytes()); + d[19 + name.len()] = 0; + d +} + +fn identifier(values: Vec<(usize, f64)>, data: Vec) -> Fx { + let mut values = values; + values.insert(0, (1, 0.0)); + Fx { + match_name: "IDentifier", + name: "IDentifier", + defs: vec![ + other(K::ArbitraryData, "Channel Info", Default::None), + popup("Display", 1, &["Colors", "Luma Matte", "Alpha Matte", "Raw"]), + slider(K::IntSlider, "ID", 0.0, (0.0, 2_147_483_647.0)), + ], + values, + data: vec![(1, data)], + } +} + +#[test] +fn identifier_display_id_and_channel_type() { + assert_eq!(identifier_channel_type(&id_info("Object ID")), Some("Object ID")); + // `identifier-01-set-all` (ID 10) and `identifier-05-pop2-3` (Alpha Matte). + let (g, r) = run(&identifier(vec![(2, 3.0), (3, 10.0)], id_info("Object ID")), "ec.3d.identifier"); + // Which channel After Effects reads is not decoded (it left the sampled EXR unchanged): reported. + let found = losses(&r, LossCategory::Effects, "IDentifier"); + assert!(found.len() == 1 && found[0].contains("is not decoded"), "{found:?}"); + let g = g.expect("imported"); + assert_eq!((v(&g, "display"), v(&g, "channelType")), (Value::Enum(2), Value::Enum(0))); + near(&g, "id", 10.0); + // A channel type that is not sampled is reported. + let (_, r) = run(&identifier(vec![], id_info("Material ID")), "ec.3d.identifier"); + let found = losses(&r, LossCategory::Effects, "IDentifier"); + assert!(found.len() == 1 && found[0].contains("\"Material ID\""), "{found:?}"); +} diff --git a/docs/aep-format.md b/docs/aep-format.md index 32ec74854..29ee51963 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -533,6 +533,22 @@ compositions, the items from one composition per popup item).** Each set value i | `ADBE Offset` | 1 Shift Center To: point, default the layer centre; 2 Blend With Original: fixed, **max 1 (a 0..1 fraction)**, default 0 | | `ADBE Deflicker` (Color Stabilizer) | 1 Stabilize: popup (Brightness, Levels, Curves), **default 2 (Levels)**; 2 Black Point, 3 Mid Point, 4 White Point: points, defaults 0.2 / 0.5 / 0.8 of the width at half the height; 5 Sample Size: fixed, max 50, default 3. The reference frame the Set Frame button picks is not among the parameters | +**Importer block 2026-11-05, #3112 (`1300-thirdparty.aep`; V for the parameter lists, H for the +data layouts, read from default instances only):** + +| Effect (match name, plug-in version) | Parameters (`parT` index: type, valid range, default) | +|---|---| +| `CS Color Neutralizer` (CC Color Neutralizer, 1.3) | 1 / 7 / 13 Shadows / Midtones / Highlights Unbalance: colours (black, 127 grey, white; pickers: setting one sets the range's sliders, e.g. [0.27, 0.55, 0.85] gives Red 42.94, Green 1.18, Blue -44.12 = (mean - channel) x 150 / 255 on 8-bit values, and leaves no colour record); 2-6, 8-12, 14-18 a group per range (start, Red, Green, Blue, end) with float sliders -100..100 (slider -50..50), default 0; 19 Pinning: float 0..100 (slider 0..50); 20 Blend w. Original: float 0..100; 21-25 the Special group: 22 View popup (Final Result, Correction B to W, Correction Stair), default 1, 23 Black Point float 0..49.9, default 0, 24 White Point float 50.1..100, default 100 | +| `EXtractoR` (2.6.6) | 1 Channel Info (`EXtractoR-0013`): arbitrary data, 688 bytes, below; 2-6 the Process group: 3 Black Point and 4 White Point float sliders, valid ±3.4028235e38 (the float32 range; slider ±1 000), default 0 / 1; 5 UnMult: checkbox (empty `pard` name, the label in `pdnm`), default off | +| `IDentifier` (2.6.6) | 1 Channel Info (`IDentifier-0005`): arbitrary data, 148 bytes, below; 2 Display: popup (Colors, Luma Matte, Alpha Matte, Raw), default 1; 3 ID: integer slider, max 2 147 483 647, default 0 | + +EXtractoR's Channel Info is the plug-in's own **little-endian** layout: `u32` 1 and 28 zero bytes, +then four 164-byte records (Red, Green, Blue, Alpha), each a `u32` kind (1 in R, G, B; 2 in Alpha, +whose name is `(copy)`) and the channel name NUL-terminated from record byte 35 (`R`, `G`, `B`). +The data is the same in every composition of the block. IDentifier's data differs between +instances with the same settings except for the channel type's name, NUL-terminated from byte 19 +(`Object ID`); the other bytes are not read. API: `ExtractorChannels`, `identifier_channel_type`. + **Curves and Mesh Warp** (961-965, 985; applied from presets, the data is not scriptable): their arbitrary-data parameter (type 11) always has a record (`tdbs` with a 4-byte `cdat`) and a `LIST aRbs` / `aRbp`, also at the default. Curves (`ADBE CurvesCustom-0001`, 1644 bytes): `u16` 1, 1; diff --git a/docs/aep-import.md b/docs/aep-import.md index 1926274f0..f14141156 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -179,6 +179,7 @@ parameter gets the file's value: | `ADBE Easy Levels2` 1 Channel, 2 Histogram data, 8 / 9 Clip To Output Black / White | `channel`; `inBlack`, `inWhite`, `gamma`, `outBlack`, `outWhite` from the data; `clipToOutputBlack`, `clipToOutputWhite` | popups by name (After Effects' items On, Off, Off for 32 bpc Color are in another order here); the RGB values are the `f32` of the arbitrary data; the per-channel values are reported when they are not at their defaults (not sampled) | 511-516 | | Batch 6 (903 defaults, 904 / 905 set values): `ADBE Brightness & Contrast 2`, `ADBE Turbulent Displace`, `ADBE Fractal Noise`, `ADBE Laser` (Beam), `CC Radial Fast Blur`, `ADBE Noise2`, `ADBE Pro Levels2` (Levels (Individual Controls)), `ADBE Corner Pin`, `ADBE Grid`, `ADBE Sharpen`, `ADBE Linear Color Key2`, `ADBE Radial Wipe`, `ADBE Remove Color Matting`, `CC Bend It`, `CC Power Pin`, `ADBE 4ColorGradient`, `ADBE Change Color`, `ADBE Extract`, `ADBE Noise HLS2`, `ADBE Scatter`, `CC Light Sweep`, `ADBE Compound Blur`, `ADBE Displacement Map`, `ADBE Set Matte3`, `ADBE Gaussian Blur` (legacy), `ADBE OFMotionBlur` (Pixel Motion Blur), `ADBE Layer Control` | the EffectCraft effect of the same name, every parameter EffectCraft has | sliders as stored (each checked against its valid maximum); Beam Length, Time and Softness and CC Power Pin Perspective are 0..1 fractions, percentages here; points as layer pixels; popups by item name (Set Matte's `Red Channel` ... is `Red` ...); twirl-down group markers and the Levels histogram carry nothing; **layer parameters** (below) | 903, 904, 905; 220, 320, 321 | | Importer block 2026-11-05 (`1100-quickwins.aep`: per effect defaults, set values, minimum and maximum, every popup item, a keyed and an expression parameter): `ADBE HDR ToneMap` (HDR Highlight Compression) 1 Amount; `ADBE Echo` 1 Echo Time, 2 Number Of Echoes, 3 Starting Intensity, 4 Decay, 5 Echo Operator; `ADBE Linear Wipe` 1 Transition Completion, 2 Wipe Angle, 3 Feather; `ADBE Offset` 1 Shift Center To, 2 Blend With Original; `ADBE Deflicker` (Color Stabilizer) 1 Stabilize, 2-4 Black / Mid / White Point, 5 Sample Size | `ec.utility.hdrcompression` `amount`; `ec.time.echo` `echoTime`, `numberOfEchoes`, `startingIntensity`, `decay`, `echoOperator`; `ec.transition.linearwipe` `completion`, `angle`, `feather`; `ec.distort.offset` `shift`, `blend`; `ec.color.colorstabilizer` `stabilize`, `blackPoint`, `midPoint`, `whitePoint`, `sampleSize` | percent (float, valid 0..100, default 100); seconds (float, valid ±30 000), integer (valid 30 000), 0..1 (float), Decay as stored (float, valid 0..100), popup by name: EffectCraft's Echo uses at most 255 echoes and a Decay up to 1, a value or key beyond that is imported as stored and **reported**; percent (fixed, 100), degrees, pixels (fixed, 32 000); point in layer pixels, Blend 0..1 x 100 = percent; popup by name (**fresh default Levels**), points in layer pixels, Sample Size as stored (fixed, valid 50, default 3; its unit, pixels here, is not verified). Color Stabilizer's reference frame (Set Frame) is not a parameter in the file and is not imported (EffectCraft's Reference Frame stays 0 s) | `tests/it/import/effects_quickwins.rs`; render comparison below | +| Importer block 2026-11-05 (`1300-thirdparty.aep`), plug-ins that ship with After Effects: `CS Color Neutralizer` (CC Color Neutralizer) 19 Pinning, 20 Blend w. Original; `EXtractoR` 1 Channel Info data, 3 Black Point, 4 White Point, 5 UnMult; `IDentifier` 1 Channel Info data, 2 Display, 3 ID | `ec.color.cccolorneutralizer` `pinning`, `blendWOriginal`; `ec.3d.extractor` `red`, `green`, `blue`, `alpha`, `blackPoint`, `whitePoint`, `unMult`; `ec.3d.identifier` `channelType`, `display`, `id` | CC Color Neutralizer: percent (float, valid 100); its correction is the Red / Green / Blue slider of each range (the Unbalance colours are pickers that set them and keep no record), with View and Black / White Point: **EffectCraft's effect has no counterpart, each is reported when stored** (EffectCraft's Unbalance / Balance stay at their identity defaults). EXtractoR: the default channels (R, G, B, `(copy)`) are EffectCraft's R, G, B, A, other channels are reported (`ExtractorChannels`; only the default is sampled), Black and White Point as stored (float, valid ±3.4e38; a value beyond ±1e9 is reported as damaged data, as for every number), UnMult a checkbox, Clip stays off. IDentifier: Display by name, ID as stored (integer, valid 2 147 483 647); the Channel Info names only the channel type (Object ID, `identifier_channel_type`; another is reported), not the channel After Effects reads, so **every instance is reported**: EffectCraft draws the footage's object ID channel where After Effects left the sampled EXR unchanged | `tests/it/import/effects_thirdparty.rs`; render comparison below | | `ADBE Slider Control`, `ADBE Angle Control`, `ADBE Checkbox Control`, `ADBE Color Control`, `ADBE Point Control` (parameter 1) | `ec.control.slider` `slider`, `ec.control.angle` `angle`, `ec.control.checkbox` `checkbox`, `ec.control.color` `color`, `ec.control.point` `point` | float (valid max 1 000 000) as stored; degrees; checkbox; colour; point in layer pixels | 071, 073, 217-219, 221, 304, 310 | - **Nothing to import:** the implicit input-layer parameter (index 0), the Swap Colors buttons @@ -325,6 +326,18 @@ frame blending, 3D material options, audio levels, guides, the mask colour, lock over 25 (EffectCraft's samples drift, After Effects' frame stays unchanged) and with keyed sample points. The renders do not exercise a Decay above 1 (After Effects' set value has Starting Intensity 0) or what Color Stabilizer corrects (the test card does not flicker). +- `crates/aep/tests/it/import/effects_thirdparty.rs` (#3112): CC Color Neutralizer, EXtractoR and + IDentifier built into `synthetic.aep` like the #3110 effects. Render check of the 29 compositions + of `1300-thirdparty.aep` (as above): CC Color Neutralizer at its defaults and the minimum and + maximum compositions match within one level (no composition isolates Pinning: the maximum has + Blend w. Original 100, which shows the original), the compositions with reported Red / + Green / Blue corrections or View differ as reported (up to 5 levels, View 25); EXtractoR at its + defaults and with UnMult within 3 levels on 6 % of the pixels, keyed and expression Black Point + within one level, the ±3.4e38 compositions differ (reported as damaged data). IDentifier: After + Effects' frame is the unchanged EXR in every variant (its default Object ID finds no channel in + that EXR), while EffectCraft's IDentifier finds the EXR's `ID` channel and draws it; the import + reports this for every IDentifier. No sample changes EXtractoR's channels or IDentifier's channel + type (the presets for that were missing from the block). - `crates/aep/tests/it/import/combined.rs`: masks and effects on the same layer, an effect on a text layer, and the same through `.aepx` (built in-test from the fixtures: the effect parade of one fixture grafted onto the layer of another). From ce3a5b3673a17dd0c766c322c19f551ad9eae9a3 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 15:23:55 +0100 Subject: [PATCH 10/94] AEP import: map Lumetri Color (ADBE Lumetri) to EffectCraft's Lumetri Color The importer now converts After Effects' Lumetri Color. Its parameters are found by match-name suffix, not by parT index: After Effects inserted -0127 ... -0130 mid-list and five tone sliders share one valid range. - Values are read from every value record, also those in the default state (tdsb 3), where Lumetri keeps High Dynamic Range, HDR Specular and the second Look popup (EffectParam::recorded_value; stored_value is unchanged). - A switched-off section, and HSL Secondary without a picked key colour, draws nothing in After Effects: its set values are reported in one line and not imported. - The HSL key data is decoded into hue / saturation / lightness ranges. - LUTs and Looks (by name), controls without a counterpart, eyedropper colours and undecoded curves, wheels and Split Toning are reported. - EffectCraft's Lumetri sliders take After Effects' valid ranges. - import_param is split into lookup plus import_mapped for reuse. Evidence: 1200 samples (After Effects 2026). The 62 read-backs of mapped controls in sections that act equal the imported values. The other 61 lie in sections that do nothing and are reported. Renders of identity grades are within 1/255. Part of #3111 Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/container/effect_map.rs | 6 + crates/aep/src/container/effects.rs | 15 +- crates/aep/src/import.rs | 1 + crates/aep/src/import/effects.rs | 56 ++- crates/aep/src/import/lumetri.rs | 642 +++++++++++++++++++++++++ crates/aep/tests/it/import.rs | 1 + crates/aep/tests/it/import/effects.rs | 2 +- crates/aep/tests/it/import/lumetri.rs | 448 +++++++++++++++++ crates/effects/src/color3.rs | 41 +- docs/aep-format.md | 38 ++ docs/aep-import.md | 29 +- docs/file-format-parity.md | 2 +- 12 files changed, 1244 insertions(+), 37 deletions(-) create mode 100644 crates/aep/src/import/lumetri.rs create mode 100644 crates/aep/tests/it/import/lumetri.rs diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index a7c9bbd48..312332e8f 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -134,6 +134,9 @@ pub enum Extra { Curves, /// Mesh Warp's Distortion Mesh (965, 985, `MeshData`). MeshWarp, + /// Lumetri Color: every parameter goes through its own table, keyed by match name + /// (`import/lumetri.rs`, 1200). + Lumetri, } /// One mapped effect. @@ -509,6 +512,9 @@ pub const EFFECTS: &[EffectMap] = &[ ], extra: Extra::MeshWarp, }, + // Lumetri Color (1200): the parameters are mapped by match name, not by `parT` index + // (`import/lumetri.rs`). + EffectMap { match_name: "ADBE Lumetri", effect: "ec.color.lumetri", params: &[], extra: Extra::Lumetri }, // Expression controls (070-077, 217-221, 310). // Directional Blur (711): Direction in degrees, Blur Length in pixels (valid max 1 000). fx( diff --git a/crates/aep/src/container/effects.rs b/crates/aep/src/container/effects.rs index df1c2b9c1..597710587 100644 --- a/crates/aep/src/container/effects.rs +++ b/crates/aep/src/container/effects.rs @@ -375,11 +375,24 @@ impl<'f, 'a> EffectParam<'f, 'a> { /// colours, popups and layer controls. A record counts when its `tdsb` is 1 and it has no /// keyframes; a layer control reads the `tdpi` chunk; a button has no value. pub fn stored_value(&self) -> Option { + let p = self.stored?; + if self.kind != ParamKind::Layer && p.flags() != Some(1) { + return None; + } + self.recorded_value() + } + + /// The value in the value record whatever the record's state (`tdsb`): what + /// [`EffectParam::stored_value`] reads, also from a record in the default state (`tdsb` 3). + /// Lumetri Color writes the values of its hidden parameters so (High Dynamic Range, HDR + /// Specular, Look: 1200 `lumetri-01-set-all`, `-02-sec-top`, `-06-sec-light`; After Effects + /// reads back the recorded values). `None` with keyframes or without a record. + pub fn recorded_value(&self) -> Option { let p = self.stored?; if self.kind == ParamKind::Layer { return be_u32(p.chunk().child(b"tdpi")?.bytes()?, 0).map(ParamValue::Layer); } - if p.flags() != Some(1) || p.is_animated() { + if p.is_animated() { return None; } let v = p.static_value()?; diff --git a/crates/aep/src/import.rs b/crates/aep/src/import.rs index 1ffe3ec48..eb4eee8c8 100644 --- a/crates/aep/src/import.rs +++ b/crates/aep/src/import.rs @@ -27,6 +27,7 @@ mod build; mod camera; mod effects; +mod lumetri; mod markers; mod masks; mod props; diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 477a25df4..2ef912e00 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::{Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, popup_key}; +use crate::effect_map::{Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_key}; use crate::{ CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, HueSaturationData, Layer as AepLayer, LayerRef, LevelsData, MeshData, ParamKind, ParamValue, @@ -99,8 +99,12 @@ pub(crate) fn import_effects<'f, 'a>( 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); - for p in ¶ms { - import_param(&fx, map, p, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *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 { + for p in ¶ms { + import_param(&fx, map, p, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col }); + } } col.imported.effects = col.imported.effects.saturating_add(1); target.children.push(Node::Group(group)); @@ -108,7 +112,7 @@ pub(crate) fn import_effects<'f, 'a>( } /// Parameters that never carry a value to import. -fn carries_nothing(p: &EffectParam<'_, '_>) -> bool { +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) } @@ -127,10 +131,15 @@ pub(crate) struct PendingLayerRef { /// Where a parameter is imported to: the effect's group, the layer parameters still to resolve /// and the report. -struct ParamOut<'a> { - group: &'a mut PropGroup, - refs: &'a mut Vec, - col: &'a mut Collector, +pub(crate) struct ParamOut<'a> { + pub group: &'a mut PropGroup, + pub refs: &'a mut Vec, + pub col: &'a mut Collector, +} + +/// How a parameter is named in the report: its name and match name. +pub(crate) fn param_label(p: &EffectParam<'_, '_>) -> String { + if p.name.trim().is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name.trim(), p.match_name) } } fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>, cx: &LayerCtx<'_>, shown: &str, out: ParamOut<'_>) { @@ -138,7 +147,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_> if carries_nothing(p) { return; } - let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) }; + let label = param_label(p); let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()); if p.kind == ParamKind::ArbitraryData && map.extra != Extra::None { import_extra(fx, map.extra, p, &here, cx.size, group, col); @@ -152,6 +161,27 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_> } return; }; + import_mapped(p, m, cx, (&here, &label), ParamOut { group, refs, col }, ValueRecord::Written); +} + +/// Which value records count. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum ValueRecord { + /// Only written records (`tdsb` 1, [`EffectParam::stored_value`]). + Written, + /// Every record, also one in the default state (Lumetri Color, [`EffectParam::recorded_value`]). + Any, +} + +/// Imports parameter `p` into the property `m` names, converted by `m.conv`. `names` is +/// `(here, label)`, the effect and the parameter for the report. +pub(crate) fn import_mapped(p: &EffectParam<'_, '_>, m: &ParamMap, cx: &LayerCtx<'_>, names: (&str, &str), out: ParamOut<'_>, records: ValueRecord) { + let (here, label) = names; + let ParamOut { group, refs, col } = out; + let read = |p: &EffectParam<'_, '_>| match records { + ValueRecord::Written => p.stored_value(), + ValueRecord::Any => p.recorded_value(), + }; if p.kind != m.kind { col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, m.kind)); return; @@ -159,7 +189,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_> match m.conv { Conv::Skip => return, Conv::LayerRef { own } => { - import_layer_ref(p, m.target, own, cx, (&here, &label), ParamOut { group, refs, col }); + import_layer_ref(p, m.target, own, cx, (here, label), ParamOut { group, refs, col }); return; } _ => {} @@ -264,12 +294,12 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_> col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} has no readable keyframe, default used")); } if let Some(map) = &popup_map { - remap_popup_keys(prop, map, &here, &label, col); + remap_popup_keys(prop, map, here, label, col); } } return; } - let value = match (p.stored(), p.stored_value()) { + let value = match (p.stored(), read(p)) { (Some(_), Some(v)) => Some(v), (Some(_), None) => { col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds an unreadable value, default used")); @@ -451,7 +481,7 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h Some(m) => import_mesh(&m, here, size, group, col), None => col.lose(LossCategory::DamagedData, format!("{here}: the Distortion Mesh is unreadable, defaults used")), }, - Extra::None => {} + Extra::None | Extra::Lumetri => {} } } diff --git a/crates/aep/src/import/lumetri.rs b/crates/aep/src/import/lumetri.rs new file mode 100644 index 000000000..22f28b1aa --- /dev/null +++ b/crates/aep/src/import/lumetri.rs @@ -0,0 +1,642 @@ +//! Lumetri Color (`ADBE Lumetri`) to EffectCraft's Lumetri Color (`ec.color.lumetri`). +//! +//! The parameters are found by their match name (`ADBE Lumetri-0011` is Exposure), not by their +//! place in `parT`: After Effects has added parameters in the middle of the list over the +//! versions (`-0127` ... `-0130` sit between older ones), and several sliders share a valid range +//! (Contrast, Highlights, Shadows, Whites and Blacks are all -150..150), so a shifted index +//! would map silently to the wrong control. Evidence: the 1200 samples (After Effects 2026, +//! `docs/aep-format.md` 3.7). +//! +//! - **Values in any record state.** Lumetri writes the values of its hidden controls (High +//! Dynamic Range, HDR Specular, the second Look popup, ...) in records in the default state +//! (`tdsb` 3); After Effects reads them back as set, so every record counts here +//! ([`EffectParam::recorded_value`]). +//! - **Section switches.** Each section (Basic Correction, Creative, Curves, RGB Curves, the five +//! hue / saturation curves, Color Wheels, HSL Secondary, Vignette) has an on / off checkbox in +//! After Effects; EffectCraft's Lumetri has none. A section that is off does nothing in After +//! Effects (1200 `lumetri-03-sec-basic-correction`, `-17-sec-vignette` render as `-00-defaults`), +//! so its values are not imported (the effect looks the same) and are listed in the report. +//! - **Arbitrary data** (`ParamKind::ArbitraryData`, every instance stores it): the hue / +//! saturation curves (a curve type, a point count, points as `f64` x, y with y an offset from +//! neutral) are imported when neutral and reported otherwise (no sample moves a point); the +//! HSL Secondary key ([`HslKey`]) is imported; the RGB curves, the colour wheels, Split Toning +//! and the correction wheels are reported when they are not the default (not sampled). +//! - **LUTs and looks.** The Input LUT and the Look are files After Effects names (the Look's name +//! is UTF-16 text in `-0096`, the popups only index a list of the installed ones); they are not +//! in the project, so a set one is reported by name and EffectCraft's popup stays None. +//! - **Eyedropper colours** (White Balance, the hue curve selectors, the key's Set / Add / +//! Remove colours) only set other controls in After Effects; they are reported when set. +//! - The `Blob` (`-0001`, an XML copy of the grade After Effects writes when a Look changes and does +//! not keep current: 1200 `-01-set-all` holds the defaults of controls set after the Look) and +//! the Auto Tone analysis (`-0121`, `-0122`) are derived data and not read. + +use crate::effect_map::{Conv, ParamMap}; +use crate::{Effect as AepEffect, EffectParam, ParamKind, ParamValue}; +use effectcraft_keyframe::Value; + +use crate::import::effects::{LayerCtx, ParamOut, ValueRecord, carries_nothing, import_mapped, param_label}; +use crate::import::report::LossCategory; +use ParamKind as K; + +/// After Effects' Show Mask styles (`-0075`, `pdnm`), EffectCraft's in the same order. +pub const MASK_STYLE: &[&str] = &["Color/Gray", "Color/Black", "White/Black"]; + +/// A part of the effect with its own on / off switch. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Section { + /// No switch (High Dynamic Range and the controls outside the sections). + Always, + BasicCorrection, + Creative, + RgbCurves, + /// The five hue / saturation curves (0 Hue vs Sat ... 4 Sat vs Sat). + HueSat(u8), + ColorWheels, + HslSecondary, + Vignette, + SpeedGradeCustom, +} + +impl Section { + fn name(self) -> &'static str { + match self { + Section::Always => "", + Section::BasicCorrection => "Basic Correction", + Section::Creative => "Creative", + Section::RgbCurves => "RGB Curves", + Section::HueSat(0) => "Hue vs Sat", + Section::HueSat(1) => "Hue vs Hue", + Section::HueSat(2) => "Hue vs Luma", + Section::HueSat(3) => "Luma vs Sat", + Section::HueSat(_) => "Sat vs Sat", + Section::ColorWheels => "Color Wheels", + Section::HslSecondary => "HSL Secondary", + Section::Vignette => "Vignette", + Section::SpeedGradeCustom => "SpeedGrade Custom", + } + } +} + +/// Arbitrary data the importer reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Data { + /// The four RGB curves (4 x 520 bytes). + RgbCurves, + /// One hue / saturation curve (520 bytes); the EffectCraft parameter it belongs to. + HueSatCurve, + /// Color Wheels (4 x 3 `f64`, each wheel 0, 0.5, 0 by default). + Wheels, + /// HSL Secondary's correction wheels (8 x 3 `f64`, the same default). + CorrectionWheels, + /// Split Toning (32 zero bytes by default). + SplitToning, + /// HSL Secondary's key ([`HslKey`]). + HslKey, + /// Data without a counterpart, empty by default (`fe fe` or zero bytes). + Other, +} + +/// What a Lumetri parameter is. +#[derive(Clone, Copy, Debug)] +enum Role { + /// An EffectCraft parameter and the conversion. + To(&'static str, Conv), + /// The on / off switch of a section. + Switch, + /// No counterpart: reported when not at the default. + Missing, + /// An eyedropper colour: reported when set. + Eyedropper, + /// The Input LUT (`lut` true) or the Look: a popup, a name or an asset. + Lut { + lut: bool, + }, + /// The Look's colour space (its popup and the space's id): goes with the Look. + LookSpace, + Data(Data), + /// Derived data, not read. + Derived, +} + +struct Row { + suffix: u16, + section: Section, + kind: Option, + role: Role, +} + +const fn row(suffix: u16, section: Section, kind: Option, role: Role) -> Row { + Row { suffix, section, kind, role } +} + +const fn to(suffix: u16, section: Section, kind: ParamKind, target: &'static str, conv: Conv) -> Row { + row(suffix, section, Some(kind), Role::To(target, conv)) +} + +const fn plain(valid_max: f64) -> Conv { + Conv::Plain { valid_max } +} + +use Section as S; + +/// Every Lumetri parameter by match-name suffix (1200: `dump.props` and the `pard` of +/// `lumetri-00-defaults`). The valid maxima are After Effects 2026's; the stored numbers are the +/// values After Effects shows (read back in every variant). +const ROWS: &[Row] = &[ + to(62, S::Always, K::Checkbox, "highDynamicRange", Conv::Checkbox), + // Basic Correction: 3 the switch; 4 / 95 the Input LUT's name data, 5 / 127 its popups. + row(3, S::BasicCorrection, Some(K::Checkbox), Role::Switch), + row(4, S::BasicCorrection, Some(K::ArbitraryData), Role::Lut { lut: true }), + row(95, S::BasicCorrection, Some(K::ArbitraryData), Role::Lut { lut: true }), + row(5, S::BasicCorrection, Some(K::Popup), Role::Lut { lut: true }), + row(127, S::BasicCorrection, Some(K::Popup), Role::Lut { lut: true }), + to(63, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/hdrWhite", plain(1000.0)), + // The Auto tone's Intensity (next to the Auto and Reset buttons 120, 119). + row(123, S::BasicCorrection, Some(K::FloatSlider), Role::Missing), + row(94, S::BasicCorrection, Some(K::Color), Role::Eyedropper), + to(7, S::BasicCorrection, K::FloatSlider, "basicCorrection/whiteBalance/temperature", plain(300.0)), + to(8, S::BasicCorrection, K::FloatSlider, "basicCorrection/whiteBalance/tint", plain(300.0)), + to(20, S::BasicCorrection, K::FloatSlider, "basicCorrection/saturation", plain(300.0)), + to(11, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/exposure", plain(7.0)), + to(12, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/contrast", plain(150.0)), + to(13, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/highlights", plain(150.0)), + to(14, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/shadows", plain(150.0)), + to(15, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/whites", plain(150.0)), + to(16, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/blacks", plain(150.0)), + to(64, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/hdrSpecular", plain(150.0)), + // Creative: 23 the switch; 24 / 96 the Look's name, 25 / 128 its popups, 129 / 130 its colour + // space. + row(23, S::Creative, Some(K::Checkbox), Role::Switch), + row(24, S::Creative, Some(K::ArbitraryData), Role::Lut { lut: false }), + row(96, S::Creative, Some(K::ArbitraryData), Role::Lut { lut: false }), + row(25, S::Creative, Some(K::Popup), Role::Lut { lut: false }), + row(128, S::Creative, Some(K::Popup), Role::Lut { lut: false }), + row(129, S::Creative, Some(K::ArbitraryData), Role::LookSpace), + row(130, S::Creative, Some(K::Popup), Role::LookSpace), + to(26, S::Creative, K::FloatSlider, "creative/lookIntensity", plain(200.0)), + to(28, S::Creative, K::FloatSlider, "creative/adjustments/fadedFilm", plain(150.0)), + to(29, S::Creative, K::FloatSlider, "creative/adjustments/sharpen", plain(100.0)), + to(30, S::Creative, K::FloatSlider, "creative/adjustments/vibrance", plain(100.0)), + to(31, S::Creative, K::FloatSlider, "creative/adjustments/creativeSaturation", plain(300.0)), + row(32, S::Creative, Some(K::ArbitraryData), Role::Data(Data::SplitToning)), + to(33, S::Creative, K::FloatSlider, "creative/adjustments/tintBalance", plain(150.0)), + // Curves: 37 switches all curves, 118 the RGB curves, 104 ... 111 each hue / saturation curve. + row(37, S::RgbCurves, Some(K::Checkbox), Role::Switch), + row(118, S::RgbCurves, Some(K::Checkbox), Role::Switch), + row(65, S::RgbCurves, Some(K::FloatSlider), Role::Missing), + row(39, S::RgbCurves, Some(K::ArbitraryData), Role::Data(Data::RgbCurves)), + row(104, S::HueSat(0), Some(K::Checkbox), Role::Switch), + row(113, S::HueSat(0), Some(K::Color), Role::Eyedropper), + row(42, S::HueSat(0), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(105, S::HueSat(1), Some(K::Checkbox), Role::Switch), + row(114, S::HueSat(1), Some(K::Color), Role::Eyedropper), + row(106, S::HueSat(1), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(107, S::HueSat(2), Some(K::Checkbox), Role::Switch), + row(115, S::HueSat(2), Some(K::Color), Role::Eyedropper), + row(108, S::HueSat(2), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(109, S::HueSat(3), Some(K::Checkbox), Role::Switch), + row(116, S::HueSat(3), Some(K::Color), Role::Eyedropper), + row(110, S::HueSat(3), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(111, S::HueSat(4), Some(K::Checkbox), Role::Switch), + row(117, S::HueSat(4), Some(K::Color), Role::Eyedropper), + row(112, S::HueSat(4), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + // Color Wheels: 46 the switch, 102 Face Detection (on by default), 66 HDR White, 47 the wheels. + row(46, S::ColorWheels, Some(K::Checkbox), Role::Switch), + row(102, S::ColorWheels, Some(K::Checkbox), Role::Missing), + row(66, S::ColorWheels, Some(K::FloatSlider), Role::Missing), + row(47, S::ColorWheels, Some(K::ArbitraryData), Role::Data(Data::Wheels)), + // HSL Secondary: 68 the switch; the key (70-76), Refine (81, 82), Correction (85-90). + row(68, S::HslSecondary, Some(K::Checkbox), Role::Switch), + row(70, S::HslSecondary, Some(K::Color), Role::Eyedropper), + row(71, S::HslSecondary, Some(K::Color), Role::Eyedropper), + row(72, S::HslSecondary, Some(K::Color), Role::Eyedropper), + row(73, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::HslKey)), + to(74, S::HslSecondary, K::Checkbox, "hslSecondary/key/showMask", Conv::Checkbox), + to(75, S::HslSecondary, K::Popup, "hslSecondary/key/maskStyle", Conv::Popup { default: 1, items: MASK_STYLE }), + to(76, S::HslSecondary, K::Checkbox, "hslSecondary/key/invertMask", Conv::Checkbox), + to(81, S::HslSecondary, K::FloatSlider, "hslSecondary/refine/denoise", plain(100.0)), + to(82, S::HslSecondary, K::FloatSlider, "hslSecondary/refine/blur", plain(1000.0)), + row(85, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::CorrectionWheels)), + // The correction wheel's mode (4 zero bytes by default). + row(93, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::Other)), + to(86, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/temperature", plain(300.0)), + to(87, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/tint", plain(300.0)), + to(88, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/contrast", plain(150.0)), + to(89, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/sharpen", plain(100.0)), + to(90, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/saturation", plain(300.0)), + // Vignette: 50 the switch. + row(50, S::Vignette, Some(K::Checkbox), Role::Switch), + to(51, S::Vignette, K::FloatSlider, "vignette/vignetteAmount", plain(5.0)), + to(52, S::Vignette, K::FloatSlider, "vignette/vignetteMidpoint", plain(100.0)), + to(53, S::Vignette, K::FloatSlider, "vignette/vignetteRoundness", plain(100.0)), + to(54, S::Vignette, K::FloatSlider, "vignette/vignetteFeather", plain(100.0)), + // SpeedGrade Custom: 57 Custom Layer (on by default), 60 its data. + row(57, S::SpeedGradeCustom, Some(K::Checkbox), Role::Switch), + row(60, S::SpeedGradeCustom, Some(K::ArbitraryData), Role::Data(Data::Other)), + // Outside the sections: Legacy Tone And Saturation, the embedded LUTs, Auto Tone, Auto Color, + // the LUT and Look assets. + row(97, S::Always, Some(K::Checkbox), Role::Missing), + row(98, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)), + row(99, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)), + row(100, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)), + row(124, S::Always, Some(K::Checkbox), Role::Missing), + row(125, S::Always, Some(K::ArbitraryData), Role::Lut { lut: true }), + row(126, S::Always, Some(K::ArbitraryData), Role::Lut { lut: false }), + row(1, S::Always, Some(K::ArbitraryData), Role::Derived), + row(121, S::Always, Some(K::FloatSlider), Role::Derived), + row(122, S::Always, Some(K::ArbitraryData), Role::Derived), +]; + +/// The names After Effects shows for the parameters whose `pard` name is blank (`dump.props`). +fn blank_name(suffix: u16) -> Option<&'static str> { + Some(match suffix { + 62 => "High Dynamic Range", + 3 | 23 | 37 | 118 | 104 | 105 | 107 | 109 | 111 | 46 | 68 | 50 => "Active", + 4 | 95 => "Input LUT data", + 24 | 96 => "Look name", + 102 => "Face Detection", + 74 => "Show Mask", + 75 => "Mask style", + 76 => "Invert Mask", + 97 => "Legacy Tone And Saturation", + 124 => "Auto Color Active", + _ => return None, + }) +} + +/// The parameter's name for the report: its own, else the one After Effects shows. +fn name_of(p: &EffectParam<'_, '_>) -> String { + match (p.name.trim(), suffix(&p.match_name).and_then(blank_name)) { + ("", Some(n)) => n.to_string(), + ("", None) => p.match_name.clone(), + (n, _) => n.to_string(), + } +} + +/// The match-name suffix of a Lumetri parameter (`ADBE Lumetri-0011` is 11). +fn suffix(match_name: &str) -> Option { + match_name.strip_prefix("ADBE Lumetri-")?.parse().ok() +} + +/// The parameter's value: the recorded one, else the `pard` default. +fn value_of(p: &EffectParam<'_, '_>) -> Option { + p.recorded_value().or_else(|| p.pard_default()) +} + +/// True when the parameter has keyframes, an expression, or a recorded value other than its +/// default (a record without a readable default counts as set). +fn is_set(p: &EffectParam<'_, '_>) -> bool { + if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) { + return true; + } + match (p.recorded_value(), p.pard_default()) { + (Some(v), Some(d)) => !same(v, d), + (Some(_), None) => true, + (None, _) => false, + } +} + +/// Equal within the float noise of a stored `f64` / `f32`. +fn same(a: ParamValue, b: ParamValue) -> bool { + let close = |x: f64, y: f64| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0); + match (a, b) { + (ParamValue::Number(x), ParamValue::Number(y)) => close(x, y), + (ParamValue::Argb(x), ParamValue::Argb(y)) => x.iter().zip(y).skip(1).all(|(x, y)| close(*x, y)), + _ => a == b, + } +} + +/// A value as the report shows it. +fn shown_value(v: Option) -> String { + match v { + Some(ParamValue::Number(n)) => { + let s = format!("{n:.3}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() + } + Some(ParamValue::Bool(b)) => if b { "on" } else { "off" }.to_string(), + Some(ParamValue::Index(i)) => format!("item {i}"), + Some(ParamValue::Argb([_, r, g, b])) => format!("RGB {:.3} {:.3} {:.3}", r, g, b), + Some(other) => format!("{other:?}"), + None => "set".to_string(), + } +} + +/// Empty data: absent, `fe fe` (no text, no asset) or only zero bytes. +fn is_empty_data(d: Option<&[u8]>) -> bool { + match d { + None => true, + Some(d) => d == [0xfe, 0xfe] || d.iter().all(|b| *b == 0), + } +} + +fn le_u32(d: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?)) +} + +fn le_f32(d: &[u8], at: usize) -> Option { + Some(f32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?)) +} + +fn le_f64(d: &[u8], at: usize) -> Option { + Some(f64::from_le_bytes(*d.get(at..at.checked_add(8)?)?.first_chunk::<8>()?)) +} + +/// A UTF-16LE text up to its NUL (the Look's name, `-0096`: `SL BLUE ICE` in 1200 `-01-set-all`); +/// `None` for empty data. +fn utf16_text(d: &[u8]) -> Option { + if is_empty_data(Some(d)) { + return None; + } + let units: Vec = d.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|u| *u != 0).collect(); + let s = String::from_utf16_lossy(&units); + let s = s.trim(); + (!s.is_empty()).then(|| s.to_string()) +} + +/// A Lumetri curve (520 bytes): `i32` curve type at 0 (3 for the hue curves, which wrap; 2 for +/// Luma vs Sat and Sat vs Sat; 0 for an RGB curve), the `u32` point count at 4, then the points +/// as little-endian `f64` pairs x, y from byte 8 (at most 32). `None` when the count does not fit. +fn curve_points(d: &[u8]) -> Option> { + let n = usize::try_from(le_u32(d, 4)?).ok()?; + if n > d.len().saturating_sub(8) / 16 { + return None; + } + (0..n).map(|i| Some((le_f64(d, 8 + 16 * i)?, le_f64(d, 16 + 16 * i)?))).collect() +} + +/// HSL Secondary's key (`-0073`, 40 bytes): a flag byte (1 once a key colour was picked, 0 for a +/// fresh effect), then hue, saturation and lightness, each three little-endian `f32` (centre, +/// range, tolerance; fractions of the full hue circle / of 0..1) and an enabled byte. A fresh effect +/// stores flag 0 and centre 0.5, range 0, tolerance 0, enabled for each (1200 `-00-defaults`); +/// After Effects' own copy in `-0001` names the same fields (`middleHLS`, `rangeHLS`, +/// `toleranceHLS`, `enableHLS`, hue in degrees: centre 180). Picking Set color RGB 0.02 0.55 0.85 +/// gives hue centre 0.5605 (that colour's hue, 201.8 degrees) and lightness centre 0.435 (its +/// HSL lightness) with range 0.05 and tolerance 0.1 (`-01-set-all`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HslKey { + pub picked: bool, + /// Hue, saturation, lightness: centre, range, tolerance, enabled. + pub channels: [(f32, f32, f32, bool); 3], +} + +impl HslKey { + pub fn parse(d: &[u8]) -> Option { + if d.len() != 40 { + return None; + } + let picked = match d.first()? { + 0 => false, + 1 => true, + _ => return None, + }; + let ch = |at: usize| -> Option<(f32, f32, f32, bool)> { + let (c, r, t) = (le_f32(d, at)?, le_f32(d, at + 4)?, le_f32(d, at + 8)?); + let on = match d.get(at + 12)? { + 0 => false, + 1 => true, + _ => return None, + }; + (c.is_finite() && r.is_finite() && t.is_finite()).then_some((c, r, t, on)) + }; + Some(HslKey { picked, channels: [ch(1)?, ch(14)?, ch(27)?] }) + } + + fn is_default(&self) -> bool { + !self.picked && self.channels.iter().all(|c| *c == (0.5, 0.0, 0.0, true)) + } +} + +/// The colour wheel data: triples of `f64`, each wheel `0, 0.5, 0` by default. +fn wheels_default(d: &[u8], wheels: usize) -> bool { + d.len() == wheels * 24 && (0..wheels).all(|w| le_f64(d, w * 24) == Some(0.0) && le_f64(d, w * 24 + 8) == Some(0.5) && le_f64(d, w * 24 + 16) == Some(0.0)) +} + +/// Is the data of `data` at After Effects' default (as far as it can be told)? +fn data_default(data: Data, d: Option<&[u8]>) -> bool { + let Some(d) = d else { return true }; + match data { + Data::RgbCurves => { + d.len() == 4 * 520 && d.as_chunks::<520>().0.iter().all(|c| curve_points(c).is_some_and(|pts| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9))) + } + Data::HueSatCurve => curve_points(d).is_some_and(|pts| pts.iter().all(|(_, y)| *y == 0.0)), + Data::Wheels => wheels_default(d, 4), + Data::CorrectionWheels => wheels_default(d, 8), + Data::SplitToning | Data::Other => is_empty_data(Some(d)), + Data::HslKey => HslKey::parse(d).is_some_and(|k| k.is_default()), + } +} + +/// What the Input LUT or the Look holds: names, a popup item, an asset. +#[derive(Default)] +struct LutState { + names: Vec, + item: Option, + asset: bool, +} + +/// How a set parameter of a section that does nothing is listed in the report; `None` when it is +/// at its default. +fn off_text(p: &EffectParam<'_, '_>, role: Role, data: Option<&[u8]>) -> Option { + match role { + Role::Data(d) => (!data_default(d, data)).then(|| name_of(p)), + // A LUT by its name (the popups index the same choice). + Role::Lut { lut } if p.kind == K::ArbitraryData => { + let what = if lut { "Input LUT" } else { "Look" }; + data.and_then(utf16_text).map(|n| format!("{what} '{n}'")).or_else(|| (!is_empty_data(data)).then(|| what.to_string())) + } + // The Look's colour space goes with the Look. + Role::Lut { .. } | Role::LookSpace | Role::Derived => None, + _ => is_set(p).then(|| format!("{} {}", name_of(p), shown_value(p.recorded_value()))), + } +} + +/// Notes what an Input LUT or Look parameter holds: a popup item, an asset or a name. +fn note_lut(p: &EffectParam<'_, '_>, suffix: u16, data: Option<&[u8]>, st: &mut LutState) { + if p.kind == K::Popup { + // The popup indexes the LUTs installed with After Effects; item 1 is None. + if let Some(ParamValue::Index(i)) = p.recorded_value() + && i > 1 + { + st.item = usize::try_from(i).ok().and_then(|i| p.items.as_ref()?.get(i - 1).cloned()).or(Some(format!("item {i}"))); + } + } else if suffix == 125 || suffix == 126 { + st.asset |= !is_empty_data(data); + } else if let Some(name) = data.and_then(utf16_text) + && !st.names.contains(&name) + { + st.names.push(name); + } +} + +/// Imports a Lumetri Color instance's parameters into `out.group` and reports what does not arrive. +pub(crate) fn import_lumetri(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, '_>], cx: &LayerCtx<'_>, shown: &str, out: ParamOut<'_>) { + let ParamOut { group, refs, col } = out; + let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()); + let find = |s: u16| params.iter().find(|p| suffix(&p.match_name) == Some(s)); + // The switches: on unless a record says off. Keys or an expression on a switch are not + // imported (the static value counts). + let switch_on = |s: u16| -> bool { + match find(s) { + Some(p) => !matches!(value_of(p), Some(ParamValue::Bool(false))), + None => true, + } + }; + // HSL Secondary without a picked key colour selects nothing: After Effects applies none of + // its correction (1200 `-16-sec-correction`, `-15-sec-refine` render as `-00-defaults`); + // EffectCraft's default key is the whole image, so the values are left out too. + // Unreadable or missing key data does not count as unpicked (it is reported or older). + let key_picked = find(73).and_then(|p| fx.arbitrary_data(&p.match_name)).and_then(HslKey::parse).is_none_or(|k| k.picked); + let section_on = |sec: Section| -> bool { + match sec { + Section::Always => true, + Section::BasicCorrection => switch_on(3), + Section::Creative => switch_on(23), + Section::RgbCurves => switch_on(37) && switch_on(118), + Section::HueSat(i) => switch_on(37) && switch_on([104, 105, 107, 109, 111].get(usize::from(i)).copied().unwrap_or(104)), + Section::ColorWheels => switch_on(46), + Section::HslSecondary => switch_on(68) && key_picked, + Section::Vignette => switch_on(50), + // Custom Layer off is itself reported (a switch without counterpart). + Section::SpeedGradeCustom => true, + } + }; + let mut off: Vec<(Section, Vec)> = Vec::new(); + let mut eyedroppers: Vec = Vec::new(); + let mut luts = [LutState::default(), LutState::default()]; + for p in params { + if carries_nothing(p) || p.match_name == "ADBE Effect Built In Params" { + continue; + } + let label = if p.name.trim().is_empty() { format!("{} ({})", name_of(p), p.match_name) } else { param_label(p) }; + let Some(row) = suffix(&p.match_name).and_then(|s| ROWS.iter().find(|r| r.suffix == s)) else { + // A parameter this table does not know (a newer version): lost when set. + if p.stored().is_some() + && (p.kind != K::ArbitraryData && is_set(p) || p.kind == K::ArbitraryData && !is_empty_data(fx.arbitrary_data(&p.match_name))) + { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported")); + } + continue; + }; + if row.kind.is_some_and(|k| k != p.kind) { + if p.stored().is_some() { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, row.kind)); + } + continue; + } + let data = || fx.arbitrary_data(&p.match_name); + // A switched-off section: its set values are listed, not imported. + if !matches!(row.role, Role::Switch) && !section_on(row.section) { + let text = off_text(p, row.role, data()); + if let Some(text) = text { + match off.iter_mut().find(|(s, _)| *s == row.section) { + Some((_, list)) => { + if !list.contains(&text) { + list.push(text); + } + } + None => off.push((row.section, vec![text])), + } + } + continue; + } + match row.role { + Role::To(target, conv) => { + let m = ParamMap { index: p.index, kind: p.kind, target, conv }; + import_mapped(p, &m, cx, (&here, &label), ParamOut { group: &mut *group, refs: &mut *refs, col: &mut *col }, ValueRecord::Any); + } + Role::Switch => { + if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) { + col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on the switch {label} are not imported")); + } + if row.section == Section::SpeedGradeCustom && !switch_on(row.suffix) { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} is off and has no counterpart")); + } + } + Role::Missing => { + if is_set(p) { + col.lose( + LossCategory::Effects, + format!("{here}: parameter {label} is {} and has no counterpart, not imported", shown_value(p.recorded_value())), + ); + } + } + Role::Eyedropper => { + if is_set(p) { + eyedroppers.push(name_of(p)); + } + } + Role::Lut { lut } => note_lut(p, row.suffix, data(), &mut luts[usize::from(!lut)]), + // The Look's colour space only applies to a Look, which is reported. + Role::LookSpace => {} + Role::Data(Data::HslKey) => import_hsl_key(data(), &here, &mut *group, &mut *col), + Role::Data(d) => { + if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) { + col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {label} are not imported")); + } + if !data_default(d, data()) { + let why = match d { + Data::HueSatCurve => "the curve's points are moved; no sample tells their scale", + Data::RgbCurves | Data::Wheels | Data::CorrectionWheels | Data::SplitToning => "the data is not decoded (not sampled)", + Data::HslKey | Data::Other => "no counterpart", + }; + col.lose(LossCategory::Effects, format!("{here}: parameter {label} is not imported ({why})")); + } + } + Role::Derived => {} + } + } + for (sec, list) in off { + let why = if sec == Section::HslSecondary && switch_on(68) { + "has no key colour picked (After Effects applies it to nothing)" + } else { + "is switched off (EffectCraft's Lumetri has no section switch)" + }; + col.lose(LossCategory::Effects, format!("{here}: section {} {why}, its values are not imported: {}", sec.name(), list.join(", "))); + } + if !eyedroppers.is_empty() { + col.lose(LossCategory::Effects, format!("{here}: the eyedropper colours {} are not imported (they only set other controls)", eyedroppers.join(", "))); + } + for (st, what) in luts.iter().zip(["Input LUT", "Look"]) { + let name = st.names.first().cloned().or_else(|| st.item.clone()); + if name.is_some() || st.asset { + let name = name.map(|n| format!(" '{n}'")).unwrap_or_default(); + col.lose( + LossCategory::Effects, + format!("{here}: the {what}{name} is not imported (the LUT file is not in the project; EffectCraft's {what} stays None)"), + ); + } + } +} + +/// HSL Secondary's key as EffectCraft's hue centre / range / softness (degrees) and saturation and +/// lightness bands (percent): range is taken as the half width, tolerance as the softness. A key +/// that was never picked keeps EffectCraft's default (the whole image). +fn import_hsl_key(d: Option<&[u8]>, here: &str, group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) { + let Some(d) = d else { return }; + let Some(key) = HslKey::parse(d) else { + col.lose(LossCategory::DamagedData, format!("{here}: the HSL Secondary key data is unreadable, the default key used")); + return; + }; + if !key.picked { + if !key.is_default() { + col.lose(LossCategory::Effects, format!("{here}: the HSL Secondary key holds values without a picked colour, not imported")); + } + return; + } + let mut set = |id: &str, v: Value| { + if let Some(prop) = group.prop_mut(&format!("hslSecondary/key/{id}")) { + prop.value = v; + } + }; + let [h, s, l] = key.channels.map(|(c, r, t, on)| (f64::from(c), f64::from(r), f64::from(t), on)); + set("hueEnabled", Value::Bool(h.3)); + set("hueCenter", Value::Scalar((h.0 * 360.0).rem_euclid(360.0))); + set("hueRange", Value::Scalar((h.1 * 360.0).clamp(0.0, 180.0))); + set("hueSoftness", Value::Scalar((h.2 * 360.0).clamp(0.0, 180.0))); + for ((c, r, t, on), id) in [(s.0, s.1, s.2, s.3), (l.0, l.1, l.2, l.3)].into_iter().zip(["saturation", "lightness"]) { + set(&format!("{id}Enabled"), Value::Bool(on)); + set(&format!("{id}Low"), Value::Scalar(((c - r) * 100.0).clamp(0.0, 100.0))); + set(&format!("{id}High"), Value::Scalar(((c + r) * 100.0).clamp(0.0, 100.0))); + set(&format!("{id}Softness"), Value::Scalar((t * 100.0).clamp(0.0, 100.0))); + } +} diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 95dced570..122d18c2b 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -13,6 +13,7 @@ mod effects; mod effects_batch5; mod effects_round2; mod effects_round3; +mod lumetri; mod markers; mod masks; mod mattes; diff --git a/crates/aep/tests/it/import/effects.rs b/crates/aep/tests/it/import/effects.rs index be1293b9a..010df44d5 100644 --- a/crates/aep/tests/it/import/effects.rs +++ b/crates/aep/tests/it/import/effects.rs @@ -72,7 +72,7 @@ fn the_table_names_tint_gradient_ramp_and_exposure() { ] { assert!(t.contains(&want), "{want:?}"); } - assert_eq!(t.len(), 57); + assert_eq!(t.len(), 58); for (_, id) in t { assert!(effectcraft_effects::find(id).is_some(), "{id}"); } diff --git a/crates/aep/tests/it/import/lumetri.rs b/crates/aep/tests/it/import/lumetri.rs new file mode 100644 index 000000000..526f43648 --- /dev/null +++ b/crates/aep/tests/it/import/lumetri.rs @@ -0,0 +1,448 @@ +//! Lumetri Color (`ADBE Lumetri`) arrives as EffectCraft's Lumetri Color (`ec.color.lumetri`), +//! its parameters found by match name. The effect is built into the committed `synthetic.aep` in +//! place of its Glow, in the layout of the 1200 samples (After Effects 2026, docs/aep-format.md +//! 3.7): the definitions are a subset in an order of our own, so a mapping by `parT` index fails. + +use effectcraft_aep::import::{ImportOptions, ImportResult, LossCategory, import}; +use effectcraft_aep::{Body, Chunk, parse, write}; +use effectcraft_keyframe::Value; +use effectcraft_project::{Project, PropGroup}; + +const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep"); + +fn tdmn(name: &str) -> Chunk<'static> { + let mut b = name.as_bytes().to_vec(); + b.resize(40, 0); + Chunk::data(*b"tdmn", b) +} + +/// A chunk holding chunks (`pdnm`, `fnam`: `NESTED_IDS`) with one `Utf8`. +fn nested(id: [u8; 4], text: &[u8]) -> Chunk<'static> { + Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", text.to_vec())]), pad: None } +} + +fn mn(suffix: u16) -> String { + format!("ADBE Lumetri-{suffix:04}") +} + +/// A parameter definition (`pard`, 148 bytes): the type at byte 15, the name at 16, the default at +/// 56 (a float slider's `f64`, a checkbox's or popup's `u32`), a popup's item count at 60, a float +/// slider's valid range at 104 / 108 and slider range at 112 / 116 (`f32`). +#[derive(Clone)] +enum Def { + Float { name: &'static str, default: f64, min: f32, max: f32 }, + Check { default: bool }, + Popup { name: &'static str, items: &'static [&'static str] }, + Data { name: &'static str }, + Group { name: &'static str, end: bool }, +} + +fn pard(d: &Def) -> Vec> { + let mut b = vec![0u8; 148]; + let name = |b: &mut Vec, n: &str| b[16..16 + n.len()].copy_from_slice(n.as_bytes()); + let mut extra = Vec::new(); + match d { + Def::Float { name: n, default, min, max } => { + b[15] = 10; + name(&mut b, n); + b[56..64].copy_from_slice(&default.to_be_bytes()); + b[104..108].copy_from_slice(&min.to_be_bytes()); + b[108..112].copy_from_slice(&max.to_be_bytes()); + b[112..116].copy_from_slice(&min.to_be_bytes()); + b[116..120].copy_from_slice(&max.to_be_bytes()); + } + Def::Check { default } => { + b[15] = 4; + name(&mut b, " "); + b[56..60].copy_from_slice(&u32::from(*default).to_be_bytes()); + } + Def::Popup { name: n, items } => { + b[15] = 7; + name(&mut b, n); + b[56..60].copy_from_slice(&1u32.to_be_bytes()); + b[60..62].copy_from_slice(&u16::try_from(items.len()).unwrap().to_be_bytes()); + extra.push(nested(*b"pdnm", items.join("|").as_bytes())); + } + Def::Data { name: n } => { + b[15] = 11; + name(&mut b, n); + } + Def::Group { name: n, end } => { + b[15] = if *end { 14 } else { 13 }; + name(&mut b, n); + } + } + let mut v = vec![Chunk::data(*b"pard", b)]; + v.extend(extra); + v +} + +/// A stored value. +#[derive(Clone)] +enum Rec { + /// A number in a record of state `tdsb` (1 written, 3 the default state). + Num(f64, u32), + /// Arbitrary data. + Data(Vec), +} + +/// A static value record after the one After Effects wrote for Glow's A & B Midpoint (its header, +/// one component). +fn record(template: &Chunk<'_>, tdsb: u32, v: f64) -> Chunk<'static> { + let mut kids = Vec::new(); + for c in template.children() { + match &c.body { + Body::Data(_) if c.id == *b"tdsb" => kids.push(Chunk::data(*b"tdsb", tdsb.to_be_bytes().to_vec())), + Body::Data(d) if c.id == *b"tdb4" => kids.push(Chunk::data(c.id, d.to_vec())), + Body::Data(_) if c.id == *b"cdat" => { + let mut d = vec![0u8; 40]; + d[..8].copy_from_slice(&v.to_be_bytes()); + kids.push(Chunk::data(*b"cdat", d)); + } + _ => {} + } + } + Chunk::list(*b"tdbs", kids) +} + +/// The defaults of a fresh instance's data (1200 `lumetri-00-defaults`). +fn empty() -> Vec { + vec![0xfe, 0xfe] +} + +/// A Lumetri curve: type, point count, points (`f64` x, y), padded to 520 bytes. +fn curve(kind: i32, pts: &[(f64, f64)]) -> Vec { + let mut d = kind.to_le_bytes().to_vec(); + d.extend(u32::try_from(pts.len()).unwrap().to_le_bytes()); + for (x, y) in pts { + d.extend(x.to_le_bytes()); + d.extend(y.to_le_bytes()); + } + d.resize(520, 0); + d +} + +fn rgb_curves() -> Vec { + (0..4).flat_map(|_| curve(0, &[(0.0, 0.0), (1.0, 1.0)])).collect() +} + +fn wheels(n: usize) -> Vec { + (0..n).flat_map(|_| [0.0f64, 0.5, 0.0].into_iter().flat_map(f64::to_le_bytes)).collect() +} + +fn utf16(s: &str) -> Vec { + s.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect() +} + +/// HSL Secondary's key data as 1200 `lumetri-01-set-all` stores it (Set color RGB 0.02 0.55 0.85). +const HSL_PICKED: &str = "01327f0f3fcdcc4c3dcdcccc3d01d519683f2e57c93dcade173e01dfdede3ecdcc4c3dcdcccc3d01"; +/// A fresh instance's key data (`-00-defaults`). +const HSL_FRESH: &str = "000000003f0000000000000000010000003f0000000000000000010000003f000000000000000001"; + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +const MASK_STYLE: &[&str] = &["Color/Gray", "Color/Black", "White/Black"]; +const LOOKS: &[&str] = &["None", "[Custom]", "Browse...", "(-", "SL BIG", "SL BLUE ICE"]; + +/// The definitions, an order of our own (not After Effects' `parT` order) and without the ones +/// After Effects 2026 added late (-0127 ... -0130). +fn defs() -> Vec<(u16, Def)> { + let f = |name, default, min, max| Def::Float { name, default, min, max }; + vec![ + (1, Def::Data { name: "Blob" }), + (62, Def::Check { default: false }), + (2, Def::Group { name: "Basic Correction", end: false }), + (3, Def::Check { default: true }), + (95, Def::Data { name: " " }), + (64, f("HDR Specular", 0.0, -150.0, 150.0)), + (16, f("Blacks", 0.0, -150.0, 150.0)), + (11, f("Exposure", 0.0, -7.0, 7.0)), + (12, f("Contrast", 0.0, -150.0, 150.0)), + (7, f("Temperature", 0.0, -300.0, 300.0)), + (21, Def::Group { name: "", end: true }), + (22, Def::Group { name: "Creative", end: false }), + (23, Def::Check { default: true }), + (96, Def::Data { name: " " }), + (25, Def::Popup { name: "Look", items: LOOKS }), + (26, f("Intensity", 100.0, 0.0, 200.0)), + (32, Def::Data { name: "Split Toning" }), + (35, Def::Group { name: "", end: true }), + (39, Def::Data { name: "RGB Curve" }), + (104, Def::Check { default: true }), + (42, Def::Data { name: "Hue vs Sat" }), + (47, Def::Data { name: "Wheels" }), + (68, Def::Check { default: true }), + (73, Def::Data { name: "HSL Sliders" }), + (74, Def::Check { default: false }), + (75, Def::Popup { name: " ", items: MASK_STYLE }), + (85, Def::Data { name: "Color Wheels" }), + (86, f("Temperature", 0.0, -300.0, 300.0)), + (50, Def::Check { default: true }), + (51, f("Amount", 0.0, -5.0, 5.0)), + (54, f("Feather", 50.0, 0.0, 100.0)), + (97, Def::Check { default: false }), + ] +} + +/// The data every instance stores, at the defaults. +fn default_data() -> Vec<(u16, Rec)> { + vec![ + (1, Rec::Data(empty())), + (95, Rec::Data(empty())), + (96, Rec::Data(empty())), + (32, Rec::Data(vec![0; 32])), + (39, Rec::Data(rgb_curves())), + (42, Rec::Data(curve(3, &[]))), + (47, Rec::Data(wheels(4))), + (73, Rec::Data(hex(HSL_FRESH))), + (85, Rec::Data(wheels(8))), + ] +} + +/// The Lumetri instance: the definitions, then the stored values `recs` (data replaces the +/// default data of the same parameter). +fn sspc(defs: &[(u16, Def)], recs: &[(u16, Rec)], template: &Chunk<'_>) -> Chunk<'static> { + let mut part = vec![Chunk::data(*b"parn", u32::try_from(defs.len() + 2).unwrap().to_be_bytes().to_vec()), tdmn(&mn(0))]; + let mut layer = vec![0u8; 148]; + layer[15] = 0; + part.push(Chunk::data(*b"pard", layer)); + for (s, d) in defs { + part.push(tdmn(&mn(*s))); + part.extend(pard(d)); + } + part.push(tdmn("ADBE Effect Built In Params")); + let mut end = vec![0u8; 148]; + end[15] = 9; + part.push(Chunk::data(*b"pard", end)); + let mut all: Vec<(u16, Rec)> = default_data().into_iter().filter(|(s, _)| !recs.iter().any(|(r, _)| r == s)).collect(); + all.extend(recs.iter().cloned()); + let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec())]; + for (s, r) in all { + values.push(tdmn(&mn(s))); + match r { + Rec::Num(v, tdsb) => values.push(record(template, tdsb, v)), + Rec::Data(d) => { + values.push(record(template, 1, 0.0)); + values.push(Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", d)])); + } + } + } + Chunk::list(*b"sspc", vec![nested(*b"fnam", b"Lumetri Color"), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)]) +} + +fn find_glow_template<'a>(chunks: &'a [Chunk<'a>]) -> Option> { + for c in chunks { + let kids = c.children(); + if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2-0011\0"))) + && let Some(record) = kids.get(at + 1).filter(|k| k.is_list(b"tdbs")) + { + return Some(record.clone()); + } + if let Some(t) = find_glow_template(kids) { + return Some(t); + } + } + None +} + +/// Puts the instance in place of the Glow effect (in the effect parade and the project's shared +/// definitions); the number of places. +fn replace_glow(chunks: &mut [Chunk<'_>], instance: &Chunk<'static>) -> usize { + let mut n = 0; + for ch in chunks { + let kids = ch.children_mut(); + if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0"))) + && kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc")) + { + kids[at] = tdmn("ADBE Lumetri"); + kids[at + 1] = instance.clone(); + n += 1; + } + n += replace_glow(kids, instance); + } + n +} + +fn run_with(defs: &[(u16, Def)], recs: &[(u16, Rec)]) -> (PropGroup, ImportResult) { + let file = parse(SYNTHETIC).unwrap(); + let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record"); + let instance = sspc(defs, recs, &template); + let mut file = parse(SYNTHETIC).unwrap(); + assert_eq!(replace_glow(&mut file.chunks, &instance), 2, "the Glow effect and its shared definitions"); + let bytes = write(&file).unwrap(); + let mut project = Project::default(); + let none = |_: &str| false; + let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); + let g = project + .comps() + .flat_map(|(_, c)| c.layers.iter()) + .find_map(|l| l.props.sub("effects").and_then(|fx| fx.children.iter().filter_map(|n| n.as_group()).find(|g| g.match_id == "ec.color.lumetri").cloned())) + .expect("an EffectCraft Lumetri Color"); + (g, r) +} + +fn run(recs: &[(u16, Rec)]) -> (PropGroup, ImportResult) { + run_with(&defs(), recs) +} + +fn losses(r: &ImportResult) -> Vec { + r.report + .losses + .iter() + .filter(|l| matches!(l.category, LossCategory::Effects | LossCategory::DamagedData)) + .flat_map(|l| l.details.clone()) + .filter(|d| d.contains("Lumetri")) + .collect() +} + +fn num(g: &PropGroup, id: &str) -> f64 { + g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.as_f64() +} + +fn val(g: &PropGroup, id: &str) -> Value { + g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.clone() +} + +#[test] +fn a_fresh_lumetri_arrives_without_losses() { + let (g, r) = run(&[]); + assert_eq!(losses(&r), Vec::::new()); + assert_eq!((num(&g, "basicCorrection/tone/exposure"), num(&g, "creative/lookIntensity"), num(&g, "vignette/vignetteFeather")), (0.0, 100.0, 50.0)); + assert_eq!(val(&g, "creative/look"), Value::Enum(0)); +} + +#[test] +fn lumetri_values_arrive_by_match_name_also_from_default_state_records() { + // 1200 `lumetri-01-set-all` / `-06-sec-light`: High Dynamic Range and HDR Specular are + // recorded in the default state (`tdsb` 3) and read back as set. + let (g, r) = run(&[ + (62, Rec::Num(1.0, 3)), + (64, Rec::Num(22.0, 3)), + (11, Rec::Num(2.589_999_914_169_311_5, 1)), + (12, Rec::Num(37.0, 1)), + (16, Rec::Num(-150.0, 1)), + (7, Rec::Num(300.0, 1)), + (26, Rec::Num(134.0, 1)), + (51, Rec::Num(1.85, 1)), + (74, Rec::Num(1.0, 1)), + (75, Rec::Num(3.0, 1)), + (73, Rec::Data(hex(HSL_PICKED))), + ]); + assert_eq!(losses(&r), Vec::::new()); + assert_eq!(val(&g, "highDynamicRange"), Value::Bool(true)); + for (id, want) in [ + ("basicCorrection/tone/hdrSpecular", 22.0), + ("basicCorrection/tone/exposure", 2.589_999_914_169_311_5), + ("basicCorrection/tone/contrast", 37.0), + ("basicCorrection/tone/blacks", -150.0), + // Past EffectCraft's old slider range: kept (the range is After Effects' now). + ("basicCorrection/whiteBalance/temperature", 300.0), + ("creative/lookIntensity", 134.0), + ("vignette/vignetteAmount", 1.85), + ] { + assert_eq!(num(&g, id), want, "{id}"); + } + assert_eq!((val(&g, "hslSecondary/key/showMask"), val(&g, "hslSecondary/key/maskStyle")), (Value::Bool(true), Value::Enum(2))); +} + +#[test] +fn a_switched_off_section_is_reported_and_not_applied() { + // 1200 `-03-sec-basic-correction`, `-17-sec-vignette`: a section that is off does nothing. + let (g, r) = run(&[(3, Rec::Num(0.0, 1)), (11, Rec::Num(2.0, 1)), (50, Rec::Num(0.0, 1)), (51, Rec::Num(-2.0, 1)), (12, Rec::Num(37.0, 1))]); + assert_eq!((num(&g, "basicCorrection/tone/exposure"), num(&g, "basicCorrection/tone/contrast"), num(&g, "vignette/vignetteAmount")), (0.0, 0.0, 0.0)); + let l = losses(&r); + assert_eq!(l.len(), 2, "{l:?}"); + assert!(l[0].contains("section Basic Correction is switched off") && l[0].contains("Exposure 2, Contrast 37"), "{l:?}"); + assert!(l[1].contains("section Vignette is switched off") && l[1].contains("Amount -2"), "{l:?}"); +} + +#[test] +fn a_look_is_reported_by_name() { + // 1200 `-01-set-all`: the Look's name in -0096, the popup's record in the default state. + let (g, r) = run(&[(96, Rec::Data(utf16("SL BLUE ICE"))), (25, Rec::Num(6.0, 3))]); + assert_eq!(val(&g, "creative/look"), Value::Enum(0)); + let l = losses(&r); + assert_eq!(l.len(), 1, "{l:?}"); + assert!(l[0].contains("the Look 'SL BLUE ICE' is not imported"), "{l:?}"); + // Only the popup: its item. + let (_, r) = run(&[(25, Rec::Num(5.0, 1))]); + assert!(losses(&r).iter().any(|d| d.contains("the Look 'SL BIG' is not imported")), "{:?}", losses(&r)); +} + +#[test] +fn hue_saturation_curves_with_neutral_points_arrive_moved_ones_are_reported() { + // 1200 `-11-sec-hue-saturation-curves`: the selector put three neutral points on the curve. + let neutral = curve(3, &[(0.742_483, 0.0), (0.798_038, 0.0), (0.853_594, 0.0)]); + let (g, r) = run(&[(42, Rec::Data(neutral))]); + assert_eq!(losses(&r), Vec::::new()); + assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new())); + let (_, r) = run(&[(42, Rec::Data(curve(3, &[(0.5, 0.25)])))]); + let l = losses(&r); + assert!(l.len() == 1 && l[0].contains("Hue vs Sat (ADBE Lumetri-0042) is not imported"), "{l:?}"); + // A point count past the data: not a default, reported, no panic. + let mut bad = curve(3, &[]); + bad[4..8].copy_from_slice(&1000u32.to_le_bytes()); + let (_, r) = run(&[(42, Rec::Data(bad))]); + assert_eq!(losses(&r).len(), 1); +} + +#[test] +fn the_picked_hsl_key_becomes_hue_saturation_and_lightness_ranges() { + let (g, r) = run(&[(73, Rec::Data(hex(HSL_PICKED)))]); + assert_eq!(losses(&r), Vec::::new()); + let close = |id: &str, want: f64| assert!((num(&g, id) - want).abs() < 0.01, "{id}: {} != {want}", num(&g, id)); + // Hue centre 0.5605 of the circle, range 0.05, tolerance 0.1. + close("hslSecondary/key/hueCenter", 201.79); + close("hslSecondary/key/hueRange", 18.0); + close("hslSecondary/key/hueSoftness", 36.0); + // Lightness centre 0.435 +- 0.05, tolerance 0.1, as percent. + close("hslSecondary/key/lightnessLow", 38.53); + close("hslSecondary/key/lightnessHigh", 48.53); + close("hslSecondary/key/lightnessSoftness", 10.0); + assert_eq!(val(&g, "hslSecondary/key/hueEnabled"), Value::Bool(true)); + // With the key picked the correction applies; without, After Effects applies it to nothing + // (1200 `-16-sec-correction` renders as `-00-defaults`): left out and reported. + let (g, _) = run(&[(73, Rec::Data(hex(HSL_PICKED))), (86, Rec::Num(19.0, 1))]); + assert_eq!(num(&g, "hslSecondary/correction/temperature"), 19.0); + let (g, r) = run(&[(86, Rec::Num(19.0, 1))]); + assert_eq!(num(&g, "hslSecondary/correction/temperature"), 0.0); + let l = losses(&r); + assert!(l.len() == 1 && l[0].contains("HSL Secondary has no key colour picked") && l[0].contains("Temperature 19"), "{l:?}"); + // Damaged key data: reported, the default key kept. + let (g, r) = run(&[(73, Rec::Data(vec![7; 40]))]); + assert!(losses(&r).iter().any(|d| d.contains("HSL Secondary key data is unreadable")), "{:?}", losses(&r)); + assert_eq!(num(&g, "hslSecondary/key/hueRange"), 180.0); +} + +#[test] +fn undecoded_data_and_controls_without_counterpart_are_reported_when_set() { + let mut split = vec![0u8; 32]; + split[0] = 1; + let mut w = wheels(4); + w[8] = 1; + let (_, r) = run(&[(32, Rec::Data(split)), (47, Rec::Data(w)), (97, Rec::Num(1.0, 3)), (95, Rec::Data(utf16("my.cube")))]); + let l = losses(&r); + assert_eq!(l.len(), 4, "{l:?}"); + assert!(l.iter().any(|d| d.contains("Split Toning (ADBE Lumetri-0032) is not imported")), "{l:?}"); + assert!(l.iter().any(|d| d.contains("Wheels (ADBE Lumetri-0047) is not imported")), "{l:?}"); + assert!(l.iter().any(|d| d.contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on")), "{l:?}"); + assert!(l.iter().any(|d| d.contains("the Input LUT 'my.cube' is not imported")), "{l:?}"); +} + +#[test] +fn an_unknown_scale_or_parameter_is_reported_not_guessed() { + let mut d = defs(); + // Exposure with another valid range (an older or newer version): not guessed at. + if let Some((_, Def::Float { min, max, .. })) = d.iter_mut().find(|(s, _)| *s == 11) { + (*min, *max) = (-100.0, 100.0); + } + // A parameter this importer does not know, set. + d.push((200, Def::Float { name: "Future", default: 0.0, min: -1.0, max: 1.0 })); + let (g, r) = run_with(&d, &[(11, Rec::Num(3.0, 1)), (200, Rec::Num(0.5, 1))]); + assert_eq!(num(&g, "basicCorrection/tone/exposure"), 0.0); + let l = losses(&r); + assert!(l.iter().any(|x| x.contains("Exposure (ADBE Lumetri-0011) has the valid maximum Some(100.0), expected 7")), "{l:?}"); + assert!(l.iter().any(|x| x.contains("Future (ADBE Lumetri-0200) not imported")), "{l:?}"); +} diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index 4850710ab..d4cdedd98 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -705,32 +705,37 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { pub fn specs() -> Vec { let grey = || col(0.5, 0.5, 0.5); + // After Effects' valid ranges (a value past the slider's drag range is still kept: .aep + // import, 1200), the slider over the range After Effects' slider covers. + let wb = || slider(-300.0, 300.0, -100.0, 100.0, 1); + let tone = || slider(-150.0, 150.0, -100.0, 100.0, 1); + let sat = || slider(0.0, 300.0, 0.0, 200.0, 1); let mut lum = vec![ p("highDynamicRange", "High Dynamic Range", Value::Bool(false), ParamUi::Checkbox), p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None", "Custom"])), // A .cube file path (or the file's text) for Input LUT = Custom. p("basicCorrection/inputLutFile", "Input LUT File", Value::Str(String::new()), ParamUi::Text), - p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), bipolar()), - p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), bipolar()), - p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)), - p("basicCorrection/tone/contrast", "Contrast", num(0.0), bipolar()), - p("basicCorrection/tone/highlights", "Highlights", num(0.0), bipolar()), - p("basicCorrection/tone/shadows", "Shadows", num(0.0), bipolar()), - p("basicCorrection/tone/whites", "Whites", num(0.0), bipolar()), - p("basicCorrection/tone/blacks", "Blacks", num(0.0), bipolar()), + p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), wb()), + p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), wb()), + p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-7.0, 7.0, -5.0, 5.0, 2)), + p("basicCorrection/tone/contrast", "Contrast", num(0.0), tone()), + p("basicCorrection/tone/highlights", "Highlights", num(0.0), tone()), + p("basicCorrection/tone/shadows", "Shadows", num(0.0), tone()), + p("basicCorrection/tone/whites", "Whites", num(0.0), tone()), + p("basicCorrection/tone/blacks", "Blacks", num(0.0), tone()), p("basicCorrection/tone/hdrWhite", "HDR White", num(100.0), slider(100.0, 10000.0, 100.0, 4000.0, 0)), - p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), bipolar()), - p("basicCorrection/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), tone()), + p("basicCorrection/saturation", "Saturation", num(100.0), sat()), p("creative/look", "Look", Value::Enum(0), popup(&LUMETRI_LOOKS)), p("creative/lookFile", "Look File", Value::Str(String::new()), ParamUi::Text), p("creative/lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), - p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), pct()), + p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), slider(0.0, 150.0, 0.0, 100.0, 1)), p("creative/adjustments/sharpen", "Sharpen", num(0.0), bipolar()), p("creative/adjustments/vibrance", "Vibrance", num(0.0), bipolar()), - p("creative/adjustments/creativeSaturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("creative/adjustments/creativeSaturation", "Saturation", num(100.0), sat()), p("creative/adjustments/shadowTint", "Shadow Tint", grey(), ParamUi::Color), p("creative/adjustments/highlightTint", "Highlight Tint", grey(), ParamUi::Color), - p("creative/adjustments/tintBalance", "Tint Balance", num(0.0), bipolar()), + p("creative/adjustments/tintBalance", "Tint Balance", num(0.0), tone()), ]; const CURVES: [(&str, &str); 12] = [ ("curves/curveMasterShadows", "RGB Curve Shadows"), @@ -786,13 +791,13 @@ pub fn specs() -> Vec { p("hslSecondary/key/maskStyle", "Mask Style", Value::Enum(0), popup(&HSL_MASK_STYLES)), p("hslSecondary/key/invertMask", "Invert Mask", Value::Bool(false), ParamUi::Checkbox), p("hslSecondary/refine/denoise", "Denoise", num(0.0), pct()), - p("hslSecondary/refine/blur", "Blur", num(0.0), pct()), + p("hslSecondary/refine/blur", "Blur", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), p("hslSecondary/correction/correctionWheel", "Correction", grey(), ParamUi::Color), - p("hslSecondary/correction/temperature", "Temperature", num(0.0), bipolar()), - p("hslSecondary/correction/tint", "Tint", num(0.0), bipolar()), - p("hslSecondary/correction/contrast", "Contrast", num(0.0), bipolar()), + p("hslSecondary/correction/temperature", "Temperature", num(0.0), wb()), + p("hslSecondary/correction/tint", "Tint", num(0.0), wb()), + p("hslSecondary/correction/contrast", "Contrast", num(0.0), tone()), p("hslSecondary/correction/sharpen", "Sharpen", num(0.0), bipolar()), - p("hslSecondary/correction/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("hslSecondary/correction/saturation", "Saturation", num(100.0), sat()), p("vignette/vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)), p("vignette/vignetteMidpoint", "Midpoint", num(50.0), pct()), p("vignette/vignetteRoundness", "Roundness", num(0.0), bipolar()), diff --git a/docs/aep-format.md b/docs/aep-format.md index fcc8f53be..9a37b384f 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -534,6 +534,44 @@ suffix is a parameter id, not the `parT` position (Curves -0002 Channel before - -0005 Quality before -0004). API: `CurvesData`, `MeshData`, `curve_lut`. [V; details: corpus FORMAT S] +**Lumetri Color** (`ADBE Lumetri`, 1200: 58 compositions made by script in After Effects 2026, 32 bpc, +working space None; defaults, all set, one per section, valid minima and maxima, every popup item the +script can set, checkboxes toggled, keys, an expression): 131 `parT` entries. The `-NNNN` suffix is a +parameter id, not the position (`-0127` ... `-0130` sit between `-0005` and `-0026`), so the importer +keys by suffix. Float sliders (type 10) store the value After Effects shows; the valid ranges (`pard` +104 / 108) are wider than the slider's (112 / 116): Temperature / Tint -300..300 (slider -100..100), +Saturation 0..300 (0..200), Exposure -7..7 (-5..5), Contrast, Highlights, Shadows, Whites, Blacks, +HDR Specular, Tint Balance -150..150 (-100..100), Faded Film 0..150 (0..100), HDR White 100..1000, +HDR Range 100..10000, Refine Blur 0..1000 (0..30), Vignette Amount -5..5 (-3..3); the read-backs of +`-19-min` / `-20-max` equal the stored numbers. **Hidden controls are recorded in the default state** +(`tdsb` 3) and still hold the value After Effects reads back: High Dynamic Range `-0062` (1.0), +HDR Specular `-0064` (22), the second Look popup `-0128` (26.16 as set, read back 26), Legacy Tone And +Saturation `-0097`, Auto Color `-0124`, Face Detection `-0102`, the Auto Intensity `-0123` +(`EffectParam::recorded_value`). Each section has an Active checkbox (blank `pard` name; `-0003` Basic +Correction, `-0023` Creative, `-0037` Curves, `-0118` RGB Curves, `-0104` / `-0105` / `-0107` / `-0109` / +`-0111` the hue / saturation curves, `-0046` Color Wheels, `-0068` HSL Secondary, `-0050` Vignette, on +by default); off, the section does nothing (`-03-sec-basic-correction`, `-17-sec-vignette` render as +`-00-defaults`). Arbitrary data (type 11, a record and `aRbs` in every instance; empty is `fe fe`): +`-0001` an XML copy of the grade written only when the Look changes (not kept current: `-01-set-all` +holds defaults for controls set after the Look); `-0096` the Look's name, UTF-16LE (`SL BLUE ICE`; the +popup `-0025` itself has no record, `-0128` can disagree with it: `-07-sec-creative` names item 20 +while `-0128` holds 23), `-0095` likewise for the Input LUT (H, by analogy: no sample sets a LUT); +`-0129` the Look colour space's 16-byte id (the GUID `LookColorSpace` of `-0001`, byte-swapped; zero +for None; the Color Space popup `-0130` writes no record of its own, `-22-pop38-1` ... `-53-pop38-33`); +`-0032` Split Toning (32 zero bytes); `-0039` the four RGB curves and `-0042`, `-0106`, `-0108`, +`-0110`, `-0112` the hue / saturation curves, 520 bytes each: `i32` type (3 for the hue curves, 2 for +Luma vs Sat and Sat vs Sat, 0 for an RGB curve), `u32` point count, then little-endian `f64` pairs x, y +(RGB: (0, 0), (1, 1) by default; hue / saturation: no points, and the selector colours `-0113` ... +`-0117` add three points at y = 0, the hue picked +- 0.0556; no sample moves a point, so y's scale is +H); `-0047` Color Wheels (4 x `f64` triples, 0, 0.5, 0 each) and `-0085` the HSL correction wheels (8 +triples, the same); `-0073` the HSL key, 40 bytes: a flag byte (1 once a colour was picked), then hue, +saturation, lightness, each centre, range, tolerance (`f32` LE, fractions) and an enabled byte (fresh: +0.5, 0, 0, on; picking RGB 0.02 0.55 0.85 gives hue centre 0.5605 = that colour's hue and lightness +centre 0.435 = its HSL lightness, range 0.05, tolerance 0.1: `-01-set-all`; whether range is the half +or full width is H); `-0122` the Auto Tone analysis (UTF-16 JSON). Curves, LUTs and wheels are not +scriptable: the presets for them are still to be made. [V for the sliders, checkboxes, popups and the +`tdsb` 3 values; H where marked] + ### 3.8 Masks and bezier paths (`tdgp[ADBE Mask Parade]`) | Field | Chunk path | Offset | Type | Meaning | Evidence | Conf. | diff --git a/docs/aep-import.md b/docs/aep-import.md index baeb8d0e1..07c089782 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** 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-05 · **Change:** 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: 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` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -58,7 +58,7 @@ outside the sampled ones keeps the current value and is reported as `project set | 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) | | Text animators | every animator with its name and its range selectors (Start, End, Offset, or the Index range when Units is Index; Units, Based On, Mode, Amount, Shape, Smoothness, Ease High / Low, Randomize Order, Random Seed) and the properties Anchor Point, Position, Scale, Skew, Skew Axis, Rotation, Opacity, Fill Color, Stroke Color, Stroke Width, Tracking Amount, Line Spacing, Blur, Character Offset and Character Range, with keys and expressions (579-594, 114, 728). Values are stored as After Effects shows them; popups are 1-based in the file | | Masks | every mask of an imported layer, in stack order (on text and shape layers, which have no source, the stored mask box is in layer pixels: 705, 706): name, path (vertices and tangents in layer pixels, open or closed), mode (None, Add, Subtract, Intersect, Lighten, Darken, Difference), inverted, feather (x, y in pixels), opacity (percent), expansion (pixels); path keyframes (time, linear or hold sides, roving, label), feather / opacity / expansion keyframes with ease | -| Effects | **Curves** (961-964: the five curves' points from the arbitrary data as EffectCraft's `x,y` points in 0..1; render within 6 levels: EffectCraft interpolates monotone, After Effects stores a natural spline) and **Mesh Warp** (965, 985: Rows, Columns, Quality and the Distortion Mesh as per-vertex offsets in layer pixels; Bezier handles moved on their own are kept as eight extra numbers of the vertex for the export and reported, EffectCraft draws the mesh bilinear; the header is rows + 1, columns + 1; render 985: 1.4 % of pixels > 2 levels around the moved vertex), **Tint**, **Gradient Ramp**, **Exposure**, **Invert**, **Fast Box Blur**, **Gaussian Blur**, **Drop Shadow**, **Glow**, **Fill**, **Hue/Saturation**, **Levels**, **Timewarp** (Method, Adjust Time By, Speed with keys, Source Frame; 529-533), **Directional Blur**, **Radial Blur**, **CC Radial Blur**, **Venetian Blinds**, **Channel Blur**, **Black & White**, **Tritone**, **Luma Key**, **Simple Choker**, **Matte Choker**, **Transform** (all parameters; 711-723) and the expression controls **Slider**, **Angle**, **Checkbox**, **Color** and **Point Control** (table below) on any imported layer (also text and shape layers and layers with masks), in stack order, with the instance name, every parameter (static value or default) and its keyframes; also the second and later instances of a type, whose parameter definitions are only in the project's shared definition list. Other effects are reported (below) | +| Effects | **Lumetri Color** (1200; see *Lumetri Color* below), **Curves** (961-964: the five curves' points from the arbitrary data as EffectCraft's `x,y` points in 0..1; render within 6 levels: EffectCraft interpolates monotone, After Effects stores a natural spline) and **Mesh Warp** (965, 985: Rows, Columns, Quality and the Distortion Mesh as per-vertex offsets in layer pixels; Bezier handles moved on their own are kept as eight extra numbers of the vertex for the export and reported, EffectCraft draws the mesh bilinear; the header is rows + 1, columns + 1; render 985: 1.4 % of pixels > 2 levels around the moved vertex), **Tint**, **Gradient Ramp**, **Exposure**, **Invert**, **Fast Box Blur**, **Gaussian Blur**, **Drop Shadow**, **Glow**, **Fill**, **Hue/Saturation**, **Levels**, **Timewarp** (Method, Adjust Time By, Speed with keys, Source Frame; 529-533), **Directional Blur**, **Radial Blur**, **CC Radial Blur**, **Venetian Blinds**, **Channel Blur**, **Black & White**, **Tritone**, **Luma Key**, **Simple Choker**, **Matte Choker**, **Transform** (all parameters; 711-723) and the expression controls **Slider**, **Angle**, **Checkbox**, **Color** and **Point Control** (table below) on any imported layer (also text and shape layers and layers with masks), in stack order, with the instance name, every parameter (static value or default) and its keyframes; also the second and later instances of a type, whose parameter definitions are only in the project's shared definition list. Other effects are reported (below) | | Layer styles | Drop Shadow, Inner Shadow, Outer Glow, Inner Glow, Bevel and Emboss, Satin, Color Overlay, Gradient Overlay and Stroke when applied (switch group `tdsb` 1): every parameter, starting from After Effects' defaults (Use Global Light is off there), with keys on scalars and colours and expressions; Blending Options: Fill Opacity, the channel switches, Blend Interior Styles as Group, Use Blend Ranges, and Global Light Angle / Altitude, which the composition adopts (one setting per composition here). Units and popups through `effectcraft_aep::style_map` (aep-format 3.12b). Render check against After Effects (`proof\styles`): drop shadow, inner shadow, satin, colour overlay, stroke within a few levels; glow and bevel intensities and the Gradient Overlay direction differ in EffectCraft's renderer | | 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 | @@ -179,6 +179,7 @@ parameter gets the file's value: | `ADBE Easy Levels2` 1 Channel, 2 Histogram data, 8 / 9 Clip To Output Black / White | `channel`; `inBlack`, `inWhite`, `gamma`, `outBlack`, `outWhite` from the data; `clipToOutputBlack`, `clipToOutputWhite` | popups by name (After Effects' items On, Off, Off for 32 bpc Color are in another order here); the RGB values are the `f32` of the arbitrary data; the per-channel values are reported when they are not at their defaults (not sampled) | 511-516 | | Batch 6 (903 defaults, 904 / 905 set values): `ADBE Brightness & Contrast 2`, `ADBE Turbulent Displace`, `ADBE Fractal Noise`, `ADBE Laser` (Beam), `CC Radial Fast Blur`, `ADBE Noise2`, `ADBE Pro Levels2` (Levels (Individual Controls)), `ADBE Corner Pin`, `ADBE Grid`, `ADBE Sharpen`, `ADBE Linear Color Key2`, `ADBE Radial Wipe`, `ADBE Remove Color Matting`, `CC Bend It`, `CC Power Pin`, `ADBE 4ColorGradient`, `ADBE Change Color`, `ADBE Extract`, `ADBE Noise HLS2`, `ADBE Scatter`, `CC Light Sweep`, `ADBE Compound Blur`, `ADBE Displacement Map`, `ADBE Set Matte3`, `ADBE Gaussian Blur` (legacy), `ADBE OFMotionBlur` (Pixel Motion Blur), `ADBE Layer Control` | the EffectCraft effect of the same name, every parameter EffectCraft has | sliders as stored (each checked against its valid maximum); Beam Length, Time and Softness and CC Power Pin Perspective are 0..1 fractions, percentages here; points as layer pixels; popups by item name (Set Matte's `Red Channel` ... is `Red` ...); twirl-down group markers and the Levels histogram carry nothing; **layer parameters** (below) | 903, 904, 905; 220, 320, 321 | | `ADBE Slider Control`, `ADBE Angle Control`, `ADBE Checkbox Control`, `ADBE Color Control`, `ADBE Point Control` (parameter 1) | `ec.control.slider` `slider`, `ec.control.angle` `angle`, `ec.control.checkbox` `checkbox`, `ec.control.color` `color`, `ec.control.point` `point` | float (valid max 1 000 000) as stored; degrees; checkbox; colour; point in layer pixels | 071, 073, 217-219, 221, 304, 310 | +| `ADBE Lumetri` by match-name suffix (1200), not by index: -0062 High Dynamic Range; -0063 HDR White; -0007 / -0008 Temperature / Tint; -0020 Saturation; -0011 Exposure; -0012 ... -0016 Contrast, Highlights, Shadows, Whites, Blacks; -0064 HDR Specular; -0026 Look Intensity; -0028 Faded Film; -0029 Sharpen; -0030 Vibrance; -0031 Saturation; -0033 Tint Balance; -0074 Show Mask; -0075 mask style; -0076 Invert Mask; -0081 Denoise; -0082 Blur; -0086 ... -0090 the HSL correction's Temperature, Tint, Contrast, Sharpen, Saturation; -0051 ... -0054 Vignette Amount, Midpoint, Roundness, Feather; -0073 the HSL key data | `highDynamicRange`, `basicCorrection/tone/hdrWhite`, `basicCorrection/whiteBalance/temperature` / `tint`, `basicCorrection/saturation`, `basicCorrection/tone/exposure`, `.../contrast`, `highlights`, `shadows`, `whites`, `blacks`, `hdrSpecular`, `creative/lookIntensity`, `creative/adjustments/fadedFilm`, `sharpen`, `vibrance`, `creativeSaturation`, `tintBalance`, `hslSecondary/key/showMask`, `maskStyle`, `invertMask`, `hslSecondary/refine/denoise`, `blur`, `hslSecondary/correction/*`, `vignette/*`; `hslSecondary/key/hue*`, `saturation*`, `lightness*` | sliders: the stored number x 1 (valid maxima 1000, 300, 300, 7, 150, 200, 150, 100, 100, 300, 150, 100, 1000, 5, 100 as in `pard`); checkboxes and the popup as elsewhere, **also from records in the default state** (`tdsb` 3); the key: hue centre x 360 degrees, range x 360 as the half width, tolerance x 360 as the softness, saturation and lightness centre +- range x 100 percent, tolerance x 100, once a colour was picked | 1200: all 62 read-backs of mapped controls in sections that act equal after import (min, max, sections, popups); the other 61 lie in sections that do nothing and are reported; keys and the expression on HDR White (`-56-keys`, `-57-expr`) | - **Nothing to import:** the implicit input-layer parameter (index 0), the Swap Colors buttons (Tint 4, Ramp 8: they always have a record and hold no value), Exposure's twirl-down group @@ -230,6 +231,28 @@ parameter gets the file's value: column just outside the layer: After Effects' Glow draws a dark halo about 10 px wide outside the layer (red 0.55, 0.65, 0.74, 0.92 at 0, 1, 2, 5 px), EffectCraft's Glow none (an effect difference, recorded for a separate fix). +- **Lumetri Color** (1200, `crates/aep/src/import/lumetri.rs`). The parameters are found by match + name (After Effects inserted `-0127` ... `-0130` mid-list; five tone sliders share one valid range, + so an index shift would not be caught). Values come from every value record, also those in the + default state (`tdsb` 3), where Lumetri keeps High Dynamic Range, HDR Specular and the second Look + popup. EffectCraft's Lumetri has no section switches: a section that is off in After Effects + (Basic Correction, Creative, Curves / RGB Curves, each hue / saturation curve, Color Wheels, HSL + Secondary, Vignette), and HSL Secondary without a picked key colour, does nothing there, so its set + values are listed in one report line and not imported (the frame stays as After Effects draws it). + The Input LUT and the Look are reported by name (LUT files are not in the project). Controls + without a counterpart (the Auto tone's Intensity, RGB Curves' HDR Range, Color Wheels' HDR White and + Face Detection, Legacy Tone And Saturation, Auto Color, SpeedGrade Custom Layer), the eyedropper + colours, and the RGB curves, colour wheels, Split Toning, HSL correction wheels and moved hue / + saturation curve points (not sampled) are reported when set. Render check at 0.5 s against After + Effects' 32 bpc PNGs (stored as value x 6553.5 in 16 bits, so read as raw / 6553.5): every + composition whose grade After Effects draws as identity (defaults, set-all with every section off, + the switched-off sections, the popups, keys, expression) is within 1/255 in EffectCraft; the + graded ones differ by EffectCraft's Lumetri formulas, not by the import: White Balance (sec-color: + After Effects warms and brightens, max 0.10 above the default; EffectCraft darkens, mean error + 0.015), Exposure / tone (sec-light: After Effects rolls highlights off and lifts the blacks to 0.18, + EffectCraft clips at 1 and keeps black at 0; mean 0.12), Creative adjustments (sec-adjustments: + Faded Film lifts black to 0.07 in After Effects, 0.035 here) and the extremes (min / max: mean 0.063 / + 0.454; max: After Effects' vignette at +5 and the tone sliders give mid grey, EffectCraft white). - **Colour.** After Effects stores the colour its picker shows as ARGB doubles 0..255 in the project's working-space encoding; EffectCraft's parameter colours are straight RGBA 0..1 that the render pipeline hands to the effect as stored (`crates/render/src/color.rs`: authored colours are @@ -389,5 +412,5 @@ crate yet. ## Not done yet -Effects outside the table (Lumetri; EXtractoR: its channel choice is arbitrary data; Stroke: its path is a mask reference), environment lights, the ease of bezier Orientation keys, wiggly and expression selectors and the other text animator properties (Character Value, Character Alignment other than its default, the Hue / Saturation / Brightness / Opacity variants, 3D rotations, variable font axes), manual kerning, No Break, the stroke line join, the Japanese leading model, Source Text keyframes, layer-style gradients, Pattern Overlay and the switch state 3, Mesh Warp handles moved on their own, +Lumetri Color's section switches (a switched-off section is reported and its values left out), its LUTs and looks (files After Effects names: reported by name), its RGB curves, colour wheels, Split Toning and HSL correction wheels (not sampled: reported when set) and moved hue / saturation curve points (the scale of y is not sampled); effects outside the table (EXtractoR: its channel choice is arbitrary data; Stroke: its path is a mask reference), environment lights, the ease of bezier Orientation keys, wiggly and expression selectors and the other text animator properties (Character Value, Character Alignment other than its default, the Hue / Saturation / Brightness / Opacity variants, 3D rotations, variable font axes), manual kerning, No Break, the stroke line join, the Japanese leading model, Source Text keyframes, layer-style gradients, Pattern Overlay and the switch state 3, Mesh Warp handles moved on their own, separated dimensions and footage interpretation need verified layouts in the reader first. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef21511d..f94fc3789 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 | none | 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 (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)). Export to `.aep` is not built yet. Effects outside the table (plug-ins, Lumetri, …) are not converted | `crates/aep` tests (`tests/it`: `container`, `import`) | +| Project `.aep` | R/W (native) | partial | none | 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 (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)). Export to `.aep` is not built yet. Effects outside the table (plug-ins, …) are not converted | `crates/aep` tests (`tests/it`: `container`, `import`) | | 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 | From 9c25944c700ab7ac8463462b006f34425b8e6a39 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 17:45:19 +0100 Subject: [PATCH 11/94] AEP import: EXtractoR's and IDentifier's chosen channels by name Presets of our own (blocks 1300 and 1310) show what the Channel Info stores for a chosen channel. - EXtractoR: each record is a kind in byte 0 and the channel name. Named channels are imported by name (Z, Z, Z renders equal to After Effects) and `(copy)` as the input's own channel. The kind is byte 0 only: one definition default has `02 00 00 32`. - IDentifier: byte 19 holds the chosen channel's name, `Object ID` when none is chosen. After Effects lists only UINT channels and leaves the layer unchanged without one, which the loss now says. A chosen `ObjectID` / `MaterialID` channel (any case) is the one EffectCraft reads itself, so it imports without a loss; other names are reported. Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/container/arbitrary.rs | 39 +++++++++--- crates/aep/src/container/effect_map.rs | 4 +- crates/aep/src/import/effects.rs | 49 +++++++++++---- .../aep/tests/it/import/effects_thirdparty.rs | 62 +++++++++++++++---- docs/aep-format.md | 24 +++++-- docs/aep-import.md | 14 ++++- 6 files changed, 149 insertions(+), 43 deletions(-) diff --git a/crates/aep/src/container/arbitrary.rs b/crates/aep/src/container/arbitrary.rs index 7b47e83c7..cbcc56c53 100644 --- a/crates/aep/src/container/arbitrary.rs +++ b/crates/aep/src/container/arbitrary.rs @@ -309,10 +309,14 @@ impl MeshData { } /// EXtractoR's Channel Info data (`EXtractoR-0013`, 688 bytes, the plug-in's own little-endian -/// layout; read from the default instances of the importer block of 2026-11-05, where no channel -/// was changed): a `u32` 1 and 28 bytes, then four records of 164 bytes (Red, Green, Blue, Alpha), -/// each a `u32` kind (1 a named channel, 2 the input's channel: `(copy)` on Alpha) and the channel -/// name from byte 35, NUL-terminated. Only the default (`R`, `G`, `B`, `(copy)`) is sampled. +/// layout; read from the instances of the importer block of 2026-11-05: the defaults and a preset +/// of our own that reads `Z` into Red, Green and Blue): a `u32` 1 and 28 bytes, then four records +/// of 164 bytes (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by +/// name, 2 the input's own channel: `(copy)`; bytes 1..4 are not always zero, `0x32` at 3 in one +/// definition's default, so only byte 0 is read) and the channel name from byte 35, +/// NUL-terminated. The other bytes are not read (they differ between instances with the same +/// channels; the `u32` at 4 of a chosen channel looks like its position in the footage's sorted +/// channel list, 13 of 14 for `Z`, where the defaults hold 0). Only the name is read. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExtractorChannels { /// Per output channel (red, green, blue, alpha): the kind and the name. @@ -333,7 +337,7 @@ impl ExtractorChannels { for (k, slot) in channels.iter_mut().enumerate() { let at = 32usize.checked_add(k.checked_mul(Self::RECORD)?)?; let rec = data.get(at..at.checked_add(Self::RECORD)?)?; - let kind = u32::from_le_bytes(*rec.first_chunk::<4>()?); + let kind = u32::from(*rec.first()?); let name = rec.get(35..)?; let end = name.iter().position(|b| *b == 0)?; let name = std::str::from_utf8(name.get(..end)?).ok()?; @@ -348,11 +352,30 @@ impl ExtractorChannels { let want = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")]; self.channels.iter().zip(want).all(|((k, n), (wk, wn))| *k == wk && n == wn) } + + /// Per output channel (red, green, blue, alpha) the channel it reads, `Err` with the + /// position of the first record of another kind or without a name: a named channel by its + /// name, `(copy)` as the input's own channel of the same position (`R`, `G`, `B`, `A`). + pub fn sources(&self) -> Result<[String; 4], usize> { + const OWN: [&str; 4] = ["R", "G", "B", "A"]; + let mut out: [String; 4] = Default::default(); + for (k, ((kind, name), slot)) in self.channels.iter().zip(out.iter_mut()).enumerate() { + *slot = match (kind, name.trim()) { + (1, n) if !n.is_empty() => n.to_string(), + (2, _) => OWN.get(k).ok_or(k)?.to_string(), + _ => return Err(k), + }; + } + Ok(out) + } } -/// IDentifier's Channel Info data (`IDentifier-0005`, 148 bytes): the channel type's name, -/// NUL-terminated from byte 19 (`Object ID` in every sample of the importer block of 2026-11-05; -/// the bytes around it differ between instances with the same settings and are not read). +/// IDentifier's Channel Info data (`IDentifier-0005`, 148 bytes): the chosen channel's name, +/// NUL-terminated from byte 19, or `Object ID` when none is chosen (importer blocks 1300 and 1310 +/// of 2026-11-05: `objectId` in a preset of our own that chose that UINT channel, `Object ID` in +/// every other instance). The bytes around it differ between instances with the same settings +/// and are not read (a chosen channel has 15 at byte 4, which looks like its position in the +/// EXR's sorted channel list, as for EXtractoR). pub fn identifier_channel_type(data: &[u8]) -> Option<&str> { let s = data.get(19..)?; let end = s.iter().position(|b| *b == 0)?; diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index 46fcf39ea..7f426bcd1 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -138,9 +138,9 @@ pub enum Extra { Curves, /// Mesh Warp's Distortion Mesh (965, 985, `MeshData`). MeshWarp, - /// EXtractoR's Channel Info (`ExtractorChannels`): the default channels only. + /// EXtractoR's Channel Info (`ExtractorChannels`): the channels by name. Extractor, - /// IDentifier's Channel Info (`identifier_channel_type`): Object ID only. + /// IDentifier's Channel Info (`identifier_channel_type`): the chosen channel by name. Identifier, } diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index a9fa3c2c7..5a8a60285 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -465,44 +465,67 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h Some(m) => import_mesh(&m, here, size, group, col), None => col.lose(LossCategory::DamagedData, format!("{here}: the Distortion Mesh is unreadable, defaults used")), }, - Extra::Extractor => match ExtractorChannels::parse(data) { - // The fresh channels: EffectCraft's own R, G, B, A (its A is the input's alpha, After - // Effects' `(copy)`). - Some(c) if c.is_default() => { - for (id, name) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { + Extra::Extractor => match ExtractorChannels::parse(data).map(|c| (c.sources(), c)) { + // Each output channel reads the footage's channel of the stored name, which + // EffectCraft's EXtractoR finds by the same name (`Z`); After Effects' `(copy)` is the + // input's own channel, EffectCraft's R, G, B or A (the fresh channels are R, G, B, A). + Some((Ok(names), _)) => { + for (id, name) in ["red", "green", "blue", "alpha"].into_iter().zip(names) { if let Some(prop) = group.prop_mut(id) { - prop.value = Value::Str(name.into()); + prop.value = Value::Str(name); } } } - Some(c) => { + Some((Err(k), c)) => { let names: Vec<&str> = c.channels.iter().map(|(_, n)| n.as_str()).collect(); col.lose( LossCategory::Effects, - format!("{here}: the channels {} are not imported (only the default channels are sampled), R, G, B, A used", names.join(", ")), + format!( + "{here}: the channels {} are not imported (record {} is of an unknown kind or names no channel), R, G, B, A used", + names.join(", "), + k.saturating_add(1) + ), ); } None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, R, G, B, A used")), }, Extra::Identifier => match crate::identifier_channel_type(data) { + // No channel chosen (the type's label): After Effects lists only the footage's integer + // (UINT) channels and, with none chosen, leaves the layer unchanged, where EffectCraft + // draws the footage's object ID channel. Some("Object ID") => { if let Some(prop) = group.prop_mut("channelType") { prop.value = Value::Enum(0); } - // The data names the type, not the channel After Effects reads: with this Channel - // Info After Effects left the sampled EXR unchanged, where EffectCraft draws the - // footage's object ID channel. col.lose( LossCategory::Effects, - format!("{here}: the channel IDentifier reads (Channel Info) is not decoded; EffectCraft uses the footage's object ID channel"), + format!("{here}: no ID channel is chosen (Channel Info), so After Effects leaves the layer unchanged; EffectCraft draws the footage's object ID channel"), ); } - other => col.lose(LossCategory::Effects, format!("{here}: the channel type {other:?} is not imported (only Object ID is sampled), Object ID used")), + // A chosen channel, by name. EffectCraft's IDentifier has no channel of its own: it + // reads the footage's first `ObjectID` (`MaterialID`) channel, names compared without + // regard to case, so a channel of that name is the one After Effects reads. + Some(name) => match IDENTIFIER_OWN.iter().position(|own| own.eq_ignore_ascii_case(name.trim())) { + Some(kind) => { + if let Some(prop) = group.prop_mut("channelType") { + prop.value = Value::Enum(u32::try_from(kind).unwrap_or(0)); + } + } + None => col.lose( + LossCategory::Effects, + format!("{here}: the ID channel '{name}' (Channel Info) is not imported; EffectCraft reads the footage's object ID channel (ObjectID, object_id, objectId, id)"), + ), + }, + None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, the object ID channel used")), }, Extra::None => {} } } +/// The channels EffectCraft's IDentifier reads first for its Channel Type Object ID and Material +/// ID (`AuxChannels::object_id`, `material_id`). +const IDENTIFIER_OWN: [&str; 2] = ["ObjectID", "MaterialID"]; + /// Curves' EffectCraft parameters, in the stored order (RGB, Red, Green, Blue, Alpha). const CURVE_IDS: [&str; 5] = ["rgb", "red", "green", "blue", "alpha"]; diff --git a/crates/aep/tests/it/import/effects_thirdparty.rs b/crates/aep/tests/it/import/effects_thirdparty.rs index 20f0b8230..7bf1ad3c8 100644 --- a/crates/aep/tests/it/import/effects_thirdparty.rs +++ b/crates/aep/tests/it/import/effects_thirdparty.rs @@ -112,6 +112,11 @@ fn extractor_channel_info_layout() { assert!(!c.is_default()); assert_eq!(c.channels[0], (1, "Z".to_string())); assert_eq!(ExtractorChannels::parse(&[0u8; 10]), None); + // The kind is byte 0 of a record: the definition default of `extractor-00-defaults` has + // `02 00 00 32` on Alpha, still the input's own channel. + let c = ExtractorChannels::parse(&channels([(1, "R"), (1, "G"), (1, "B"), (0x3200_0002, "(copy)")])).expect("parses"); + assert!(c.is_default()); + assert_eq!(c.sources(), Ok(["R", "G", "B", "A"].map(String::from))); } #[test] @@ -129,12 +134,32 @@ fn extractor_points_unmult_and_default_channels() { } } +/// `extractor-07-preset-channel-z` (a preset of our own): `Z` into Red, Green and Blue, Alpha +/// `(copy)`. EffectCraft's EXtractoR reads the footage's channel by the same name. #[test] -fn extractor_channels_other_than_the_default_are_reported() { +fn extractor_channels_are_imported_by_name() { let (g, r) = run(&extractor(vec![], channels([(1, "Z"), (1, "Z"), (1, "Z"), (2, "(copy)")])), "ec.3d.extractor"); - let found = losses(&r, LossCategory::Effects, "EXtractoR"); - assert!(found.len() == 1 && found[0].contains("the channels Z, Z, Z, (copy) are not imported"), "{found:?}"); - assert_eq!(v(&g.expect("imported"), "red"), Value::Str("R".into())); + assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::::new()); + let g = g.expect("imported"); + for (id, want) in [("red", "Z"), ("green", "Z"), ("blue", "Z"), ("alpha", "A")] { + assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); + } + // `(copy)` on another channel is that channel of the input. + let (g, _) = run(&extractor(vec![], channels([(2, "(copy)"), (1, "CryptoObject00.R"), (2, "(copy)"), (1, "Z")])), "ec.3d.extractor"); + let g = g.expect("imported"); + for (id, want) in [("red", "R"), ("green", "CryptoObject00.R"), ("blue", "B"), ("alpha", "Z")] { + assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); + } +} + +#[test] +fn extractor_channels_of_an_unknown_kind_are_reported() { + for recs in [[(1, "Z"), (3, "Z"), (1, "Z"), (2, "(copy)")], [(1, "Z"), (1, ""), (1, "Z"), (2, "(copy)")]] { + let (g, r) = run(&extractor(vec![], channels(recs)), "ec.3d.extractor"); + let found = losses(&r, LossCategory::Effects, "EXtractoR"); + assert!(found.len() == 1 && found[0].contains("are not imported (record 2 is of an unknown kind or names no channel)"), "{recs:?}: {found:?}"); + assert_eq!(v(&g.expect("imported"), "red"), Value::Str("R".into()), "{recs:?}"); + } } /// IDentifier's Channel Info: the channel type's name at 19 among bytes that are not read. @@ -164,16 +189,31 @@ fn identifier(values: Vec<(usize, f64)>, data: Vec) -> Fx { #[test] fn identifier_display_id_and_channel_type() { assert_eq!(identifier_channel_type(&id_info("Object ID")), Some("Object ID")); - // `identifier-01-set-all` (ID 10) and `identifier-05-pop2-3` (Alpha Matte). + // `identifier-01-set-all` (ID 10) and `identifier-05-pop2-3` (Alpha Matte): no channel chosen + // (After Effects found none or none was picked), so After Effects leaves the layer unchanged. let (g, r) = run(&identifier(vec![(2, 3.0), (3, 10.0)], id_info("Object ID")), "ec.3d.identifier"); - // Which channel After Effects reads is not decoded (it left the sampled EXR unchanged): reported. let found = losses(&r, LossCategory::Effects, "IDentifier"); - assert!(found.len() == 1 && found[0].contains("is not decoded"), "{found:?}"); + assert!(found.len() == 1 && found[0].contains("no ID channel is chosen"), "{found:?}"); let g = g.expect("imported"); assert_eq!((v(&g, "display"), v(&g, "channelType")), (Value::Enum(2), Value::Enum(0))); near(&g, "id", 10.0); - // A channel type that is not sampled is reported. - let (_, r) = run(&identifier(vec![], id_info("Material ID")), "ec.3d.identifier"); - let found = losses(&r, LossCategory::Effects, "IDentifier"); - assert!(found.len() == 1 && found[0].contains("\"Material ID\""), "{found:?}"); +} + +/// `identifier-09-preset-channel-objectid-id2` (1310, a preset of our own): the UINT channel +/// `objectId` chosen and ID 2. EffectCraft's IDentifier reads that channel itself (`ObjectID` +/// without regard to case), so nothing is lost; another name is reported. +#[test] +fn identifier_chosen_channels_by_name() { + for (name, kind) in [("objectId", 0), ("ObjectID", 0), ("materialid", 1)] { + let (g, r) = run(&identifier(vec![(3, 2.0)], id_info(name)), "ec.3d.identifier"); + assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::::new(), "{name}"); + let g = g.expect("imported"); + assert_eq!(v(&g, "channelType"), Value::Enum(kind), "{name}"); + near(&g, "id", 2.0); + } + for name in ["IndexOB.X", "ID", "Material ID"] { + let (_, r) = run(&identifier(vec![], id_info(name)), "ec.3d.identifier"); + let found = losses(&r, LossCategory::Effects, "IDentifier"); + assert!(found.len() == 1 && found[0].contains(&format!("the ID channel '{name}' (Channel Info) is not imported")), "{name}: {found:?}"); + } } diff --git a/docs/aep-format.md b/docs/aep-format.md index 29ee51963..7b9e67981 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -542,12 +542,24 @@ data layouts, read from default instances only):** | `EXtractoR` (2.6.6) | 1 Channel Info (`EXtractoR-0013`): arbitrary data, 688 bytes, below; 2-6 the Process group: 3 Black Point and 4 White Point float sliders, valid ±3.4028235e38 (the float32 range; slider ±1 000), default 0 / 1; 5 UnMult: checkbox (empty `pard` name, the label in `pdnm`), default off | | `IDentifier` (2.6.6) | 1 Channel Info (`IDentifier-0005`): arbitrary data, 148 bytes, below; 2 Display: popup (Colors, Luma Matte, Alpha Matte, Raw), default 1; 3 ID: integer slider, max 2 147 483 647, default 0 | -EXtractoR's Channel Info is the plug-in's own **little-endian** layout: `u32` 1 and 28 zero bytes, -then four 164-byte records (Red, Green, Blue, Alpha), each a `u32` kind (1 in R, G, B; 2 in Alpha, -whose name is `(copy)`) and the channel name NUL-terminated from record byte 35 (`R`, `G`, `B`). -The data is the same in every composition of the block. IDentifier's data differs between -instances with the same settings except for the channel type's name, NUL-terminated from byte 19 -(`Object ID`); the other bytes are not read. API: `ExtractorChannels`, `identifier_channel_type`. +EXtractoR's Channel Info is the plug-in's own **little-endian** layout: `u32` 1 and 28 bytes, then +four 164-byte records (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by +name: `R`, `G`, `B` by default; 2 the input's own channel, named `(copy)`: Alpha by default) and the +channel name NUL-terminated from record byte 35. The rest is not read: it differs between instances +with the same channels (uninitialised-looking values, also in bytes 1..4 of a record: `02 00 00 32` +on Alpha in one definition's default, which is why only byte 0 is the kind). **A chosen channel** +(`extractor-07-preset-channel-z`, a preset of our own that reads `Z` into Red, Green and Blue) is kind +1 with its name (`Z`), and the `u32` at record byte 4 is then 13, which looks like the channel's +position in the EXR's sorted channel list of 14 (the defaults hold 0 there: H). IDentifier's data +differs between instances with the same settings except for **the chosen channel's name**, +NUL-terminated from byte 19, which is `Object ID` when no channel is chosen. After Effects lists +only integer (UINT) channels: in 1300's EXR (a FLOAT `ID` channel) it found none ("No ID channels +available"), so that block's preset `identifier-09-preset-channel-id` only sets ID 2; in 1310's +EXR (UINT `objectId` and `IndexOB.X` added) the preset `identifier-09-preset-channel-objectid-id2` +stores `objectId`, with 15 at byte 4 (the channel's position in the sorted list of 16, as for +EXtractoR: H). Without a chosen channel After Effects leaves the layer unchanged (1300 and 1310 +defaults). The other bytes are not read. API: `ExtractorChannels`, +`identifier_channel_type`. **Curves and Mesh Warp** (961-965, 985; applied from presets, the data is not scriptable): their arbitrary-data parameter (type 11) always has a record (`tdbs` with a 4-byte `cdat`) and a `LIST diff --git a/docs/aep-import.md b/docs/aep-import.md index f14141156..cb61cd2ab 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -179,7 +179,7 @@ parameter gets the file's value: | `ADBE Easy Levels2` 1 Channel, 2 Histogram data, 8 / 9 Clip To Output Black / White | `channel`; `inBlack`, `inWhite`, `gamma`, `outBlack`, `outWhite` from the data; `clipToOutputBlack`, `clipToOutputWhite` | popups by name (After Effects' items On, Off, Off for 32 bpc Color are in another order here); the RGB values are the `f32` of the arbitrary data; the per-channel values are reported when they are not at their defaults (not sampled) | 511-516 | | Batch 6 (903 defaults, 904 / 905 set values): `ADBE Brightness & Contrast 2`, `ADBE Turbulent Displace`, `ADBE Fractal Noise`, `ADBE Laser` (Beam), `CC Radial Fast Blur`, `ADBE Noise2`, `ADBE Pro Levels2` (Levels (Individual Controls)), `ADBE Corner Pin`, `ADBE Grid`, `ADBE Sharpen`, `ADBE Linear Color Key2`, `ADBE Radial Wipe`, `ADBE Remove Color Matting`, `CC Bend It`, `CC Power Pin`, `ADBE 4ColorGradient`, `ADBE Change Color`, `ADBE Extract`, `ADBE Noise HLS2`, `ADBE Scatter`, `CC Light Sweep`, `ADBE Compound Blur`, `ADBE Displacement Map`, `ADBE Set Matte3`, `ADBE Gaussian Blur` (legacy), `ADBE OFMotionBlur` (Pixel Motion Blur), `ADBE Layer Control` | the EffectCraft effect of the same name, every parameter EffectCraft has | sliders as stored (each checked against its valid maximum); Beam Length, Time and Softness and CC Power Pin Perspective are 0..1 fractions, percentages here; points as layer pixels; popups by item name (Set Matte's `Red Channel` ... is `Red` ...); twirl-down group markers and the Levels histogram carry nothing; **layer parameters** (below) | 903, 904, 905; 220, 320, 321 | | Importer block 2026-11-05 (`1100-quickwins.aep`: per effect defaults, set values, minimum and maximum, every popup item, a keyed and an expression parameter): `ADBE HDR ToneMap` (HDR Highlight Compression) 1 Amount; `ADBE Echo` 1 Echo Time, 2 Number Of Echoes, 3 Starting Intensity, 4 Decay, 5 Echo Operator; `ADBE Linear Wipe` 1 Transition Completion, 2 Wipe Angle, 3 Feather; `ADBE Offset` 1 Shift Center To, 2 Blend With Original; `ADBE Deflicker` (Color Stabilizer) 1 Stabilize, 2-4 Black / Mid / White Point, 5 Sample Size | `ec.utility.hdrcompression` `amount`; `ec.time.echo` `echoTime`, `numberOfEchoes`, `startingIntensity`, `decay`, `echoOperator`; `ec.transition.linearwipe` `completion`, `angle`, `feather`; `ec.distort.offset` `shift`, `blend`; `ec.color.colorstabilizer` `stabilize`, `blackPoint`, `midPoint`, `whitePoint`, `sampleSize` | percent (float, valid 0..100, default 100); seconds (float, valid ±30 000), integer (valid 30 000), 0..1 (float), Decay as stored (float, valid 0..100), popup by name: EffectCraft's Echo uses at most 255 echoes and a Decay up to 1, a value or key beyond that is imported as stored and **reported**; percent (fixed, 100), degrees, pixels (fixed, 32 000); point in layer pixels, Blend 0..1 x 100 = percent; popup by name (**fresh default Levels**), points in layer pixels, Sample Size as stored (fixed, valid 50, default 3; its unit, pixels here, is not verified). Color Stabilizer's reference frame (Set Frame) is not a parameter in the file and is not imported (EffectCraft's Reference Frame stays 0 s) | `tests/it/import/effects_quickwins.rs`; render comparison below | -| Importer block 2026-11-05 (`1300-thirdparty.aep`), plug-ins that ship with After Effects: `CS Color Neutralizer` (CC Color Neutralizer) 19 Pinning, 20 Blend w. Original; `EXtractoR` 1 Channel Info data, 3 Black Point, 4 White Point, 5 UnMult; `IDentifier` 1 Channel Info data, 2 Display, 3 ID | `ec.color.cccolorneutralizer` `pinning`, `blendWOriginal`; `ec.3d.extractor` `red`, `green`, `blue`, `alpha`, `blackPoint`, `whitePoint`, `unMult`; `ec.3d.identifier` `channelType`, `display`, `id` | CC Color Neutralizer: percent (float, valid 100); its correction is the Red / Green / Blue slider of each range (the Unbalance colours are pickers that set them and keep no record), with View and Black / White Point: **EffectCraft's effect has no counterpart, each is reported when stored** (EffectCraft's Unbalance / Balance stay at their identity defaults). EXtractoR: the default channels (R, G, B, `(copy)`) are EffectCraft's R, G, B, A, other channels are reported (`ExtractorChannels`; only the default is sampled), Black and White Point as stored (float, valid ±3.4e38; a value beyond ±1e9 is reported as damaged data, as for every number), UnMult a checkbox, Clip stays off. IDentifier: Display by name, ID as stored (integer, valid 2 147 483 647); the Channel Info names only the channel type (Object ID, `identifier_channel_type`; another is reported), not the channel After Effects reads, so **every instance is reported**: EffectCraft draws the footage's object ID channel where After Effects left the sampled EXR unchanged | `tests/it/import/effects_thirdparty.rs`; render comparison below | +| Importer block 2026-11-05 (`1300-thirdparty.aep`), plug-ins that ship with After Effects: `CS Color Neutralizer` (CC Color Neutralizer) 19 Pinning, 20 Blend w. Original; `EXtractoR` 1 Channel Info data, 3 Black Point, 4 White Point, 5 UnMult; `IDentifier` 1 Channel Info data, 2 Display, 3 ID | `ec.color.cccolorneutralizer` `pinning`, `blendWOriginal`; `ec.3d.extractor` `red`, `green`, `blue`, `alpha`, `blackPoint`, `whitePoint`, `unMult`; `ec.3d.identifier` `channelType`, `display`, `id` | CC Color Neutralizer: percent (float, valid 100); its correction is the Red / Green / Blue slider of each range (the Unbalance colours are pickers that set them and keep no record), with View and Black / White Point: **EffectCraft's effect has no counterpart, each is reported when stored** (EffectCraft's Unbalance / Balance stay at their identity defaults). EXtractoR: each output channel by the footage channel's name, which EffectCraft's EXtractoR reads by the same name (the default R, G, B; `Z` from our preset), and `(copy)` as the input's own channel of that position (EffectCraft's R, G, B or A; the default Alpha is A); a record of another kind or without a name is reported and R, G, B, A used (`ExtractorChannels`), Black and White Point as stored (float, valid ±3.4e38; a value beyond ±1e9 is reported as damaged data, as for every number), UnMult a checkbox, Clip stays off. IDentifier: Display by name, ID as stored (integer, valid 2 147 483 647); the Channel Info names the chosen channel (`identifier_channel_type`): After Effects lists only the footage's integer (UINT) channels, and with none chosen (the data then says `Object ID`) it leaves the layer unchanged, where EffectCraft draws the footage's object ID channel: **reported**; a chosen channel named `ObjectID` or `MaterialID` (without regard to case, `objectId` in our preset) is the one EffectCraft's IDentifier reads itself (Channel Type Object ID or Material ID), any other name is reported (EffectCraft's IDentifier has no channel choice) | `tests/it/import/effects_thirdparty.rs`; render comparison below | | `ADBE Slider Control`, `ADBE Angle Control`, `ADBE Checkbox Control`, `ADBE Color Control`, `ADBE Point Control` (parameter 1) | `ec.control.slider` `slider`, `ec.control.angle` `angle`, `ec.control.checkbox` `checkbox`, `ec.control.color` `color`, `ec.control.point` `point` | float (valid max 1 000 000) as stored; degrees; checkbox; colour; point in layer pixels | 071, 073, 217-219, 221, 304, 310 | - **Nothing to import:** the implicit input-layer parameter (index 0), the Swap Colors buttons @@ -336,8 +336,16 @@ frame blending, 3D material options, audio levels, guides, the mask colour, lock within one level, the ±3.4e38 compositions differ (reported as damaged data). IDentifier: After Effects' frame is the unchanged EXR in every variant (its default Object ID finds no channel in that EXR), while EffectCraft's IDentifier finds the EXR's `ID` channel and draws it; the import - reports this for every IDentifier. No sample changes EXtractoR's channels or IDentifier's channel - type (the presets for that were missing from the block). + reports this for every IDentifier. The follow-up presets (our own): `extractor-07-preset-channel-z` + (`Z` into Red, Green and Blue) imports without a loss and renders equal to After Effects (within + 3e-5 at After Effects' 1/10 PNG scale; the background's Z of 100 clips there in both). IDentifier + needs an integer channel: block 1310 (`1310-identifier-uint.aep`, the EXR with UINT channels + `objectId` and `IndexOB.X` added) with the preset `identifier-09-preset-channel-objectid-id2` + (`objectId`, ID 2) imports without a loss, and EffectCraft's IDentifier finds the same channel: + the ID regions are the same pixels (8 449, 2 400, 1 515, 1 256, 780), the colours differ (After + Effects draws flat colours per ID and does not highlight the chosen ID, EffectCraft draws its + own ID colours with the chosen ID white). At its defaults (no channel chosen) After Effects + leaves the EXR unchanged in 1310 too. - `crates/aep/tests/it/import/combined.rs`: masks and effects on the same layer, an effect on a text layer, and the same through `.aepx` (built in-test from the fixtures: the effect parade of one fixture grafted onto the layer of another). From 6a67b4dac1f989d3ae4be3ef01656404b7144f11 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 17:48:29 +0100 Subject: [PATCH 12/94] AEP import: Lumetri Color's master RGB curve, Hue vs Sat and colour wheels After Effects 2026 presets made by hand (1200 lumetri-58 ... -62) decode the data the scripted samples could not set. - RGB curve (-0039): the first of the four curves is the master curve and arrives as curves/rgbCurves/master. A moved red, green or blue curve is reported: no sample tells which slot is which channel. - Hue vs Sat (-0042): y is the offset from neutral, 1 doubling the saturation (in linear light), so EffectCraft's y is 0.5 + y / 2. The other four hue / saturation curves stay reported when moved. - Color Wheels (-0047): four triples of hue (degrees), brightness slider (0.5 neutral) and distance. Shadows, Midtones and Highlights become EffectCraft's wheel colours (HSL of that hue, saturation = distance, lightness 0.5); the brightness sliders and the fourth wheel are reported. - Damaged curve or wheel data is reported as damaged. - Legacy Tone And Saturation is reported when a record says on: its pard default turns from off to on when the project is saved again (the 25.x / 24.x / 26 copies of 1200), so comparing with it missed it. - The Input LUT and Look presets confirm -0095 / -0096 hold the names; the LUT files are After Effects' own and are not embedded. Evidence: the 62 read-backs still equal the imported values in the new 63-composition 1200 file. The version copies store the same Lumetri definitions, ranges and values and give the same report. Renders: master curve within 0.024 (After Effects uses a natural spline, EffectCraft's Lumetri a monotone one), Hue vs Sat mean 0.008, wheels mean 0.042. Part of #3111 Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import/lumetri.rs | 190 +++++++++++++++++++++++--- crates/aep/tests/it/import/lumetri.rs | 129 +++++++++++++++-- docs/aep-format.md | 52 ++++--- docs/aep-import.md | 26 +++- 4 files changed, 348 insertions(+), 49 deletions(-) diff --git a/crates/aep/src/import/lumetri.rs b/crates/aep/src/import/lumetri.rs index 22f28b1aa..9369155f4 100644 --- a/crates/aep/src/import/lumetri.rs +++ b/crates/aep/src/import/lumetri.rs @@ -16,11 +16,18 @@ //! After Effects; EffectCraft's Lumetri has none. A section that is off does nothing in After //! Effects (1200 `lumetri-03-sec-basic-correction`, `-17-sec-vignette` render as `-00-defaults`), //! so its values are not imported (the effect looks the same) and are listed in the report. -//! - **Arbitrary data** (`ParamKind::ArbitraryData`, every instance stores it): the hue / -//! saturation curves (a curve type, a point count, points as `f64` x, y with y an offset from -//! neutral) are imported when neutral and reported otherwise (no sample moves a point); the -//! HSL Secondary key ([`HslKey`]) is imported; the RGB curves, the colour wheels, Split Toning -//! and the correction wheels are reported when they are not the default (not sampled). +//! - **Arbitrary data** (`ParamKind::ArbitraryData`, every instance stores it). Curves are a +//! curve type, a point count and points as `f64` x, y ([`curve_points`]). +//! - The RGB curve (`-0039`) holds four curves; the first is the master (white) curve (1200 +//! `lumetri-60-preset-curves-rgb`), imported. The other three are reported when moved: no +//! sample tells which is which channel. +//! - Hue vs Sat (`-0042`): y is an offset from neutral, 1 doubling the saturation (`-61`), so +//! EffectCraft's y is `0.5 + y / 2`. The other four hue / saturation curves have no sample +//! of their scale and are reported when moved. +//! - Color Wheels (`-0047`): Shadows, Midtones, Highlights and a fourth wheel, each hue, +//! brightness slider, distance ([`import_wheels`], `-62`). +//! - The HSL Secondary key ([`HslKey`]) is imported; Split Toning and the correction wheels are +//! reported when they are not the default (not sampled). //! - **LUTs and looks.** The Input LUT and the Look are files After Effects names (the Look's name //! is UTF-16 text in `-0096`, the popups only index a list of the installed ones); they are not //! in the project, so a set one is reported by name and EffectCraft's popup stays None. @@ -82,8 +89,9 @@ impl Section { enum Data { /// The four RGB curves (4 x 520 bytes). RgbCurves, - /// One hue / saturation curve (520 bytes); the EffectCraft parameter it belongs to. - HueSatCurve, + /// One hue / saturation curve (520 bytes); the EffectCraft parameter when the scale of its y + /// is known (Hue vs Sat). + HueSatCurve(Option<&'static str>), /// Color Wheels (4 x 3 `f64`, each wheel 0, 0.5, 0 by default). Wheels, /// HSL Secondary's correction wheels (8 x 3 `f64`, the same default). @@ -105,6 +113,11 @@ enum Role { Switch, /// No counterpart: reported when not at the default. Missing, + /// A checkbox without counterpart whose `pard` default is not reliable: reported when a + /// record says on. Legacy Tone And Saturation (`-0097`) is off in a fresh instance, but its + /// `pard` default reads off in 1200 as saved by the script and on after the project was + /// opened and saved again (`versions/1200-lumetri-26`, `-25x`, `-24x`). + MissingWhenOn, /// An eyedropper colour: reported when set. Eyedropper, /// The Input LUT (`lut` true) or the Look: a popup, a name or an asset. @@ -187,19 +200,19 @@ const ROWS: &[Row] = &[ row(39, S::RgbCurves, Some(K::ArbitraryData), Role::Data(Data::RgbCurves)), row(104, S::HueSat(0), Some(K::Checkbox), Role::Switch), row(113, S::HueSat(0), Some(K::Color), Role::Eyedropper), - row(42, S::HueSat(0), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(42, S::HueSat(0), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(Some("curves/hueSaturationCurves/hueVsSat")))), row(105, S::HueSat(1), Some(K::Checkbox), Role::Switch), row(114, S::HueSat(1), Some(K::Color), Role::Eyedropper), - row(106, S::HueSat(1), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(106, S::HueSat(1), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))), row(107, S::HueSat(2), Some(K::Checkbox), Role::Switch), row(115, S::HueSat(2), Some(K::Color), Role::Eyedropper), - row(108, S::HueSat(2), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(108, S::HueSat(2), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))), row(109, S::HueSat(3), Some(K::Checkbox), Role::Switch), row(116, S::HueSat(3), Some(K::Color), Role::Eyedropper), - row(110, S::HueSat(3), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(110, S::HueSat(3), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))), row(111, S::HueSat(4), Some(K::Checkbox), Role::Switch), row(117, S::HueSat(4), Some(K::Color), Role::Eyedropper), - row(112, S::HueSat(4), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(112, S::HueSat(4), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))), // Color Wheels: 46 the switch, 102 Face Detection (on by default), 66 HDR White, 47 the wheels. row(46, S::ColorWheels, Some(K::Checkbox), Role::Switch), row(102, S::ColorWheels, Some(K::Checkbox), Role::Missing), @@ -235,7 +248,7 @@ const ROWS: &[Row] = &[ row(60, S::SpeedGradeCustom, Some(K::ArbitraryData), Role::Data(Data::Other)), // Outside the sections: Legacy Tone And Saturation, the embedded LUTs, Auto Tone, Auto Color, // the LUT and Look assets. - row(97, S::Always, Some(K::Checkbox), Role::Missing), + row(97, S::Always, Some(K::Checkbox), Role::MissingWhenOn), row(98, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)), row(99, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)), row(100, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)), @@ -355,7 +368,11 @@ fn utf16_text(d: &[u8]) -> Option { /// A Lumetri curve (520 bytes): `i32` curve type at 0 (3 for the hue curves, which wrap; 2 for /// Luma vs Sat and Sat vs Sat; 0 for an RGB curve), the `u32` point count at 4, then the points -/// as little-endian `f64` pairs x, y from byte 8 (at most 32). `None` when the count does not fit. +/// as little-endian `f64` pairs x, y from byte 8 (at most 32), sorted by x. Bytes after the +/// counted points are stale (a fresh hue curve has count 0 and old points behind it). An RGB +/// curve's x and y are 0..1 (2 points 0,0 and 1,1 by default); a hue / saturation curve's x is +/// the hue (0 red, 1/3 green) or the luma / saturation, 0..1, and y its offset from neutral. +/// `None` when the count does not fit. fn curve_points(d: &[u8]) -> Option> { let n = usize::try_from(le_u32(d, 4)?).ok()?; if n > d.len().saturating_sub(8) / 16 { @@ -418,7 +435,7 @@ fn data_default(data: Data, d: Option<&[u8]>) -> bool { Data::RgbCurves => { d.len() == 4 * 520 && d.as_chunks::<520>().0.iter().all(|c| curve_points(c).is_some_and(|pts| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9))) } - Data::HueSatCurve => curve_points(d).is_some_and(|pts| pts.iter().all(|(_, y)| *y == 0.0)), + Data::HueSatCurve(_) => curve_points(d).is_some_and(|pts| pts.iter().all(|(_, y)| *y == 0.0)), Data::Wheels => wheels_default(d, 4), Data::CorrectionWheels => wheels_default(d, 8), Data::SplitToning | Data::Other => is_empty_data(Some(d)), @@ -552,6 +569,11 @@ pub(crate) fn import_lumetri(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, ' col.lose(LossCategory::Effects, format!("{here}: parameter {label} is off and has no counterpart")); } } + Role::MissingWhenOn => { + if p.is_animated() || matches!(p.recorded_value(), Some(ParamValue::Bool(true))) { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} is on and has no counterpart, not imported")); + } + } Role::Missing => { if is_set(p) { col.lose( @@ -575,8 +597,20 @@ pub(crate) fn import_lumetri(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, ' } if !data_default(d, data()) { let why = match d { - Data::HueSatCurve => "the curve's points are moved; no sample tells their scale", - Data::RgbCurves | Data::Wheels | Data::CorrectionWheels | Data::SplitToning => "the data is not decoded (not sampled)", + Data::RgbCurves => { + import_rgb_curves(data(), (&here, &label), &mut *group, &mut *col); + continue; + } + Data::HueSatCurve(Some(target)) => { + import_hue_sat_curve(data(), target, (&here, &label), &mut *group, &mut *col); + continue; + } + Data::Wheels => { + import_wheels(data(), (&here, &label), &mut *group, &mut *col); + continue; + } + Data::HueSatCurve(None) => "the curve's points are moved; no sample tells their scale", + Data::CorrectionWheels | Data::SplitToning => "the data is not decoded (not sampled)", Data::HslKey | Data::Other => "no counterpart", }; col.lose(LossCategory::Effects, format!("{here}: parameter {label} is not imported ({why})")); @@ -640,3 +674,125 @@ fn import_hsl_key(d: Option<&[u8]>, here: &str, group: &mut effectcraft_project: set(&format!("{id}Softness"), Value::Scalar((t * 100.0).clamp(0.0, 100.0))); } } + +/// A parameter of `group` set to `v` (ids the spec does not have are skipped). +fn set_prop(group: &mut effectcraft_project::PropGroup, id: &str, v: Value) { + if let Some(prop) = group.prop_mut(id) { + prop.value = v; + } +} + +/// Curve points as EffectCraft's `x,y x,y ...` text, x and y clamped to 0..1; `None` when a value +/// is not finite. +fn points_text(pts: impl IntoIterator) -> Option { + let mut out: Vec = Vec::new(); + for (x, y) in pts { + if !x.is_finite() || !y.is_finite() { + return None; + } + out.push(format!("{},{}", x.clamp(0.0, 1.0), y.clamp(0.0, 1.0))); + } + Some(out.join(" ")) +} + +/// The RGB curve (`-0039`, four curves of 520 bytes). The first is the master curve (1200 +/// `lumetri-60-preset-curves-rgb`: its midpoint raised to 0.787 lifts R, G and B alike) and +/// becomes `curves/rgbCurves/master`. The other three are the channel curves, but no sample moves +/// one, so which is red, green or blue is not known: a moved one is reported. +fn import_rgb_curves(d: Option<&[u8]>, (here, label): (&str, &str), group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) { + let Some(d) = d else { return }; + let curves: Option>> = if d.len() == 4 * 520 { d.as_chunks::<520>().0.iter().map(|c| curve_points(c)).collect() } else { None }; + let Some(curves) = curves else { + col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the curves are not imported")); + return; + }; + let identity = |pts: &[(f64, f64)]| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9); + if let Some(master) = curves.first().filter(|c| !identity(c)) { + match points_text(master.iter().copied()) { + Some(text) => set_prop(group, "curves/rgbCurves/master", Value::Str(text)), + None => col.lose(LossCategory::DamagedData, format!("{here}: the master curve of {label} has unreadable points, not imported")), + } + } + let moved = curves.iter().skip(1).filter(|c| !identity(c)).count(); + if moved > 0 { + col.lose( + LossCategory::Effects, + format!("{here}: {moved} channel curve(s) of {label} are not imported (no sample tells which curve is red, green or blue)"), + ); + } +} + +/// Hue vs Sat (`-0042`): After Effects' y is an offset from neutral where 1 doubles the +/// saturation (1200 `lumetri-61-preset-curves-huesat`: a point at hue 0.0155, y 0.97 scales pure +/// red's saturation by about 1.96 in linear light; pins at -0.03 and -0.02 scale green, blue and +/// cyan by 0.97). EffectCraft's y = 1 doubles it and 0.5 is neutral, so y becomes `0.5 + y / 2`. +/// x is the hue in both (0 red, 1/3 green). +fn import_hue_sat_curve( + d: Option<&[u8]>, + target: &str, + (here, label): (&str, &str), + group: &mut effectcraft_project::PropGroup, + col: &mut crate::import::report::Collector, +) { + let Some(d) = d else { return }; + match curve_points(d).and_then(|pts| points_text(pts.into_iter().map(|(x, y)| (x, 0.5 + y / 2.0)))) { + Some(text) => set_prop(group, target, Value::Str(text)), + None => col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the curve is not imported")), + } +} + +/// The colour EffectCraft's wheel takes for After Effects' hue (degrees, 0 red, 120 green) and +/// distance from the centre (0..1): the HSL colour of that hue with saturation = distance and +/// lightness 0.5 (grey 0.5 at distance 0). EffectCraft's wheel adds that colour's offset from its +/// grey, so the direction is After Effects' and the strength grows with the distance. +fn wheel_colour(hue: f64, distance: f64) -> [f64; 4] { + let c = distance.clamp(0.0, 1.0); + let h = hue.rem_euclid(360.0) / 60.0; + let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs()); + let (r, g, b) = match h { + h if h < 1.0 => (c, x, 0.0), + h if h < 2.0 => (x, c, 0.0), + h if h < 3.0 => (0.0, c, x), + h if h < 4.0 => (0.0, x, c), + h if h < 5.0 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = 0.5 - c / 2.0; + [r + m, g + m, b + m, 1.0] +} + +/// Color Wheels (`-0047`, 96 bytes): four wheels of three little-endian `f64`, the hue in degrees, +/// the brightness slider (0..1, 0.5 neutral) and the distance from the centre (0..1); `0, 0.5, 0` +/// each by default. The first three are Shadows, Midtones and Highlights (1200 +/// `lumetri-62-preset-wheels`: Shadows 229 degrees (blue), slider 0.37, distance 0.55; Midtones +/// 0.1 degrees (red), 0.5, 0.39; Highlights 93 degrees (yellow-green), 0.73, 0.32); the fourth is +/// not known. Hue and distance become EffectCraft's wheel colour ([`wheel_colour`]); the brightness +/// sliders and a set fourth wheel have no counterpart and are reported. +fn import_wheels(d: Option<&[u8]>, (here, label): (&str, &str), group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) { + let Some(d) = d else { return }; + let wheels: Option> = if d.len() == 4 * 24 { + (0..4).map(|w| Some([le_f64(d, w * 24)?, le_f64(d, w * 24 + 8)?, le_f64(d, w * 24 + 16)?]).filter(|v| v.iter().all(|x| x.is_finite()))).collect() + } else { + None + }; + let Some(wheels) = wheels else { + col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the colour wheels are not imported")); + return; + }; + let mut lost: Vec = Vec::new(); + for (w, (id, name)) in wheels.iter().zip([("shadowsWheel", "Shadows"), ("midtonesWheel", "Midtones"), ("highlightsWheel", "Highlights")]) { + let [hue, brightness, distance] = *w; + if distance != 0.0 { + set_prop(group, &format!("colorWheels/{id}"), Value::Color(wheel_colour(hue, distance))); + } + if (brightness - 0.5).abs() > 1e-9 { + lost.push(format!("{name} brightness {brightness:.3} (0.5 is neutral)")); + } + } + if wheels.get(3).is_some_and(|w| *w != [0.0, 0.5, 0.0]) { + lost.push("the fourth wheel (its role is not known)".to_string()); + } + if !lost.is_empty() { + col.lose(LossCategory::Effects, format!("{here}: {label}: {} not imported (no counterpart)", lost.join(", "))); + } +} diff --git a/crates/aep/tests/it/import/lumetri.rs b/crates/aep/tests/it/import/lumetri.rs index 526f43648..a3ec9138b 100644 --- a/crates/aep/tests/it/import/lumetri.rs +++ b/crates/aep/tests/it/import/lumetri.rs @@ -172,6 +172,7 @@ fn defs() -> Vec<(u16, Def)> { (39, Def::Data { name: "RGB Curve" }), (104, Def::Check { default: true }), (42, Def::Data { name: "Hue vs Sat" }), + (106, Def::Data { name: "Hue vs Hue" }), (47, Def::Data { name: "Wheels" }), (68, Def::Check { default: true }), (73, Def::Data { name: "HSL Sliders" }), @@ -195,6 +196,7 @@ fn default_data() -> Vec<(u16, Rec)> { (32, Rec::Data(vec![0; 32])), (39, Rec::Data(rgb_curves())), (42, Rec::Data(curve(3, &[]))), + (106, Rec::Data(curve(3, &[]))), (47, Rec::Data(wheels(4))), (73, Rec::Data(hex(HSL_FRESH))), (85, Rec::Data(wheels(8))), @@ -372,20 +374,112 @@ fn a_look_is_reported_by_name() { } #[test] -fn hue_saturation_curves_with_neutral_points_arrive_moved_ones_are_reported() { +fn hue_vs_sat_arrives_with_its_y_scaled_other_moved_curves_are_reported() { // 1200 `-11-sec-hue-saturation-curves`: the selector put three neutral points on the curve. let neutral = curve(3, &[(0.742_483, 0.0), (0.798_038, 0.0), (0.853_594, 0.0)]); let (g, r) = run(&[(42, Rec::Data(neutral))]); assert_eq!(losses(&r), Vec::::new()); assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new())); - let (_, r) = run(&[(42, Rec::Data(curve(3, &[(0.5, 0.25)])))]); + // 1200 `-61-preset-curves-huesat`: red raised to 0.97 (After Effects nearly doubles its + // saturation), the neighbours pinned near 0. EffectCraft's y: 0.5 + y / 2. + let preset = curve(3, &[(0.015_463_917_525_773_196, 0.97), (0.262_886_597_938_144_34, -0.03), (0.764_604_810_996_563_5, -0.02)]); + let (g, r) = run(&[(42, Rec::Data(preset))]); + assert_eq!(losses(&r), Vec::::new()); + let Value::Str(text) = val(&g, "curves/hueSaturationCurves/hueVsSat") else { panic!("a point list") }; + let pts: Vec<(f64, f64)> = text + .split(' ') + .map(|p| { + let (x, y) = p.split_once(',').unwrap(); + (x.parse().unwrap(), y.parse().unwrap()) + }) + .collect(); + let want = [(0.015_464, 0.985), (0.262_887, 0.485), (0.764_605, 0.49)]; + assert_eq!(pts.len(), 3, "{text}"); + for ((x, y), (wx, wy)) in pts.iter().zip(want) { + assert!((x - wx).abs() < 1e-5 && (y - wy).abs() < 1e-9, "{text}"); + } + // Hue vs Hue moved: no sample tells its scale, reported and left neutral. + let (g, r) = run(&[(106, Rec::Data(curve(3, &[(0.5, 0.25)])))]); let l = losses(&r); - assert!(l.len() == 1 && l[0].contains("Hue vs Sat (ADBE Lumetri-0042) is not imported"), "{l:?}"); - // A point count past the data: not a default, reported, no panic. + assert!(l.len() == 1 && l[0].contains("Hue vs Hue (ADBE Lumetri-0106) is not imported"), "{l:?}"); + assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsHue"), Value::Str(String::new())); + // A point count past the data: not a default, reported as damaged, no panic. let mut bad = curve(3, &[]); bad[4..8].copy_from_slice(&1000u32.to_le_bytes()); - let (_, r) = run(&[(42, Rec::Data(bad))]); - assert_eq!(losses(&r).len(), 1); + let (g, r) = run(&[(42, Rec::Data(bad))]); + let l = losses(&r); + assert!(l.len() == 1 && l[0].contains("Hue vs Sat (ADBE Lumetri-0042) is unreadable"), "{l:?}"); + assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new())); +} + +#[test] +fn the_master_rgb_curve_arrives_moved_channel_curves_are_reported() { + // 1200 `-60-preset-curves-rgb`: the first of the four curves, its midpoint raised. + let master = curve(0, &[(0.0, 0.0), (0.491_408_934_707_903_76, 0.786_885_245_901_639_3), (1.0, 1.0)]); + let identity = curve(0, &[(0.0, 0.0), (1.0, 1.0)]); + let mut d = master.clone(); + (0..3).for_each(|_| d.extend(&identity)); + let (g, r) = run(&[(39, Rec::Data(d))]); + assert_eq!(losses(&r), Vec::::new()); + assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 0.49140893470790376,0.7868852459016393 1,1".into())); + assert_eq!(val(&g, "curves/rgbCurves/red"), Value::Str("0,0 1,1".into())); + // A moved channel curve: which channel it is is not known, reported. + let mut d = identity.clone(); + d.extend(&master); + (0..2).for_each(|_| d.extend(&identity)); + let (g, r) = run(&[(39, Rec::Data(d))]); + let l = losses(&r); + assert!(l.len() == 1 && l[0].contains("1 channel curve(s) of RGB Curve (ADBE Lumetri-0039) are not imported"), "{l:?}"); + assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 1,1".into())); + // Damaged: the wrong length. + let (_, r) = run(&[(39, Rec::Data(master))]); + assert!(losses(&r).iter().any(|x| x.contains("RGB Curve (ADBE Lumetri-0039) is unreadable")), "{:?}", losses(&r)); +} + +#[test] +fn colour_wheels_arrive_as_colours_their_brightness_is_reported() { + // 1200 `-62-preset-wheels`: Shadows towards blue with the slider down, Midtones towards red, + // Highlights towards yellow-green with the slider up; the fourth wheel at its default. + let w: Vec = [ + 229.119_895_976_548_18, + 0.369_892_467_494_294_2, + 0.547_209_348_004_638, + 0.104_745_366_878_020_72, + 0.5, + 0.394_961_840_132_529_44, + 93.374_076_744_668_57, + 0.726_881_718_523_399_9, + 0.318_976_840_675_999_35, + 0.0, + 0.5, + 0.0, + ] + .into_iter() + .flat_map(f64::to_le_bytes) + .collect(); + let (g, r) = run(&[(47, Rec::Data(w.clone()))]); + let colour = |id: &str| match val(&g, id) { + Value::Color(c) => c, + other => panic!("{id}: {other:?}"), + }; + // The hue's direction: blue strongest in Shadows, red in Midtones, green in Highlights. + let s = colour("colorWheels/shadowsWheel"); + assert!(s[2] > 0.77 && s[0] < 0.23 && (s[0] + s[2] - 1.0).abs() < 1e-9, "{s:?}"); + let m = colour("colorWheels/midtonesWheel"); + assert!((m[0] - 0.697_480_920_066_264_7).abs() < 1e-9 && m[1] > m[2], "{m:?}"); + let h = colour("colorWheels/highlightsWheel"); + assert!(h[1] > h[0] && h[0] > h[2], "{h:?}"); + let l = losses(&r); + assert!(l.len() == 1 && l[0].contains("Shadows brightness 0.370") && l[0].contains("Highlights brightness 0.727"), "{l:?}"); + assert!(!l[0].contains("Midtones") && !l[0].contains("fourth"), "{l:?}"); + // The fourth wheel set: reported; damaged data: reported, the wheels stay grey. + let mut w4 = w; + w4[88..96].copy_from_slice(&0.3f64.to_le_bytes()); + let (_, r) = run(&[(47, Rec::Data(w4))]); + assert!(losses(&r).iter().any(|x| x.contains("the fourth wheel")), "{:?}", losses(&r)); + let (g, r) = run(&[(47, Rec::Data(vec![1; 95]))]); + assert!(losses(&r).iter().any(|x| x.contains("Wheels (ADBE Lumetri-0047) is unreadable")), "{:?}", losses(&r)); + assert_eq!(val(&g, "colorWheels/shadowsWheel"), Value::Color([0.5, 0.5, 0.5, 1.0])); } #[test] @@ -420,17 +514,30 @@ fn the_picked_hsl_key_becomes_hue_saturation_and_lightness_ranges() { fn undecoded_data_and_controls_without_counterpart_are_reported_when_set() { let mut split = vec![0u8; 32]; split[0] = 1; - let mut w = wheels(4); - w[8] = 1; - let (_, r) = run(&[(32, Rec::Data(split)), (47, Rec::Data(w)), (97, Rec::Num(1.0, 3)), (95, Rec::Data(utf16("my.cube")))]); + let (_, r) = run(&[(32, Rec::Data(split)), (97, Rec::Num(1.0, 3)), (95, Rec::Data(utf16("my.cube")))]); let l = losses(&r); - assert_eq!(l.len(), 4, "{l:?}"); + assert_eq!(l.len(), 3, "{l:?}"); assert!(l.iter().any(|d| d.contains("Split Toning (ADBE Lumetri-0032) is not imported")), "{l:?}"); - assert!(l.iter().any(|d| d.contains("Wheels (ADBE Lumetri-0047) is not imported")), "{l:?}"); assert!(l.iter().any(|d| d.contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on")), "{l:?}"); assert!(l.iter().any(|d| d.contains("the Input LUT 'my.cube' is not imported")), "{l:?}"); } +#[test] +fn legacy_tone_on_is_reported_whatever_the_pard_default_says() { + // After the project is opened and saved again, the `pard` default of Legacy Tone And + // Saturation reads on (versions/1200-lumetri-26 / -25x / -24x; off in 1200 as the script + // saved it): a record that says on is still reported, a missing record is not. + let mut d = defs(); + if let Some((_, Def::Check { default })) = d.iter_mut().find(|(s, _)| *s == 97) { + *default = true; + } + let (_, r) = run_with(&d, &[(97, Rec::Num(1.0, 3))]); + let l = losses(&r); + assert!(l.len() == 1 && l[0].contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on"), "{l:?}"); + let (_, r) = run_with(&d, &[]); + assert_eq!(losses(&r), Vec::::new()); +} + #[test] fn an_unknown_scale_or_parameter_is_reported_not_guessed() { let mut d = defs(); diff --git a/docs/aep-format.md b/docs/aep-format.md index 9a37b384f..639c0a013 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -534,9 +534,10 @@ suffix is a parameter id, not the `parT` position (Curves -0002 Channel before - -0005 Quality before -0004). API: `CurvesData`, `MeshData`, `curve_lut`. [V; details: corpus FORMAT S] -**Lumetri Color** (`ADBE Lumetri`, 1200: 58 compositions made by script in After Effects 2026, 32 bpc, -working space None; defaults, all set, one per section, valid minima and maxima, every popup item the -script can set, checkboxes toggled, keys, an expression): 131 `parT` entries. The `-NNNN` suffix is a +**Lumetri Color** (`ADBE Lumetri`, 1200: 63 compositions in After Effects 2026, 32 bpc, working space +None; by script: defaults, all set, one per section, valid minima and maxima, every popup item the script +can set, checkboxes toggled, keys, an expression; from presets made by hand, `-58` ... `-62`: an Input +LUT, a Look, the RGB curve, Hue vs Sat, the colour wheels): 131 `parT` entries. The `-NNNN` suffix is a parameter id, not the position (`-0127` ... `-0130` sit between `-0005` and `-0026`), so the importer keys by suffix. Float sliders (type 10) store the value After Effects shows; the valid ranges (`pard` 104 / 108) are wider than the slider's (112 / 116): Temperature / Tint -300..300 (slider -100..100), @@ -546,31 +547,50 @@ HDR Range 100..10000, Refine Blur 0..1000 (0..30), Vignette Amount -5..5 (-3..3) `-19-min` / `-20-max` equal the stored numbers. **Hidden controls are recorded in the default state** (`tdsb` 3) and still hold the value After Effects reads back: High Dynamic Range `-0062` (1.0), HDR Specular `-0064` (22), the second Look popup `-0128` (26.16 as set, read back 26), Legacy Tone And -Saturation `-0097`, Auto Color `-0124`, Face Detection `-0102`, the Auto Intensity `-0123` +Saturation `-0097` (its `pard` default reads off as the script saved the project and on once the +project was opened and saved again, `versions/1200-lumetri-26`, `-25x`, `-24x`; a fresh instance reads +back off), Auto Color `-0124`, Face Detection `-0102`, the Auto Intensity `-0123` (`EffectParam::recorded_value`). Each section has an Active checkbox (blank `pard` name; `-0003` Basic Correction, `-0023` Creative, `-0037` Curves, `-0118` RGB Curves, `-0104` / `-0105` / `-0107` / `-0109` / `-0111` the hue / saturation curves, `-0046` Color Wheels, `-0068` HSL Secondary, `-0050` Vignette, on by default); off, the section does nothing (`-03-sec-basic-correction`, `-17-sec-vignette` render as `-00-defaults`). Arbitrary data (type 11, a record and `aRbs` in every instance; empty is `fe fe`): `-0001` an XML copy of the grade written only when the Look changes (not kept current: `-01-set-all` -holds defaults for controls set after the Look); `-0096` the Look's name, UTF-16LE (`SL BLUE ICE`; the -popup `-0025` itself has no record, `-0128` can disagree with it: `-07-sec-creative` names item 20 -while `-0128` holds 23), `-0095` likewise for the Input LUT (H, by analogy: no sample sets a LUT); -`-0129` the Look colour space's 16-byte id (the GUID `LookColorSpace` of `-0001`, byte-swapped; zero -for None; the Color Space popup `-0130` writes no record of its own, `-22-pop38-1` ... `-53-pop38-33`); +holds defaults for controls set after the Look; it names the LUT file, `Fuji F125 Kodak 2393 (by +Adobe).cube`); `-0096` the Look's name, UTF-16LE (`SL BLUE ICE`, `Fuji F125 Kodak 2393 (by Adobe)` in +`-59-preset-look-lut`; the popup `-0025` itself has no record, `-0128` can disagree with it: +`-07-sec-creative` names item 20 while `-0128` holds 23), `-0095` likewise for the Input LUT +(`ALEXA_Default_LogC2Rec709`, `-58-preset-input-lut`; the popups `-0005` / `-0127` keep no record, +`-0004`, `-0024` and the assets `-0125` / `-0126` stay `fe fe`: the LUTs are After Effects' own files, +named, not embedded); `-0129` the Look colour space's 16-byte id (the GUID `LookColorSpace` of `-0001` +in Windows layout: the first 4, 2 and 2 bytes little-endian; zero for None; the Color Space popup +`-0130` writes no record of its own, `-22-pop38-1` ... `-53-pop38-33`); `-0032` Split Toning (32 zero bytes); `-0039` the four RGB curves and `-0042`, `-0106`, `-0108`, `-0110`, `-0112` the hue / saturation curves, 520 bytes each: `i32` type (3 for the hue curves, 2 for Luma vs Sat and Sat vs Sat, 0 for an RGB curve), `u32` point count, then little-endian `f64` pairs x, y -(RGB: (0, 0), (1, 1) by default; hue / saturation: no points, and the selector colours `-0113` ... -`-0117` add three points at y = 0, the hue picked +- 0.0556; no sample moves a point, so y's scale is -H); `-0047` Color Wheels (4 x `f64` triples, 0, 0.5, 0 each) and `-0085` the HSL correction wheels (8 -triples, the same); `-0073` the HSL key, 40 bytes: a flag byte (1 once a colour was picked), then hue, +sorted by x (bytes after the counted points are stale). RGB: x and y 0..1, (0, 0), (1, 1) by default; +the first of the four is the master curve (`-60-preset-curves-rgb`: (0.491, 0.787) lifts R, G and B +alike, and the render follows a natural cubic spline through the points within 0.003); the order of +the other three is H (the `-0001` XML names `ToneCurvesLuma`, `Red`, `Green`, `Blue`; no sample moves +one). Hue / saturation: x the hue (0 red, 1/3 green) or the luma / saturation 0..1, y the offset from +neutral 0; a fresh curve has no points, the selector colours `-0113` ... `-0117` add three at y = 0, +the hue picked +- 0.0556. Hue vs Sat's y scales the saturation by 1 + y in linear light +(`-61-preset-curves-huesat`: (0.0155, 0.97) takes pure red's linear saturation to 1.96 times, (0.263, +-0.03) and (0.765, -0.02) green, cyan and blue to 0.97); the other four curves' scale is H. `-0047` +Color Wheels, 4 `f64` triples: hue in degrees (0 red, 120 green), brightness slider 0..1 (0.5 +neutral), distance from the centre 0..1; 0, 0.5, 0 each by default; Shadows, Midtones, Highlights, then +a fourth wheel of unknown role (`-62-preset-wheels`: Shadows 229.1, 0.370, 0.547 towards blue with the +slider down; Midtones 0.1, 0.5, 0.395 towards red; Highlights 93.4, 0.727, 0.319 towards yellow-green +with the slider up; the fourth at its default); `-0085` the HSL correction wheels (8 triples, the same +default; layout H); `-0073` the HSL key, 40 bytes: a flag byte (1 once a colour was picked), then hue, saturation, lightness, each centre, range, tolerance (`f32` LE, fractions) and an enabled byte (fresh: 0.5, 0, 0, on; picking RGB 0.02 0.55 0.85 gives hue centre 0.5605 = that colour's hue and lightness centre 0.435 = its HSL lightness, range 0.05, tolerance 0.1: `-01-set-all`; whether range is the half -or full width is H); `-0122` the Auto Tone analysis (UTF-16 JSON). Curves, LUTs and wheels are not -scriptable: the presets for them are still to be made. [V for the sliders, checkboxes, popups and the -`tdsb` 3 values; H where marked] +or full width is H); `-0122` the Auto Tone analysis (UTF-16 JSON). The project saved as 25.x and 24.x +copies (`versions/1200-lumetri-25x`, `-24x`: the 58 scripted compositions, saved by After Effects +2026) stores every Lumetri definition, valid range and value as the 26 save does. [V for the sliders, +checkboxes, popups, the `tdsb` 3 values, the master RGB curve, Hue vs Sat, the colour wheels and the +LUT names; H where marked] ### 3.8 Masks and bezier paths (`tdgp[ADBE Mask Parade]`) diff --git a/docs/aep-import.md b/docs/aep-import.md index 07c089782..f09f0ba1a 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-05 · **Change:** 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: 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-05 · **Change:** 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: 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` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -179,7 +179,7 @@ parameter gets the file's value: | `ADBE Easy Levels2` 1 Channel, 2 Histogram data, 8 / 9 Clip To Output Black / White | `channel`; `inBlack`, `inWhite`, `gamma`, `outBlack`, `outWhite` from the data; `clipToOutputBlack`, `clipToOutputWhite` | popups by name (After Effects' items On, Off, Off for 32 bpc Color are in another order here); the RGB values are the `f32` of the arbitrary data; the per-channel values are reported when they are not at their defaults (not sampled) | 511-516 | | Batch 6 (903 defaults, 904 / 905 set values): `ADBE Brightness & Contrast 2`, `ADBE Turbulent Displace`, `ADBE Fractal Noise`, `ADBE Laser` (Beam), `CC Radial Fast Blur`, `ADBE Noise2`, `ADBE Pro Levels2` (Levels (Individual Controls)), `ADBE Corner Pin`, `ADBE Grid`, `ADBE Sharpen`, `ADBE Linear Color Key2`, `ADBE Radial Wipe`, `ADBE Remove Color Matting`, `CC Bend It`, `CC Power Pin`, `ADBE 4ColorGradient`, `ADBE Change Color`, `ADBE Extract`, `ADBE Noise HLS2`, `ADBE Scatter`, `CC Light Sweep`, `ADBE Compound Blur`, `ADBE Displacement Map`, `ADBE Set Matte3`, `ADBE Gaussian Blur` (legacy), `ADBE OFMotionBlur` (Pixel Motion Blur), `ADBE Layer Control` | the EffectCraft effect of the same name, every parameter EffectCraft has | sliders as stored (each checked against its valid maximum); Beam Length, Time and Softness and CC Power Pin Perspective are 0..1 fractions, percentages here; points as layer pixels; popups by item name (Set Matte's `Red Channel` ... is `Red` ...); twirl-down group markers and the Levels histogram carry nothing; **layer parameters** (below) | 903, 904, 905; 220, 320, 321 | | `ADBE Slider Control`, `ADBE Angle Control`, `ADBE Checkbox Control`, `ADBE Color Control`, `ADBE Point Control` (parameter 1) | `ec.control.slider` `slider`, `ec.control.angle` `angle`, `ec.control.checkbox` `checkbox`, `ec.control.color` `color`, `ec.control.point` `point` | float (valid max 1 000 000) as stored; degrees; checkbox; colour; point in layer pixels | 071, 073, 217-219, 221, 304, 310 | -| `ADBE Lumetri` by match-name suffix (1200), not by index: -0062 High Dynamic Range; -0063 HDR White; -0007 / -0008 Temperature / Tint; -0020 Saturation; -0011 Exposure; -0012 ... -0016 Contrast, Highlights, Shadows, Whites, Blacks; -0064 HDR Specular; -0026 Look Intensity; -0028 Faded Film; -0029 Sharpen; -0030 Vibrance; -0031 Saturation; -0033 Tint Balance; -0074 Show Mask; -0075 mask style; -0076 Invert Mask; -0081 Denoise; -0082 Blur; -0086 ... -0090 the HSL correction's Temperature, Tint, Contrast, Sharpen, Saturation; -0051 ... -0054 Vignette Amount, Midpoint, Roundness, Feather; -0073 the HSL key data | `highDynamicRange`, `basicCorrection/tone/hdrWhite`, `basicCorrection/whiteBalance/temperature` / `tint`, `basicCorrection/saturation`, `basicCorrection/tone/exposure`, `.../contrast`, `highlights`, `shadows`, `whites`, `blacks`, `hdrSpecular`, `creative/lookIntensity`, `creative/adjustments/fadedFilm`, `sharpen`, `vibrance`, `creativeSaturation`, `tintBalance`, `hslSecondary/key/showMask`, `maskStyle`, `invertMask`, `hslSecondary/refine/denoise`, `blur`, `hslSecondary/correction/*`, `vignette/*`; `hslSecondary/key/hue*`, `saturation*`, `lightness*` | sliders: the stored number x 1 (valid maxima 1000, 300, 300, 7, 150, 200, 150, 100, 100, 300, 150, 100, 1000, 5, 100 as in `pard`); checkboxes and the popup as elsewhere, **also from records in the default state** (`tdsb` 3); the key: hue centre x 360 degrees, range x 360 as the half width, tolerance x 360 as the softness, saturation and lightness centre +- range x 100 percent, tolerance x 100, once a colour was picked | 1200: all 62 read-backs of mapped controls in sections that act equal after import (min, max, sections, popups); the other 61 lie in sections that do nothing and are reported; keys and the expression on HDR White (`-56-keys`, `-57-expr`) | +| `ADBE Lumetri` by match-name suffix (1200), not by index: -0062 High Dynamic Range; -0063 HDR White; -0007 / -0008 Temperature / Tint; -0020 Saturation; -0011 Exposure; -0012 ... -0016 Contrast, Highlights, Shadows, Whites, Blacks; -0064 HDR Specular; -0026 Look Intensity; -0028 Faded Film; -0029 Sharpen; -0030 Vibrance; -0031 Saturation; -0033 Tint Balance; -0074 Show Mask; -0075 mask style; -0076 Invert Mask; -0081 Denoise; -0082 Blur; -0086 ... -0090 the HSL correction's Temperature, Tint, Contrast, Sharpen, Saturation; -0051 ... -0054 Vignette Amount, Midpoint, Roundness, Feather; -0073 the HSL key data; -0039 the first of the four RGB curves; -0042 Hue vs Sat; -0047 the Shadows, Midtones and Highlights wheels | `highDynamicRange`, `basicCorrection/tone/hdrWhite`, `basicCorrection/whiteBalance/temperature` / `tint`, `basicCorrection/saturation`, `basicCorrection/tone/exposure`, `.../contrast`, `highlights`, `shadows`, `whites`, `blacks`, `hdrSpecular`, `creative/lookIntensity`, `creative/adjustments/fadedFilm`, `sharpen`, `vibrance`, `creativeSaturation`, `tintBalance`, `hslSecondary/key/showMask`, `maskStyle`, `invertMask`, `hslSecondary/refine/denoise`, `blur`, `hslSecondary/correction/*`, `vignette/*`; `hslSecondary/key/hue*`, `saturation*`, `lightness*`; `curves/rgbCurves/master`; `curves/hueSaturationCurves/hueVsSat`; `colorWheels/shadowsWheel`, `midtonesWheel`, `highlightsWheel` | sliders: the stored number x 1 (valid maxima 1000, 300, 300, 7, 150, 200, 150, 100, 100, 300, 150, 100, 1000, 5, 100 as in `pard`); checkboxes and the popup as elsewhere, **also from records in the default state** (`tdsb` 3); the key: hue centre x 360 degrees, range x 360 as the half width, tolerance x 360 as the softness, saturation and lightness centre +- range x 100 percent, tolerance x 100, once a colour was picked; the master curve's points as `x,y` (0..1); Hue vs Sat's points with y as `0.5 + y / 2` (After Effects' y 1 doubles the saturation, EffectCraft's y 1 too); a wheel's hue (degrees) and distance as the HSL colour of that hue with saturation = distance and lightness 0.5 | 1200: all 62 read-backs of mapped controls in sections that act equal after import (min, max, sections, popups); the other 61 lie in sections that do nothing and are reported; keys and the expression on HDR White (`-56-keys`, `-57-expr`); the presets `-60-preset-curves-rgb`, `-61-preset-curves-huesat`, `-62-preset-wheels` | - **Nothing to import:** the implicit input-layer parameter (index 0), the Swap Colors buttons (Tint 4, Ramp 8: they always have a record and hold no value), Exposure's twirl-down group @@ -242,8 +242,15 @@ parameter gets the file's value: The Input LUT and the Look are reported by name (LUT files are not in the project). Controls without a counterpart (the Auto tone's Intensity, RGB Curves' HDR Range, Color Wheels' HDR White and Face Detection, Legacy Tone And Saturation, Auto Color, SpeedGrade Custom Layer), the eyedropper - colours, and the RGB curves, colour wheels, Split Toning, HSL correction wheels and moved hue / - saturation curve points (not sampled) are reported when set. Render check at 0.5 s against After + colours, the red, green and blue curves (only the master curve is sampled, so which is which is + not known), the colour wheels' brightness sliders and their fourth wheel, Split Toning, the HSL + correction wheels and moved points of the other four hue / saturation curves (not sampled) are + reported when set. The master RGB curve arrives as its points; Hue vs Sat with y as `0.5 + y / 2` + (After Effects' y is the offset from neutral, 1 doubling the saturation; EffectCraft's y = 1 + doubles it); a colour wheel's hue and distance as the HSL colour of that hue with saturation = + distance, lightness 0.5 (EffectCraft's wheels have no brightness slider). Legacy Tone And + Saturation is reported when a record says on: its `pard` default turns from off to on when the + project is saved again. Render check at 0.5 s against After Effects' 32 bpc PNGs (stored as value x 6553.5 in 16 bits, so read as raw / 6553.5): every composition whose grade After Effects draws as identity (defaults, set-all with every section off, the switched-off sections, the popups, keys, expression) is within 1/255 in EffectCraft; the @@ -253,6 +260,15 @@ parameter gets the file's value: EffectCraft clips at 1 and keeps black at 0; mean 0.12), Creative adjustments (sec-adjustments: Faded Film lifts black to 0.07 in After Effects, 0.035 here) and the extremes (min / max: mean 0.063 / 0.454; max: After Effects' vignette at +5 and the tone sliders give mid grey, EffectCraft white). + The 32 bpc PNGs hold the pixel value with no transfer curve (the defaults' ramp reads x / 320 + exactly), so values above 1 survive up to about 10; the comparison clips After Effects at 1 as + EffectCraft's 8-bit frame does. The hand-made presets: the master RGB curve within 0.024 (mean + 0.009; After Effects' curve is a natural cubic spline through the points, within 0.003, while + EffectCraft's Lumetri interpolates monotone); Hue vs Sat mean 0.008, max 0.18 (After Effects + scales the saturation in linear light, red reaching 1.26; EffectCraft in display values, 8.3 % of + pixels > 2 levels); the colour wheels mean 0.042, max 0.13 (the hues go the same way: Shadows + lift blue at black by 0.13 in After Effects, 0.21 here; Highlights add green; the brightness + sliders are not imported); the Input LUT and the Look are not imported (mean 0.088 / 0.076). - **Colour.** After Effects stores the colour its picker shows as ARGB doubles 0..255 in the project's working-space encoding; EffectCraft's parameter colours are straight RGBA 0..1 that the render pipeline hands to the effect as stored (`crates/render/src/color.rs`: authored colours are @@ -412,5 +428,5 @@ crate yet. ## Not done yet -Lumetri Color's section switches (a switched-off section is reported and its values left out), its LUTs and looks (files After Effects names: reported by name), its RGB curves, colour wheels, Split Toning and HSL correction wheels (not sampled: reported when set) and moved hue / saturation curve points (the scale of y is not sampled); effects outside the table (EXtractoR: its channel choice is arbitrary data; Stroke: its path is a mask reference), environment lights, the ease of bezier Orientation keys, wiggly and expression selectors and the other text animator properties (Character Value, Character Alignment other than its default, the Hue / Saturation / Brightness / Opacity variants, 3D rotations, variable font axes), manual kerning, No Break, the stroke line join, the Japanese leading model, Source Text keyframes, layer-style gradients, Pattern Overlay and the switch state 3, Mesh Warp handles moved on their own, +Lumetri Color's section switches (a switched-off section is reported and its values left out), its LUTs and looks (files After Effects names: reported by name), its red, green and blue curves (no sample tells which is which), the colour wheels' brightness sliders and the fourth wheel, Split Toning and the HSL correction wheels (not sampled: reported when set) and moved points of the four hue / saturation curves other than Hue vs Sat (the scale of y is not sampled); effects outside the table (EXtractoR: its channel choice is arbitrary data; Stroke: its path is a mask reference), environment lights, the ease of bezier Orientation keys, wiggly and expression selectors and the other text animator properties (Character Value, Character Alignment other than its default, the Hue / Saturation / Brightness / Opacity variants, 3D rotations, variable font axes), manual kerning, No Break, the stroke line join, the Japanese leading model, Source Text keyframes, layer-style gradients, Pattern Overlay and the switch state 3, Mesh Warp handles moved on their own, separated dimensions and footage interpretation need verified layouts in the reader first. From 337299ddf6eb9af6b9644a1ef21698171b7048c0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 17:50:28 +0100 Subject: [PATCH 13/94] AEP import docs: Lumetri wheel render figure measured at the same pixel The EffectCraft value for the Shadows wheel's blue lift at black was read at input 0.05; at input 0 it is 0.17 (After Effects 0.13). Part of #3111 Co-Authored-By: Claude Opus 5.5 --- docs/aep-import.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/aep-import.md b/docs/aep-import.md index f09f0ba1a..7ad0efb83 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -267,7 +267,7 @@ parameter gets the file's value: EffectCraft's Lumetri interpolates monotone); Hue vs Sat mean 0.008, max 0.18 (After Effects scales the saturation in linear light, red reaching 1.26; EffectCraft in display values, 8.3 % of pixels > 2 levels); the colour wheels mean 0.042, max 0.13 (the hues go the same way: Shadows - lift blue at black by 0.13 in After Effects, 0.21 here; Highlights add green; the brightness + lift blue at black by 0.13 in After Effects, 0.17 here; Highlights add green; the brightness sliders are not imported); the Input LUT and the Look are not imported (mean 0.088 / 0.076). - **Colour.** After Effects stores the colour its picker shows as ARGB doubles 0..255 in the project's working-space encoding; EffectCraft's parameter colours are straight RGBA 0..1 that the From 495221aac67f1b8491455fe8bd1bd1fe3f40428f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 18:36:56 +0100 Subject: [PATCH 14/94] AEP import: doc comments on the quick-win test helpers and tests Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import/effects.rs | 2 ++ crates/aep/tests/it/import/effects_quickwins.rs | 7 +++++++ crates/aep/tests/it/import/fx_inject.rs | 15 +++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 1d20a9dd1..c078f7b76 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -133,6 +133,8 @@ struct ParamOut<'a> { col: &'a mut Collector, } +/// Imports one parameter of `fx` into its EffectCraft property: its keyframes, the value in its +/// record or the `pard` default, with a loss for what cannot be read or mapped. fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>, cx: &LayerCtx<'_>, shown: &str, out: ParamOut<'_>) { let ParamOut { group, refs, col } = out; if carries_nothing(p) { diff --git a/crates/aep/tests/it/import/effects_quickwins.rs b/crates/aep/tests/it/import/effects_quickwins.rs index 792e08d74..0d4ed61ec 100644 --- a/crates/aep/tests/it/import/effects_quickwins.rs +++ b/crates/aep/tests/it/import/effects_quickwins.rs @@ -10,6 +10,7 @@ use effectcraft_keyframe::Value; const ECHO_OPS: &[&str] = &["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"]; +/// Echo with the definitions After Effects 2026 wrote and the stored `values`. fn echo(values: Vec<(usize, f64)>) -> Fx { Fx { match_name: "ADBE Echo", @@ -26,6 +27,7 @@ fn echo(values: Vec<(usize, f64)>) -> Fx { } } +/// HDR Highlight Compression's Amount, set and default. #[test] fn hdr_highlight_compression_amount() { let fx = |values| Fx { @@ -43,6 +45,7 @@ fn hdr_highlight_compression_amount() { } } +/// Echo's values and operator, set and default. #[test] fn echo_values_and_operator() { // `echo-01-set-all` (without its Decay 20, below) and `echo-09-pop5-6`. @@ -61,6 +64,7 @@ fn echo_values_and_operator() { assert_eq!((num(&g, "numberOfEchoes"), v(&g, "echoOperator")), (1.0, Value::Enum(0))); } +/// Echo values beyond EffectCraft's ranges are imported as stored and reported. #[test] fn echo_values_beyond_effectcrafts_range_are_reported() { // `echo-03-max`: 30 000 echoes and Decay 100; EffectCraft's Echo uses at most 255 and 1. @@ -75,6 +79,7 @@ fn echo_values_beyond_effectcrafts_range_are_reported() { near(&g, "decay", 100.0); } +/// Linear Wipe's values, set and default. #[test] fn linear_wipe_values() { let fx = |values| Fx { @@ -99,6 +104,7 @@ fn linear_wipe_values() { near(&run(&fx(vec![]), "ec.transition.linearwipe").0.expect("imported"), "angle", 90.0); } +/// Offset's Blend With Original as a percentage and its centre as a point. #[test] fn offset_blend_is_a_fraction_and_the_centre_a_point() { let fx = |values| Fx { @@ -118,6 +124,7 @@ fn offset_blend_is_a_fraction_and_the_centre_a_point() { near(&run(&fx(vec![(2, 0.25)]), "ec.distort.offset").0.expect("imported"), "blend", 25.0); } +/// Color Stabilizer's points, Sample Size and Stabilize, default and set. #[test] fn color_stabilizer_points_sample_size_and_stabilize() { let fx = |values| Fx { diff --git a/crates/aep/tests/it/import/fx_inject.rs b/crates/aep/tests/it/import/fx_inject.rs index 9687e2d6c..bf9f08ea2 100644 --- a/crates/aep/tests/it/import/fx_inject.rs +++ b/crates/aep/tests/it/import/fx_inject.rs @@ -37,6 +37,7 @@ pub enum Default { Color([u8; 4]), } +/// The parameter type code `pard` byte 15 holds for `k`. fn kind_code(k: ParamKind) -> u8 { match k { ParamKind::Layer => 0, @@ -57,10 +58,12 @@ fn kind_code(k: ParamKind) -> u8 { } } +/// `v` as a big-endian 16.16 fixed-point value. fn fixed(v: f64) -> [u8; 4] { ((v * 65536.0).round() as i32).to_be_bytes() } +/// The 148-byte parameter definition (`pard`) of `d`: type, name, default and range. pub fn pard(d: &Def) -> Chunk<'static> { let mut b = vec![0u8; 148]; b[15] = kind_code(d.kind); @@ -99,6 +102,7 @@ pub fn tdmn(name: &str) -> Chunk<'static> { Chunk::data(*b"tdmn", b) } +/// A UTF-8 string chunk (`Utf8`). fn utf8(s: &str) -> Chunk<'static> { Chunk::data(*b"Utf8", s.as_bytes().to_vec()) } @@ -137,10 +141,13 @@ pub struct Fx { pub data: Vec<(usize, Vec)>, } +/// The match name of parameter `i` of `fx` (`-NNNN`). fn param_name(fx: &Fx, i: usize) -> String { format!("{}-{i:04}", fx.match_name) } +/// The effect's `sspc`: its name, the parameter definitions (`parT`) and the stored values +/// (`tdgp`), each value record copied from `template`. fn sspc(fx: &Fx, template: &Chunk<'_>) -> Chunk<'static> { let n = fx.defs.len() + 2; let mut part = vec![ @@ -194,6 +201,7 @@ fn find_glow_template(chunks: &[Chunk<'_>]) -> Option> { None } +/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with `fx`; the number replaced. fn replace_glow(chunks: &mut [Chunk<'_>], fx: &Fx, template: &Chunk<'_>) -> usize { let mut n = 0; for ch in chunks { @@ -239,10 +247,12 @@ pub fn losses(r: &ImportResult, category: LossCategory, match_name: &str) -> Vec r.report.losses.iter().filter(|l| l.category == category).flat_map(|l| l.details.clone()).filter(|d| d.contains(&tag)).collect() } +/// The value of property `id` of `g` (panics when it is missing). pub fn v(g: &PropGroup, id: &str) -> Value { g.prop(id).unwrap_or_else(|| panic!("no {id} in {}", g.match_id)).value.clone() } +/// The scalar value of property `id` of `g`. pub fn num(g: &PropGroup, id: &str) -> f64 { match v(g, id) { Value::Scalar(x) => x, @@ -250,11 +260,13 @@ pub fn num(g: &PropGroup, id: &str) -> f64 { } } +/// Asserts that property `id` of `g` is `want` (relative 1e-6). pub fn near(g: &PropGroup, id: &str, want: f64) { let got = num(g, id); assert!((got - want).abs() <= want.abs().max(1.0) * 1e-6, "{} {id}: {got} != {want}", g.match_id); } +/// Asserts that the point property `id` of `g` is `want` (within 0.01). pub fn pt(g: &PropGroup, id: &str, want: [f64; 2]) { let Value::Vec2(a) = v(g, id) else { panic!("{id}") }; assert!((a[0] - want[0]).abs() < 1e-2 && (a[1] - want[1]).abs() < 1e-2, "{} {id}: {a:?} != {want:?}", g.match_id); @@ -265,14 +277,17 @@ pub fn slider(kind: ParamKind, name: &'static str, default: f64, valid: (f64, f6 Def { kind, name, default: Default::Number(default), valid, items: &[] } } +/// A popup definition: default item (1-based) and items. pub fn popup(name: &'static str, default: u32, items: &'static [&'static str]) -> Def { Def { kind: ParamKind::Popup, name, default: Default::Index(default), valid: (0.0, 0.0), items } } +/// A definition of another kind (angle, point, colour, ...) with `default`. pub fn other(kind: ParamKind, name: &'static str, default: Default) -> Def { Def { kind, name, default, valid: (0.0, 0.0), items: &[] } } +/// The definitions written here read back as the reader decodes After Effects' `pard`. #[test] fn pard_layout_decodes_as_written() { // Echo's Decay (float, valid 0..100, default 1), Linear Wipe's Feather (fixed, valid max From c32aa7cfbc1c63d1f8b9cba1c5e34419fd498d7e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 23:58:35 +0100 Subject: [PATCH 15/94] AEP import: Echo keyframe regression for the range loss Co-Authored-By: Claude Sonnet 5.5 --- .../aep/tests/it/import/effects_quickwins.rs | 23 +++++++++++++++ crates/aep/tests/it/import/fx_inject.rs | 29 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/crates/aep/tests/it/import/effects_quickwins.rs b/crates/aep/tests/it/import/effects_quickwins.rs index 0d4ed61ec..f2b0594b6 100644 --- a/crates/aep/tests/it/import/effects_quickwins.rs +++ b/crates/aep/tests/it/import/effects_quickwins.rs @@ -24,6 +24,7 @@ fn echo(values: Vec<(usize, f64)>) -> Fx { ], values, data: vec![], + keys: vec![], } } @@ -36,6 +37,7 @@ fn hdr_highlight_compression_amount() { defs: vec![slider(K::FloatSlider, "Amount", 100.0, (0.0, 100.0))], values, data: vec![], + keys: vec![], }; // `hdr-01-set-all`: 90; `hdr-00-defaults`: no record, 100. for (values, want) in [(vec![(1, 90.0)], 90.0), (vec![], 100.0)] { @@ -79,6 +81,24 @@ fn echo_values_beyond_effectcrafts_range_are_reported() { near(&g, "decay", 100.0); } +/// A keyframed Echo parameter keeps its keys as stored and reports the range loss. +#[test] +fn echo_keys_beyond_effectcrafts_range_are_kept_and_reported() { + // Number Of Echoes keyed 10 -> 30 000: the second key is beyond EffectCraft's 255. + let mut fx = echo(vec![]); + fx.keys = vec![(2, vec![(0, 10.0), (1000, 30000.0)])]; + let (g, r) = run(&fx, "ec.time.echo"); + let found = losses(&r, LossCategory::Effects, "ADBE Echo"); + assert_eq!(found.len(), 1, "{found:?}"); + assert!(found[0].contains("Number Of Echoes") && found[0].contains("30000") && found[0].contains("0 to 255"), "{found:?}"); + assert!(losses(&r, LossCategory::DamagedData, "ADBE Echo").is_empty()); + // Both keys arrive with their values. + let g = g.expect("still imported"); + let keys = &g.prop("numberOfEchoes").expect("numberOfEchoes").keys; + let values: Vec = keys.iter().map(|k| k.value.clone()).collect(); + assert_eq!(values, [Value::Scalar(10.0), Value::Scalar(30000.0)]); +} + /// Linear Wipe's values, set and default. #[test] fn linear_wipe_values() { @@ -92,6 +112,7 @@ fn linear_wipe_values() { ], values, data: vec![], + keys: vec![], }; // `linwipe-01-set-all`. let (g, r) = run(&fx(vec![(1, 10.0), (2, 77.0), (3, 16.0)]), "ec.transition.linearwipe"); @@ -113,6 +134,7 @@ fn offset_blend_is_a_fraction_and_the_centre_a_point() { defs: vec![other(K::Point, "Shift Center To", Default::Point(0.5, 0.5)), slider(K::FixedSlider, "Blend With Original", 0.0, (0.0, 1.0))], values, data: vec![], + keys: vec![], }; // `offset-02-chk-toggled`: Blend With Original stored 1.0 is 100 %. let (g, r) = run(&fx(vec![(2, 1.0)]), "ec.distort.offset"); @@ -139,6 +161,7 @@ fn color_stabilizer_points_sample_size_and_stabilize() { ], values, data: vec![], + keys: vec![], }; // `colstab-00-defaults`: Levels, the points at 20 %, 50 % and 80 % of the width. let (g, r) = run(&fx(vec![]), "ec.color.colorstabilizer"); diff --git a/crates/aep/tests/it/import/fx_inject.rs b/crates/aep/tests/it/import/fx_inject.rs index bf9f08ea2..81e6a821a 100644 --- a/crates/aep/tests/it/import/fx_inject.rs +++ b/crates/aep/tests/it/import/fx_inject.rs @@ -130,6 +130,27 @@ fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> { Chunk::list(*b"tdbs", kids) } +/// A keyframed value record: `record`'s header with a key list of linear one-component keys. +fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> { + let Body::List { children, .. } = record(template, keys.first().map_or(0.0, |k| k.1)).body else { unreachable!("record builds a LIST") }; + let mut children = children; + let mut lhd3 = vec![0u8; 52]; + lhd3[0..4].copy_from_slice(&0x00d0_0beeu32.to_be_bytes()); + lhd3[8..12].copy_from_slice(&(keys.len() as u32).to_be_bytes()); + lhd3[16..20].copy_from_slice(&48u32.to_be_bytes()); + let mut ldat = Vec::new(); + for (time, value) in keys { + let mut rec = vec![0u8; 48]; + rec[0..4].copy_from_slice(&time.to_be_bytes()); + rec[4] = 1; + rec[5] = 1; + rec[8..16].copy_from_slice(&value.to_be_bytes()); + ldat.extend(rec); + } + children.push(Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", lhd3), Chunk::data(*b"ldat", ldat)])); + Chunk::list(*b"tdbs", children) +} + /// An effect instance: its match name, display name, parameter definitions (after the input /// layer, before the built-in entry), the 1-D values stored (`parT` index, value) and the /// arbitrary data stored (`parT` index, bytes). @@ -139,6 +160,9 @@ pub struct Fx { pub defs: Vec, pub values: Vec<(usize, f64)>, pub data: Vec<(usize, Vec)>, + /// Keyframed 1-D parameters (`parT` index, (time units, value) per key), linear keys; the + /// parameter then has no entry in `values`. + pub keys: Vec<(usize, Vec<(i32, f64)>)>, } /// The match name of parameter `i` of `fx` (`-NNNN`). @@ -172,6 +196,10 @@ fn sspc(fx: &Fx, template: &Chunk<'_>) -> Chunk<'static> { values.push(Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", d.clone())])); } } + for (i, keys) in &fx.keys { + values.push(tdmn(¶m_name(fx, *i))); + values.push(keyed_record(template, keys)); + } Chunk::list(*b"sspc", vec![nested(*b"fnam", vec![utf8(fx.name)]), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)]) } @@ -306,6 +334,7 @@ fn pard_layout_decodes_as_written() { ], values: vec![(1, 20.0)], data: vec![], + keys: vec![], }; let bytes = bytes_with(&fx); let file = parse(&bytes).unwrap(); From 0f882368846d75bd30212abfe9ae5b4e11bb3eb9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 6 Nov 2026 00:05:54 +0100 Subject: [PATCH 16/94] AEP import: negative cases for EXtractoR and IDentifier channel names and a missing Channel Info payload Co-Authored-By: Claude Sonnet 5.5 --- .../aep/tests/it/import/effects_thirdparty.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/crates/aep/tests/it/import/effects_thirdparty.rs b/crates/aep/tests/it/import/effects_thirdparty.rs index 7bf1ad3c8..7c7ec190c 100644 --- a/crates/aep/tests/it/import/effects_thirdparty.rs +++ b/crates/aep/tests/it/import/effects_thirdparty.rs @@ -44,6 +44,7 @@ fn neutralizer(values: Vec<(usize, f64)>) -> Fx { ], values, data: vec![], + keys: vec![], } } @@ -101,6 +102,7 @@ fn extractor(values: Vec<(usize, f64)>, data: Vec) -> Fx { ], values, data: vec![(1, data)], + keys: vec![], } } @@ -183,6 +185,7 @@ fn identifier(values: Vec<(usize, f64)>, data: Vec) -> Fx { ], values, data: vec![(1, data)], + keys: vec![], } } @@ -217,3 +220,71 @@ fn identifier_chosen_channels_by_name() { assert!(found.len() == 1 && found[0].contains(&format!("the ID channel '{name}' (Channel Info) is not imported")), "{name}: {found:?}"); } } + +/// EXtractoR's data with the name of record `k` from byte 35 replaced by `name` (no NUL added; the +/// rest of the record's name field is left as `fill`). +fn channels_with_raw_name(k: usize, name: &[u8], fill: u8) -> Vec { + let mut d = channels(DEFAULT_CHANNELS); + let at = 32 + k * 164 + 35; + d[at..at + 129].fill(fill); + d[at..at + name.len()].copy_from_slice(name); + d +} + +/// A channel name that never ends (no NUL up to the end of its record) or is not UTF-8 is +/// refused by the decoder and reported at the importer; the defaults R, G, B, A stay. +#[test] +fn extractor_names_without_a_terminator_or_in_invalid_utf8_are_refused() { + let unterminated = channels_with_raw_name(2, b"Z", b'Z'); + let invalid = channels_with_raw_name(1, &[0x5a, 0xff, 0xfe, 0x00], 0); + for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] { + assert_eq!(ExtractorChannels::parse(&data), None, "{what}"); + let (g, r) = run(&extractor(vec![], data), "ec.3d.extractor"); + let found = losses(&r, LossCategory::Effects, "EXtractoR"); + assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}"); + let g = g.expect("imported"); + for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { + assert_eq!(v(&g, id), Value::Str(want.into()), "{what}: {id}"); + } + } +} + +/// The same for IDentifier: a channel name without NUL up to the end of the data, or in invalid +/// UTF-8, is refused and reported; the Object ID channel stays. +#[test] +fn identifier_names_without_a_terminator_or_in_invalid_utf8_are_refused() { + let mut unterminated = id_info("objectId"); + unterminated[19..].fill(b'x'); + let mut invalid = id_info("objectId"); + invalid[19..22].copy_from_slice(&[b'o', 0xff, 0]); + for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] { + assert_eq!(identifier_channel_type(&data), None, "{what}"); + let (g, r) = run(&identifier(vec![], data), "ec.3d.identifier"); + let found = losses(&r, LossCategory::Effects, "IDentifier"); + assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}"); + assert_eq!(v(&g.expect("imported"), "channelType"), Value::Enum(0), "{what}"); + } + // Data too short to reach the name. + assert_eq!(identifier_channel_type(&[1; 10]), None); +} + +/// A Channel Info value record without its arbitrary data (no `aRbs`): the effect still imports, +/// with the defaults, and nothing panics. +#[test] +fn a_missing_channel_info_payload_leaves_the_defaults() { + let mut fx = extractor(vec![], vec![]); + fx.data.clear(); + let (g, r) = run(&fx, "ec.3d.extractor"); + assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::::new()); + let g = g.expect("imported"); + for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { + assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); + } + let mut fx = identifier(vec![(3, 5.0)], vec![]); + fx.data.clear(); + let (g, r) = run(&fx, "ec.3d.identifier"); + assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::::new()); + let g = g.expect("imported"); + assert_eq!(v(&g, "channelType"), Value::Enum(0)); + near(&g, "id", 5.0); +} From 692cc5228903ce421f79a51fa1b65789cd3d8ee7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 6 Nov 2026 00:15:17 +0100 Subject: [PATCH 17/94] AEP import: Lumetri value records without their arbitrary data are reported as damaged Co-Authored-By: Claude Sonnet 5.5 --- crates/aep/src/import/lumetri.rs | 5 +++++ crates/aep/tests/it/import/lumetri.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/crates/aep/src/import/lumetri.rs b/crates/aep/src/import/lumetri.rs index 9369155f4..194afe15f 100644 --- a/crates/aep/src/import/lumetri.rs +++ b/crates/aep/src/import/lumetri.rs @@ -590,6 +590,11 @@ pub(crate) fn import_lumetri(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, ' Role::Lut { lut } => note_lut(p, row.suffix, data(), &mut luts[usize::from(!lut)]), // The Look's colour space only applies to a Look, which is reported. Role::LookSpace => {} + Role::Data(_) if p.stored().is_some() && data().is_none() => { + // The value record is there but its aRbs/aRbp payload is not: damaged, not the + // default (a parameter without a record is the default). + col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is missing, not imported (the default used)")); + } Role::Data(Data::HslKey) => import_hsl_key(data(), &here, &mut *group, &mut *col), Role::Data(d) => { if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) { diff --git a/crates/aep/tests/it/import/lumetri.rs b/crates/aep/tests/it/import/lumetri.rs index a3ec9138b..d31960cef 100644 --- a/crates/aep/tests/it/import/lumetri.rs +++ b/crates/aep/tests/it/import/lumetri.rs @@ -84,6 +84,8 @@ enum Rec { Num(f64, u32), /// Arbitrary data. Data(Vec), + /// A value record whose arbitrary data (`aRbs`) is missing. + NoData, } /// A static value record after the one After Effects wrote for Glow's A & B Midpoint (its header, @@ -229,6 +231,7 @@ fn sspc(defs: &[(u16, Def)], recs: &[(u16, Rec)], template: &Chunk<'_>) -> Chunk values.push(record(template, 1, 0.0)); values.push(Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", d)])); } + Rec::NoData => values.push(record(template, 1, 0.0)), } } Chunk::list(*b"sspc", vec![nested(*b"fnam", b"Lumetri Color"), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)]) @@ -436,6 +439,21 @@ fn the_master_rgb_curve_arrives_moved_channel_curves_are_reported() { assert!(losses(&r).iter().any(|x| x.contains("RGB Curve (ADBE Lumetri-0039) is unreadable")), "{:?}", losses(&r)); } +/// A value record without its arbitrary data is damaged, not the default: reported for the RGB +/// curve and the HSL key, the neighbours stay as they were. +#[test] +fn a_value_record_without_its_data_is_reported_as_damaged() { + for (suffix, name) in [(39, "RGB Curve (ADBE Lumetri-0039)"), (73, "HSL Sliders (ADBE Lumetri-0073)")] { + let (g, r) = run(&[(suffix, Rec::NoData)]); + let l = losses(&r); + assert!(l.len() == 1 && l[0].contains(name) && l[0].contains("is missing"), "{suffix}: {l:?}"); + assert!(r.report.losses.iter().any(|x| x.category == LossCategory::DamagedData), "{suffix}"); + // The grade stays at the default. + assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 1,1".into())); + assert_eq!(num(&g, "hslSecondary/key/hueRange"), 180.0); + } +} + #[test] fn colour_wheels_arrive_as_colours_their_brightness_is_reported() { // 1200 `-62-preset-wheels`: Shadows towards blue with the slider down, Midtones towards red, From 61af9e5b725658ec1d8a394cfb6d2b14391ba0f7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 15:41:14 +0100 Subject: [PATCH 18/94] #3233: a text layer's name follows its text until the user renames it A text layer's name followed its Source Text whenever the name started with "Text", so a user's "Text Title" was replaced by the next text edit. The layer now records whether its name follows the text (`name_follows_text`, optional in the project file). A new text layer named after its text follows it; a name given at creation or by Rename (Enter, layer.rename, a script's layer.name, Layer Settings' name) is the user's and stays; renaming to an empty name shows the text again and follows it; committing the unchanged name changes nothing; copies keep the state. On-canvas typing and layer.setText share the rule. The name is the text's first paragraph, which ends at a line feed or a bare carriage return (After Effects' paragraph break). Projects saved without the state keep the old rule for the names that followed: a name that is empty, the text's own, or "Text"/"Text 2"... follows; any other name stays. AEP import records a stored layer name as the user's and no name as following, and names an empty text layer without a name "Text" instead of "Layer"; export writes no name for a following layer and keeps the name the user gave, even when it equals the text. Fixes #3233 --- crates/aep/src/export/plan.rs | 5 +- crates/aep/src/import/build.rs | 11 ++- crates/aep/tests/it/export/text.rs | 42 ++++++++++ crates/engine/src/cmd/layer.rs | 10 ++- crates/engine/src/cmd/layer/contents.rs | 12 +-- crates/engine/src/cmd/layer/create.rs | 4 +- crates/engine/src/cmd/layer/switches.rs | 26 ++++++- crates/engine/src/cmd/layer/transform.rs | 4 +- crates/engine/src/cmd/text_edit.rs | 15 ++-- crates/engine/src/tests/text_edit.rs | 99 ++++++++++++++++++++++++ crates/project/src/build.rs | 1 + crates/project/src/layer.rs | 26 +++++++ crates/project/src/lib.rs | 2 +- docs/agents.md | 2 + 14 files changed, 236 insertions(+), 23 deletions(-) diff --git a/crates/aep/src/export/plan.rs b/crates/aep/src/export/plan.rs index 9f45c50cd..f8aa2b3b8 100644 --- a/crates/aep/src/export/plan.rs +++ b/crates/aep/src/export/plan.rs @@ -1047,9 +1047,10 @@ fn plan_layer(cx: &PlanCx<'_>, at: CompAt<'_>, cp: &CompPlan, layer: &Layer, col let props = plan_transform(layer, &src, comp, source_size, cp, comp_name, col); let remap = remap_prop.filter(|_| remapped).map(|p| plan_remap(p, comp, cp, &layer.name, comp_name, col)); let mut name = cut_name(&layer.name, 255, &what("layer"), col); - // A text layer named after its text stores no name (110, 113: After Effects shows the text). + // A text layer named after its text stores no name (110, 113: After Effects shows the text): + // the layer's own rule decides (#3233). if let LayerSrc::Text(tp) = &src - && tp.text.split('\r').next().is_some_and(|first| first.chars().take(64).collect::() == name) + && layer.name_follows_text(&effectcraft_project::text_layer_name(&tp.text)) { name.clear(); } diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 4ec346328..fa66ee411 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -479,7 +479,12 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition .map(|d| crate::import::text::first_paragraph(&d.raw_text)) .filter(|t| !t.trim().is_empty()) }; - let name = aep_name.clone().or_else(|| src.as_ref().map(|s| s.name.clone())).or_else(text_name).unwrap_or_else(|| "Layer".to_string()); + let name = aep_name + .clone() + .or_else(|| src.as_ref().map(|s| s.name.clone())) + .or_else(text_name) + // An empty text layer without a name is named as a new one is (#3233). + .unwrap_or_else(|| if h.layer_type == LayerType::Text { "Text" } else { "Layer" }.to_string()); let skip = |cx: &mut Ctx<'_>, skipped: &mut HashMap, category: LossCategory, what: &str| { cx.col.lose(category, format!("{comp_name}: {what} '{name}'")); skipped.insert(h.id, what.to_string()); @@ -537,6 +542,10 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition }; let mut l: Layer = build::layer(project, &comp, &name, source.clone(), size, None); l.name = name.clone(); + // A text layer without a stored name follows its text; a stored name is the user's (#3233). + if is_text { + l.name_follows_text = Some(aep_name.is_none()); + } l.label = Label::ALL.get(usize::from(h.label)).copied().unwrap_or(Label::None); apply_timing(&mut l, &h, &tm, &name, &mut cx.col); apply_switches(&mut l, &h); diff --git a/crates/aep/tests/it/export/text.rs b/crates/aep/tests/it/export/text.rs index 32c7572c5..32e55a269 100644 --- a/crates/aep/tests/it/export/text.rs +++ b/crates/aep/tests/it/export/text.rs @@ -196,6 +196,48 @@ fn documents_built_in_code_are_written_as_their_fields() { assert_eq!(layer.header().map(|h| h.layer_type), Some(effectcraft_aep::LayerType::Text)); } +/// #3233: whether a text layer's name follows its text survives export and import. A followed +/// name is written as no name (After Effects shows the text) and comes back following; a name the +/// user gave is written, even when it equals the text, and comes back as the user's. Without a +/// recorded state (older projects) a name equal to the text follows. +#[test] +fn a_text_layer_name_follows_its_text_through_export_and_import() { + for (name, follows, stored) in [ + ("Hello", Some(false), Some("Hello")), + ("Hello 2", Some(true), None), + ("Hello", None, None), + ("Text 2", None, None), + ("Text Title", None, Some("Text Title")), + ("Text Title", Some(false), Some("Text Title")), + ] { + let case = format!("{name:?} {follows:?}"); + let (mut p, c) = text_project(vec![TextDoc::plain("Hello")]); + with_layer(&mut p, c, 0, |l| { + l.name = name.into(); + l.name_follows_text = follows; + }); + let r = export_all(&p); + let file = parse(&r.bytes).expect("parse"); + let view = effectcraft_aep::Project::new(&file).expect("project"); + let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer"); + assert_eq!(layer.name().as_deref(), stored, "stored name ({case})"); + let back = reimport(&r.bytes); + let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); + assert_eq!(l.name, stored.unwrap_or("Hello"), "imported name ({case})"); + assert_eq!(l.name_follows_text, Some(stored.is_none()), "imported state ({case})"); + } + // An empty text layer that follows its text is written without a name, as After Effects + // writes it, and comes back named "Text", still following. + let (mut p, c) = text_project(vec![TextDoc::plain("")]); + with_layer(&mut p, c, 0, |l| { + l.name = "Text".into(); + l.name_follows_text = Some(true); + }); + let back = reimport(&export_all(&p).bytes); + let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); + assert_eq!((l.name.as_str(), l.name_follows_text), ("Text", Some(true)), "an empty text layer"); +} + #[test] fn what_a_text_document_cannot_hold_is_reported() { let mut d = TextDoc::plain("x"); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f88ab32c8..b015b72b4 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -114,7 +114,15 @@ pub fn specs() -> Vec { has_layers, set_switch ), - cmd!("layer.rename", "Rename", [], Some("Enter"), "{layer?, name}", has_layers, rename), + cmd!( + "layer.rename", + "Rename", + [], + Some("Enter"), + "{layer?, name} — a text layer's name follows its text until renamed; an empty name follows it again", + has_layers, + rename + ), cmd!("layer.setComment", "Layer Comment", [], None, "{layers?, comment}", has_layers, set_comment), cmd!("layer.setBlendMode", "Blending Mode", [], None, "{layers?, mode?: Normal|Multiply|Screen|…, step?: ±1}", has_layers, blend), cmd!( diff --git a/crates/engine/src/cmd/layer/contents.rs b/crates/engine/src/cmd/layer/contents.rs index 5eb253beb..3015f4ecf 100644 --- a/crates/engine/src/cmd/layer/contents.rs +++ b/crates/engine/src/cmd/layer/contents.rs @@ -207,14 +207,14 @@ pub(super) fn set_text(s: &mut Session, p: &Value) -> Result { KV::Str(t) => TextDoc::plain(&t), _ => TextDoc::default(), }; + let before = effectcraft_project::text_layer_name(&doc.text); apply_text_params("layer.setText", p, &mut doc, range.clone())?; pr.set_value_at(lt, KV::Text(Box::new(doc.clone()))); - if let Some(first) = doc.text.lines().next() - && str_p(p, "text").is_some() - && !first.is_empty() - && (l.name.starts_with("Text") || l.name.is_empty()) - { - l.name = first.chars().take(40).collect(); + // Text layers are named after their text until renamed (#3233). + let after = effectcraft_project::text_layer_name(&doc.text); + if str_p(p, "text").is_some() && !after.is_empty() && l.name_follows_text(&before) { + l.name = after; + l.name_follows_text = Some(true); } Ok(()) })?; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 621ae866b..0196b5aee 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -187,8 +187,10 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { } let edit = b_p(p, "edit").unwrap_or(false); let id = s.edit("New Text Layer", None, |proj, st| { - let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect()); + 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); + // 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") { pr.value = KV::Text(Box::new(doc.clone())); } diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs index 366c1eeb1..afee522c6 100644 --- a/crates/engine/src/cmd/layer/switches.rs +++ b/crates/engine/src/cmd/layer/switches.rs @@ -139,13 +139,37 @@ fn drop_3d_values(l: &mut Layer) { pub(super) fn rename(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.rename")?; let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string(); + let t = s.time(); s.edit("Rename Layer", None, |proj, _| { - layer_mut(proj, cid, lid)?.name = name.clone(); + set_layer_name(layer_mut(proj, cid, lid)?, &name, t); Ok(()) })?; Ok(Value::Null) } +/// Give layer `l` the name `name` the user typed (comp time `t` picks the text). A text layer's +/// name follows its text until the user renames it; an empty name makes it follow the text again +/// (After Effects, #3233). Committing the unchanged name changes nothing. +pub(super) fn set_layer_name(l: &mut Layer, name: &str, t: effectcraft_time::Tick) { + let text_name = + l.props.prop("text/sourceText").filter(|_| matches!(l.source, effectcraft_project::LayerSource::Text)).map(|pr| match pr.value_at(l.layer_time(t)) { + KV::Text(d) => effectcraft_project::text_layer_name(&d.text), + KV::Str(txt) => effectcraft_project::text_layer_name(&txt), + _ => String::new(), + }); + match text_name { + Some(first) if name.trim().is_empty() => { + l.name = if first.is_empty() { "Text".to_string() } else { first }; + l.name_follows_text = Some(true); + } + Some(_) if name != l.name => { + l.name = name.to_string(); + l.name_follows_text = Some(false); + } + _ => l.name = name.to_string(), + } +} + /// The Timeline's Comment column. pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index eb0f610f9..87403fb26 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -172,10 +172,12 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result { let pixel_aspect = pixel_aspect_p(p)?; let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true); + let t = s.time(); let solid = s.edit("Layer Settings", None, |proj, _| { let src = layer_mut(proj, cid, lid)?.source.clone(); if let Some(n) = &name { - layer_mut(proj, cid, lid)?.name = n.clone(); + // A user name: a text layer's name stops following its text (#3233). + super::switches::set_layer_name(layer_mut(proj, cid, lid)?, n, t); } let LayerSource::Solid { item } = src else { return Ok(None) }; let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1; diff --git a/crates/engine/src/cmd/text_edit.rs b/crates/engine/src/cmd/text_edit.rs index cdb650486..9240f0c1a 100644 --- a/crates/engine/src/cmd/text_edit.rs +++ b/crates/engine/src/cmd/text_edit.rs @@ -6,7 +6,7 @@ //! Positions are character indices. The selection is `anchor..caret` (either order). use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV}; -use effectcraft_project::{ItemId, LayerId, LayerSource}; +use effectcraft_project::{ItemId, LayerId, LayerSource, text_layer_name}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -288,10 +288,6 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit { } } -fn first_line_name(text: &str) -> String { - text.lines().next().unwrap_or("").chars().take(40).collect() -} - /// Edit a layer's Source Text at the CTI (one undo step, or merged by `merge`), then set the /// session's selection. pub(crate) fn edit_doc( @@ -319,12 +315,13 @@ pub(crate) fn edit_doc( // already has) must not turn a static Str into Text or add a keyframe at the CTI: that // would change the project and record an empty undo step. if doc != original { - let before = first_line_name(&original.text); - let after = first_line_name(&doc.text); + let before = text_layer_name(&original.text); + let after = text_layer_name(&doc.text); pr.set_value_at(lt, KV::Text(Box::new(doc))); - // Text layers are named after their text until renamed. - if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) { + // Text layers are named after their text until renamed (#3233). + if before != after && !after.is_empty() && l.name_follows_text(&before) { l.name = after; + l.name_follows_text = Some(true); } } Ok(()) diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index eb1de6f89..bcb4f0559 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -1417,3 +1417,102 @@ fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() { assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied"); assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected"); } + +fn layer_name(s: &Session, t: u64) -> String { + s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().name.clone() +} + +/// Replace the text with `text` in the on-canvas editor (everything selected, typed, committed). +fn retype(s: &mut Session, t: u64, text: &str) { + begin_edit(s, t); + s.execute("text.insert", json!({"text": text})).unwrap(); + s.execute("text.endEdit", json!({})).unwrap(); +} + +/// #3233: a text layer's name follows its Source Text, edited on canvas or by `layer.setText`, +/// until the user renames it (After Effects). A user name stays, even one that starts with +/// "Text", and so does a copy's; renaming to an empty name follows the text again. +#[test] +fn a_text_layer_name_follows_its_text_until_renamed() { + let (mut s, t) = session("Hello"); + assert_eq!(layer_name(&s, t), "Hello"); + retype(&mut s, t, "World"); + assert_eq!(layer_name(&s, t), "World", "a new text layer follows its text"); + s.execute("layer.setText", json!({"layer": t, "text": "Again"})).unwrap(); + assert_eq!(layer_name(&s, t), "Again"); + s.execute("layer.rename", json!({"layer": t, "name": "Text Title"})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "text": "Changed"})).unwrap(); + assert_eq!(layer_name(&s, t), "Text Title", "layer.setText keeps the user's name"); + retype(&mut s, t, "Typed"); + assert_eq!(layer_name(&s, t), "Text Title", "typing keeps the user's name"); + s.execute("layer.select", json!({"layers": [t]})).unwrap(); + let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + let copy_name = layer_name(&s, copy); + s.execute("layer.setText", json!({"layer": copy, "text": "Copied"})).unwrap(); + assert_eq!(layer_name(&s, copy), copy_name, "a copy keeps the user's name too"); + // Renaming to an empty name: the name is the text's again, and follows it. + s.execute("layer.rename", json!({"layer": t, "name": ""})).unwrap(); + assert_eq!(layer_name(&s, t), "Typed", "an empty name shows the text"); + retype(&mut s, t, "Followed"); + assert_eq!(layer_name(&s, t), "Followed", "and follows it again"); + // Committing the unchanged name (Enter, Enter) does not stop the following. + s.execute("layer.rename", json!({"layer": t, "name": "Followed"})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "text": "Still"})).unwrap(); + assert_eq!(layer_name(&s, t), "Still"); + // A name given in Layer Settings is the user's too. The UI offers Layer Settings only for + // solids, nulls, cameras and lights (as After Effects does); this guards the command, which + // agents can call on any layer. + s.execute("layer.settings", json!({"layer": t, "name": "Settings Name"})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "text": "Later"})).unwrap(); + assert_eq!(layer_name(&s, t), "Settings Name", "Layer Settings names it for good"); + // A layer given a name when it is made does not follow. + let named = s.execute("layer.newText", json!({"text": "Body", "name": "Caption"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setText", json!({"layer": named, "text": "Other"})).unwrap(); + assert_eq!(layer_name(&s, named), "Caption"); +} + +/// #3233: After Effects breaks paragraphs with a bare carriage return; the name is the first +/// paragraph, without the break, and an older layer named after it counts as following. +#[test] +fn a_text_layer_name_ends_at_a_bare_carriage_return() { + let (mut s, t) = session("A\rB"); + assert_eq!(layer_name(&s, t), "A"); + s.execute("layer.setText", json!({"layer": t, "text": "C\rD"})).unwrap(); + assert_eq!(layer_name(&s, t), "C"); + retype(&mut s, t, "E\rF"); + assert_eq!(layer_name(&s, t), "E"); + let cid = s.active_comp_id().unwrap(); + let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(t)).unwrap(); + l.name = "Para1".into(); + l.name_follows_text = None; + l.props.prop_mut("text/sourceText").unwrap().value = effectcraft_keyframe::Value::Text(Box::new(TextDoc::plain("Para1\rPara2"))); + s.execute("layer.setText", json!({"layer": t, "text": "Next\rLine"})).unwrap(); + assert_eq!(layer_name(&s, t), "Next", "an older layer named after its first paragraph follows"); +} + +/// #3233: projects saved before the state was recorded load without it; there a name that is +/// the text's, empty, or the default "Text"/"Text 2"… follows the text, any other name stays. +#[test] +fn older_text_layers_without_the_name_state_follow_as_before() { + let (mut s, t) = session("Hello"); + let mut v = serde_json::to_value(s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap()).unwrap(); + v.as_object_mut().unwrap().remove("name_follows_text"); + let old: effectcraft_project::Layer = serde_json::from_value(v).unwrap(); + assert_eq!(old.name_follows_text, None, "an older project has no state"); + for (name, follows) in [("Hello", true), ("", true), ("Text", true), ("Text 3", true), ("Text Title", false), ("Text 3b", false), ("Title", false)] { + let l = effectcraft_project::Layer { name: name.into(), ..old.clone() }; + assert_eq!(l.name_follows_text("Hello"), follows, "{name:?}"); + } + let legacy = |s: &mut Session, name: &str| { + let cid = s.active_comp_id().unwrap(); + let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(t)).unwrap(); + l.name = name.into(); + l.name_follows_text = None; + }; + legacy(&mut s, "Hello"); + s.execute("layer.setText", json!({"layer": t, "text": "World"})).unwrap(); + assert_eq!(layer_name(&s, t), "World", "a name equal to the text follows it"); + legacy(&mut s, "Title"); + retype(&mut s, t, "Typed"); + assert_eq!(layer_name(&s, t), "Title", "another name stays"); +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 1aa8d7c90..7a960dbfb 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -935,6 +935,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource Layer { id, name: comp.unique_layer_name(name), + name_follows_text: None, source, label, comment: String::new(), diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index 34eeb50c0..f7b3b5e51 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -299,6 +299,8 @@ impl Layer { let Layer { id, name, + // Whether the name follows the text changes no pixels until the name changes. + name_follows_text: _, source, label, comment, @@ -474,6 +476,11 @@ pub enum AutoOrient { pub struct Layer { pub id: LayerId, pub name: String, + /// A text layer's name follows the first line of its Source Text until the user renames it + /// (After Effects). `Some(true)`: it follows, `Some(false)`: the user named it. `None` (older + /// projects, layers made before this was recorded): see [`Layer::name_follows_text()`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name_follows_text: Option, pub source: LayerSource, pub label: Label, #[serde(default)] @@ -512,7 +519,26 @@ pub struct Layer { pub props: PropGroup, } +/// The name a text layer takes from its text: the first 40 characters of the first line. A line +/// ends at a line feed or at a bare carriage return (After Effects' paragraph break). +pub fn text_layer_name(text: &str) -> String { + text.split(['\r', '\n']).next().unwrap_or("").chars().take(40).collect() +} + +/// A text layer's default name when its text gives none: "Text", numbered "Text 2", "Text 3"… +fn is_default_text_name(name: &str) -> bool { + name == "Text" || name.strip_prefix("Text ").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) +} + impl Layer { + /// Whether this text layer's name follows its Source Text, whose first line gives the name + /// `current` (see [`text_layer_name`]). Without a recorded state (an older project), a name + /// that is empty, the text's own, or the default "Text"/"Text 2"… follows; any other name was + /// given by the user. + pub fn name_follows_text(&self, current: &str) -> bool { + self.name_follows_text.unwrap_or_else(|| self.name.is_empty() || self.name == current || is_default_text_name(&self.name)) + } + /// The layers this layer reads: its parent, track matte, layer parameters (Displacement Map, /// Audio Spectrum's Audio Layer…) and tracker targets. pub fn referenced_layers(&self) -> Vec { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 079b81765..fabcd607f 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -43,7 +43,7 @@ pub use item::NO_LAYER; pub use item::{Heard, Item, ItemId, ItemKind, LayerId}; pub use layer::{ AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte, is_audio_effect, - reads_other_frames, + reads_other_frames, text_layer_name, }; pub use layers::Layers; pub use settings::{ diff --git a/docs/agents.md b/docs/agents.md index ac77439b7..27664cb73 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -713,6 +713,8 @@ in the Plug-in Library: [publishing-plugins.md](publishing-plugins.md). step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers). * Rename a layer with Enter (or double-click a name): the whole name is selected, Enter or a click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly. + A text layer's name follows its text until it is renamed; renaming it to an empty name makes + it follow the text again (After Effects). * Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`. * `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its From 2abd727338dea0a7b48131794689253ba01a8173 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 16:41:56 +0100 Subject: [PATCH 19/94] AEP import: Puppet (ADBE FreePin3) arrives as EffectCraft's Puppet, its meshes and pins are reported After Effects' Puppet was dropped with a one-line "has the effect" loss. It now arrives as EffectCraft's Puppet (ec.distort.puppet) in its place in the stack, with its name and fx switch, and the report lists per mesh Triangles, Expansion and per pin group (Deform, Overlap, Stiffness) the pins, keyed pins and atom match names; entries outside the known tree are named once, and long lists stop at ten with "and N more". No pins are created: a pin's rest place is a vertex of the hidden mesh data and the unit of its Position is unknown, and no corpus file holds a Puppet. The tree (docs/aep-format.md 3.7) comes from an external scripting listing, not the corpus; the sample that settles it is described there. The fx_inject test helpers can now inject any effect instance, which the new tests use. Refs #3093 --- crates/aep/src/import.rs | 1 + crates/aep/src/import/effects.rs | 7 +- crates/aep/src/import/puppet.rs | 234 ++++++++++++++++++++++++ crates/aep/tests/it/import.rs | 1 + crates/aep/tests/it/import/fx_inject.rs | 40 ++-- crates/aep/tests/it/import/puppet.rs | 171 +++++++++++++++++ docs/aep-format.md | 49 ++++- docs/aep-import.md | 4 +- 8 files changed, 489 insertions(+), 18 deletions(-) create mode 100644 crates/aep/src/import/puppet.rs create mode 100644 crates/aep/tests/it/import/puppet.rs diff --git a/crates/aep/src/import.rs b/crates/aep/src/import.rs index 956e3b1c5..938d0c46e 100644 --- a/crates/aep/src/import.rs +++ b/crates/aep/src/import.rs @@ -30,6 +30,7 @@ mod effects; mod markers; mod masks; mod props; +mod puppet; mod report; mod settings; mod shapes; diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 8409a6f7e..f205245eb 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -76,6 +76,11 @@ pub(crate) fn import_effects<'f, 'a>( col: &mut Collector, ) { for fx in layer.effects() { + // Puppet keeps its meshes and pins in nested groups, not in `parT` (its own step). + if fx.match_name() == crate::import::puppet::MATCH_NAME { + crate::import::puppet::import_puppet(defs, &fx, cx, ids, target, col); + 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(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); @@ -112,7 +117,7 @@ pub(crate) fn import_effects<'f, 'a>( } /// Parameters that never carry a value to import. -fn carries_nothing(p: &EffectParam<'_, '_>) -> bool { +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) } diff --git a/crates/aep/src/import/puppet.rs b/crates/aep/src/import/puppet.rs new file mode 100644 index 000000000..ce10ae7f5 --- /dev/null +++ b/crates/aep/src/import/puppet.rs @@ -0,0 +1,234 @@ +//! After Effects' Puppet effect (`ADBE FreePin3`) arrives as EffectCraft's Puppet +//! (`ec.distort.puppet`) in its place in the stack, with its name and fx switch, but **without +//! its meshes and pins**: how After Effects stores a pin's rest place (a vertex index and offset +//! into the hidden mesh data) and its Position (pixels or a fraction of the layer) is not decoded +//! yet, and a guessed rest place would deform the layer wrongly without a word. What the instance +//! holds is reported instead, mesh by mesh: Triangles, Expansion, and per pin container (Deform, +//! Stiffness, Overlap) the number of pins, how many are keyed or carry an expression, and the +//! match names of their atoms (Advanced and Bend pins show up there). Any entry this reader does +//! not know is named verbatim. +//! +//! The tree read here (docs/aep-format.md 3.7, Puppet) comes from an external scripting listing +//! of the effect's property tree, not from the corpus (no corpus file has a Puppet): **?** until a +//! byte sample exists. It is read with the same verified group reader as masks, shapes and layer +//! styles; a file whose tree differs is reported, never guessed at. + +use effectcraft_project::build::Ids; +use effectcraft_project::{Node, PropGroup}; + +use crate::import::effects::{LayerCtx, carries_nothing}; +use crate::import::report::{Collector, LossCategory}; +use crate::{Effect as AepEffect, EffectDefinitions, ParamValue, PropGroup as AepGroup, PropNode, Property as AepProperty}; + +/// After Effects' Puppet effect. +pub(crate) const MATCH_NAME: &str = "ADBE FreePin3"; +/// The hidden group that holds the meshes (`arap`). +const ARAP: &str = "ADBE FreePin3 ARAP Group"; +/// Auto-traced Shapes, beside the meshes. +const OUTLINES: &str = "ADBE FreePin3 Outlines"; +/// The hidden group of the meshes. +const MESH_GROUP: &str = "ADBE FreePin3 Mesh Group"; +/// One mesh (`Mesh 1`). +const MESH_ATOM: &str = "ADBE FreePin3 Mesh Atom"; +/// A mesh's hidden triangulation. +const MESH_DATA: &str = "ADBE FreePin3 Mesh"; +const TRIANGLES: &str = "ADBE FreePin3 Mesh Tri Count"; +const EXPANSION: &str = "ADBE FreePin3 Mesh Expansion"; +/// At most this many meshes, pin atom names or unrecognised names are listed in a report line; +/// the rest are counted (`and 3 more`). +const LIST_CAP: usize = 10; +/// The pin containers of a mesh: match name and the name After Effects shows. +const PIN_GROUPS: [(&str, &str); 3] = [("ADBE FreePin3 PosPins", "Deform"), ("ADBE FreePin3 HghtPins", "Overlap"), ("ADBE FreePin3 StarchPins", "Stiffness")]; + +/// Imports a Puppet instance (the caller checked the match name) into `target`. +pub(crate) fn import_puppet<'f, 'a>( + defs: &EffectDefinitions<'f, 'a>, + fx: &AepEffect<'f, 'a>, + cx: &LayerCtx<'_>, + ids: &mut Ids<'_>, + target: &mut PropGroup, + col: &mut Collector, +) { + let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string()); + let here = format!("{}: '{}' effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer); + let Some(spec) = effectcraft_effects::find(effectcraft_effects::puppet::ID) else { + col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer)); + return; + }; + 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)), + } + // The ordinary parameters (`parT`): none is mapped yet, so a stored one is reported. + let params = defs.params(fx); + for p in ¶ms { + if carries_nothing(p) { + continue; + } + if p.stored().is_some() && !matches!(p.stored_value(), Some(ParamValue::Layer(0))) { + let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) }; + col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported")); + } + } + report_meshes(fx, ¶ms.iter().map(|p| p.match_name.as_str()).collect::>(), &here, col); + // The effect itself arrives (an EffectCraft Puppet the user can pin again), so it counts as + // imported; what it held is in the losses above. + col.imported.effects = col.imported.effects.saturating_add(1); + target.children.push(Node::Group(group)); +} + +/// Reports the meshes and pins of `fx` and every entry of its value group that is neither a +/// `parT` parameter (`param_names`) nor the mesh tree. +fn report_meshes(fx: &AepEffect<'_, '_>, param_names: &[&str], here: &str, col: &mut Collector) { + let mut unknown: Vec = Vec::new(); + let values = fx.chunk().child_list(b"tdgp").and_then(AepGroup::from_chunk); + let mut arap = None; + for e in values.map(|g| g.entries()).unwrap_or_default() { + if e.match_name == ARAP { + if let PropNode::Group(g) = e.node { + if arap.is_none() { + arap = Some(g); + } + } else { + push_unique(&mut unknown, e.match_name); + } + } else if !param_names.contains(&e.match_name.as_str()) { + match e.node { + // A value outside `parT` (On Transparent, ...): reported with what it holds. + PropNode::Property(p) => col.lose(LossCategory::Effects, format!("{here}: property {} ({}) not imported", e.match_name, describe(&p))), + PropNode::Group(_) | PropNode::Other(_) => push_unique(&mut unknown, e.match_name), + } + } + } + let mut meshes: Vec = Vec::new(); + let mut found = false; + if let Some(arap) = arap { + for e in arap.entries() { + match (e.match_name.as_str(), e.node) { + (MESH_GROUP, PropNode::Group(g)) if !found => { + found = true; + for m in g.entries() { + match (m.match_name.as_str(), m.node) { + (MESH_ATOM, PropNode::Group(mesh)) => { + let n = meshes.len().saturating_add(1); + meshes.push(mesh_summary(&mesh, n, &mut unknown)); + } + _ => push_unique(&mut unknown, m.match_name.clone()), + } + } + } + // Auto-traced Shapes are made from the layer again (EffectCraft traces the alpha). + (OUTLINES, _) => {} + _ => push_unique(&mut unknown, e.match_name.clone()), + } + } + } + let extra = if unknown.is_empty() { String::new() } else { format!("; not recognised: {}", capped(&unknown, ", ")) }; + if !found { + col.lose( + LossCategory::Effects, + format!("{here}: imported without meshes or pins: its mesh group ({ARAP} / {MESH_GROUP}) was not found or is not a group{extra}"), + ); + } else if !meshes.is_empty() { + col.lose( + LossCategory::Effects, + format!( + "{here}: imported without its meshes and pins (After Effects' pin rest places and positions are not decoded yet): {}{extra}", + capped(&meshes, "; ") + ), + ); + } else if !unknown.is_empty() { + col.lose(LossCategory::Effects, format!("{here}: no mesh{extra}")); + } +} + +/// `Mesh n (Triangles t, Expansion e): Deform 3 pins (1 keyed) [atom names], Overlap no pins, ...` +fn mesh_summary(mesh: &AepGroup<'_, '_>, n: usize, unknown: &mut Vec) -> String { + let name = mesh.user_name().unwrap_or_else(|| format!("Mesh {n}")); + let mut settings: Vec = Vec::new(); + let mut containers: Vec = Vec::new(); + for e in mesh.entries() { + let label = match e.match_name.as_str() { + TRIANGLES => Some("Triangles"), + EXPANSION => Some("Expansion"), + _ => None, + }; + match (label, e.node) { + (Some(label), PropNode::Property(p)) => settings.push(format!("{label} {}", describe(&p))), + // The triangulation After Effects keeps; EffectCraft builds its own. + _ if e.match_name == MESH_DATA => {} + _ => match (PIN_GROUPS.iter().find(|(m, _)| *m == e.match_name), e.node) { + (Some((_, shown)), PropNode::Group(g)) => containers.push(format!("{shown} {}", pins_summary(&g, unknown))), + _ => push_unique(unknown, e.match_name), + }, + } + } + let settings = if settings.is_empty() { String::new() } else { format!(" ({})", settings.join(", ")) }; + let containers = if containers.is_empty() { "no pin groups".to_string() } else { containers.join(", ") }; + format!("{name}{settings}: {containers}") +} + +/// `3 pins (1 keyed, 1 with an expression) [ADBE FreePin3 PosPin Atom]`, or `no pins`. An entry +/// of the container that is not a group (a pin is one) goes to `unknown`. +fn pins_summary(container: &AepGroup<'_, '_>, unknown: &mut Vec) -> String { + let mut count = 0usize; + let mut keyed = 0usize; + let mut expressions = 0usize; + let mut atoms: Vec = Vec::new(); + for e in container.entries() { + let PropNode::Group(pin) = e.node else { + push_unique(unknown, e.match_name); + continue; + }; + count = count.saturating_add(1); + push_unique(&mut atoms, e.match_name); + let props: Vec> = + pin.entries().into_iter().filter_map(|p| if let PropNode::Property(p) = p.node { Some(p) } else { None }).collect(); + if props.iter().any(AepProperty::is_animated) { + keyed = keyed.saturating_add(1); + } + if props.iter().any(|p| p.expression().is_some()) { + expressions = expressions.saturating_add(1); + } + } + if count == 0 { + return "no pins".to_string(); + } + let mut what = vec![format!("{keyed} keyed")]; + if expressions > 0 { + what.push(format!("{expressions} with an expression")); + } + format!("{count} pin{} ({}) [{}]", if count == 1 { "" } else { "s" }, what.join(", "), capped(&atoms, ", ")) +} + +/// A stored property's value for the report: `keyed`, the static value, or `unreadable`. +fn describe(p: &AepProperty<'_, '_>) -> String { + if p.is_animated() { + return "keyed".to_string(); + } + match p.static_value() { + Some(v) if v.len() == 1 => v.first().map(|x| format!("{x}")).unwrap_or_default(), + Some(v) => format!("[{}]", v.iter().map(|x| format!("{x}")).collect::>().join(", ")), + None => "unreadable".to_string(), + } +} + +/// Adds `name` to `names` unless it is there already. +fn push_unique(names: &mut Vec, name: String) { + if !names.contains(&name) { + names.push(name); + } +} + +/// The first [`LIST_CAP`] of `items` joined by `sep`, and how many more there are. +fn capped(items: &[String], sep: &str) -> String { + let shown: Vec<&str> = items.iter().take(LIST_CAP).map(String::as_str).collect(); + let mut out = shown.join(sep); + let more = items.len().saturating_sub(LIST_CAP); + if more > 0 { + out.push_str(&format!("{sep}and {more} more")); + } + out +} diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 102a215c8..61a04c401 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -19,6 +19,7 @@ mod fx_inject; mod markers; mod masks; mod mattes; +mod puppet; mod resaved_defaults; mod robustness; mod shapes; diff --git a/crates/aep/tests/it/import/fx_inject.rs b/crates/aep/tests/it/import/fx_inject.rs index 81e6a821a..ae61794d3 100644 --- a/crates/aep/tests/it/import/fx_inject.rs +++ b/crates/aep/tests/it/import/fx_inject.rs @@ -103,18 +103,18 @@ pub fn tdmn(name: &str) -> Chunk<'static> { } /// A UTF-8 string chunk (`Utf8`). -fn utf8(s: &str) -> Chunk<'static> { +pub fn utf8(s: &str) -> Chunk<'static> { Chunk::data(*b"Utf8", s.as_bytes().to_vec()) } /// A chunk whose body is chunks without being a `LIST` (`pdnm`, `fnam`: `NESTED_IDS`). -fn nested(id: [u8; 4], children: Vec>) -> Chunk<'static> { +pub fn nested(id: [u8; 4], children: Vec>) -> Chunk<'static> { Chunk { id, body: Body::Chunks(children), pad: None } } /// A static value record holding `v`, after the one After Effects wrote for Glow's A & B Midpoint /// (its header; without the expression). -fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> { +pub fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> { let mut kids = Vec::new(); for c in template.children() { match &c.body { @@ -131,7 +131,7 @@ fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> { } /// A keyframed value record: `record`'s header with a key list of linear one-component keys. -fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> { +pub fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> { let Body::List { children, .. } = record(template, keys.first().map_or(0.0, |k| k.1)).body else { unreachable!("record builds a LIST") }; let mut children = children; let mut lhd3 = vec![0u8; 52]; @@ -229,39 +229,51 @@ fn find_glow_template(chunks: &[Chunk<'_>]) -> Option> { None } -/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with `fx`; the number replaced. -fn replace_glow(chunks: &mut [Chunk<'_>], fx: &Fx, template: &Chunk<'_>) -> usize { +/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with the effect `match_name` +/// and its `sspc`; the number replaced. +fn replace_glow(chunks: &mut [Chunk<'_>], match_name: &str, sspc: &Chunk<'static>) -> usize { let mut n = 0; for ch in chunks { let kids = ch.children_mut(); if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0"))) && kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc")) { - kids[at] = tdmn(fx.match_name); - kids[at + 1] = sspc(fx, template); + kids[at] = tdmn(match_name); + kids[at + 1] = sspc.clone(); n += 1; } - n += replace_glow(kids, fx, template); + n += replace_glow(kids, match_name, sspc); } n } -/// `synthetic.aep` with `fx` in place of the Glow effect. -pub fn bytes_with(fx: &Fx) -> Vec { +/// `synthetic.aep` with the effect `match_name` in place of the Glow effect; `make` builds its +/// `sspc` from the Glow's value record (the template of every record). +pub fn bytes_with_effect(match_name: &str, make: impl Fn(&Chunk<'_>) -> Chunk<'static>) -> Vec { let file = parse(SYNTHETIC).unwrap(); let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record"); + let sspc = make(&template); let mut file = parse(SYNTHETIC).unwrap(); - assert_eq!(replace_glow(&mut file.chunks, fx, &template), 2, "the Glow effect and its shared definitions"); + assert_eq!(replace_glow(&mut file.chunks, match_name, &sspc), 2, "the Glow effect and its shared definitions"); write(&file).unwrap() } +/// `synthetic.aep` with `fx` in place of the Glow effect. +pub fn bytes_with(fx: &Fx) -> Vec { + bytes_with_effect(fx.match_name, |t| sspc(fx, t)) +} + /// Imports `synthetic.aep` with `fx` in place of its Glow: the EffectCraft effect `effect` (if /// imported) and the result. pub fn run(fx: &Fx, effect: &str) -> (Option, ImportResult) { - let bytes = bytes_with(fx); + import_bytes(&bytes_with(fx), effect) +} + +/// Imports `bytes`: the first EffectCraft effect `effect` (if imported) and the result. +pub fn import_bytes(bytes: &[u8], effect: &str) -> (Option, ImportResult) { let mut project = Project::default(); let none = |_: &str| false; - let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); + let r = import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); let g = project .comps() .flat_map(|(_, c)| c.layers.iter()) diff --git a/crates/aep/tests/it/import/puppet.rs b/crates/aep/tests/it/import/puppet.rs new file mode 100644 index 000000000..a3054286e --- /dev/null +++ b/crates/aep/tests/it/import/puppet.rs @@ -0,0 +1,171 @@ +//! After Effects' Puppet (`ADBE FreePin3`, #3093) arrives as EffectCraft's Puppet +//! (`ec.distort.puppet`) in its place in the stack, and what its meshes and pins hold is reported +//! mesh by mesh instead of the one line "has the effect 'Puppet'". The effect is built into the +//! committed `synthetic.aep` in place of its Glow (`fx_inject`, in memory), in the tree +//! docs/aep-format.md 3.7 gives for Puppet. That tree comes from an external scripting listing, +//! not a byte sample: these tests prove the reader's walk and its report, not the format. + +use effectcraft_aep::Chunk; +use effectcraft_aep::import::{ImportOptions, LossCategory, import}; +use effectcraft_project::Project; + +use crate::import::fx_inject::{bytes_with_effect, import_bytes, keyed_record, losses, nested, record, tdmn, utf8}; + +const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep"); +const PUPPET: &str = "ADBE FreePin3"; +const POS_ATOM: &str = "ADBE FreePin3 PosPin Atom"; + +fn tdsb(v: u32) -> Chunk<'static> { + Chunk::data(*b"tdsb", v.to_be_bytes().to_vec()) +} + +/// A property group of `entries` (match name, node). +fn group(entries: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> { + let mut kids = vec![tdsb(1)]; + for (name, node) in entries { + kids.push(tdmn(name)); + kids.push(node); + } + Chunk::list(*b"tdgp", kids) +} + +/// A pin atom with its hidden vertex records and Position (keyed when `keys`). The values are +/// one-component records: the reader only asks whether a pin is keyed. +fn pin(t: &Chunk<'_>, atom: &'static str, keys: bool) -> (&'static str, Chunk<'static>) { + let position = if keys { keyed_record(t, &[(0, 1.0), (25_600, 2.0)]) } else { record(t, 1.0) }; + ( + atom, + group(vec![ + ("ADBE FreePin3 PosPin Vtx Offset", record(t, 0.0)), + ("ADBE FreePin3 PosPin Vtx Index", record(t, 7.0)), + ("ADBE FreePin3 PosPin Position", position), + ]), + ) +} + +/// `Mesh 1`: Triangles 500, Expansion 5, four Deform pins (one keyed, one with an atom name +/// nobody knows) and a property among them, no Overlap pin, one Stiffness pin, and a group +/// nobody knows. +fn full_mesh(t: &Chunk<'_>) -> Chunk<'static> { + let mesh_data = + Chunk::list(*b"tdbs", vec![tdsb(1), Chunk::data(*b"cdat", vec![0u8; 4]), Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", vec![1, 2, 3, 4])])]); + let deform = group(vec![ + pin(t, POS_ATOM, false), + pin(t, POS_ATOM, true), + pin(t, POS_ATOM, false), + pin(t, "ADBE FreePin3 Test Atom", false), + ("ADBE FreePin3 Test Prop", record(t, 3.0)), + ]); + let starch = group(vec![("ADBE FreePin3 StarchPin Atom", group(vec![("ADBE FreePin3 StarchPin Amount", record(t, 0.5))]))]); + group(vec![ + ("ADBE FreePin3 Mesh", mesh_data), + ("ADBE FreePin3 Mesh Tri Count", record(t, 500.0)), + ("ADBE FreePin3 Mesh Expansion", record(t, 5.0)), + ("ADBE FreePin3 PosPins", deform), + ("ADBE FreePin3 HghtPins", group(vec![])), + ("ADBE FreePin3 StarchPins", starch), + ("ADBE FreePin3 Test Group", group(vec![])), + ]) +} + +/// The Puppet instance: `fx_switch` is the value group's `tdsb`, `arap` its mesh tree (`None`: +/// none), `extra` more entries of its value group. +fn sspc(fx_switch: u32, arap: Option>, extra: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> { + let pard = |kind: u8| { + let mut b = vec![0u8; 148]; + b[15] = kind; + Chunk::data(*b"pard", b) + }; + let part = vec![Chunk::data(*b"parn", 2u32.to_be_bytes().to_vec()), tdmn("ADBE FreePin3-0000"), pard(0), tdmn("ADBE Effect Built In Params"), pard(9)]; + let mut values = vec![tdsb(fx_switch)]; + if let Some(arap) = arap { + values.push(tdmn("ADBE FreePin3 ARAP Group")); + values.push(arap); + } + for (name, node) in extra { + values.push(tdmn(name)); + values.push(node); + } + Chunk::list(*b"sspc", vec![nested(*b"fnam", vec![utf8("Puppet")]), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)]) +} + +/// The layer that holds `effect`, the effect's place in that layer's stack and the stack's size. +fn stack_place(bytes: &[u8], effect: &str) -> Option<(String, usize, usize)> { + let mut project = Project::default(); + let none = |_: &str| false; + import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); + project.comps().flat_map(|(_, c)| c.layers.iter()).find_map(|l| { + let fx: Vec<_> = l.props.sub("effects")?.children.iter().filter_map(|n| n.as_group()).collect(); + let at = fx.iter().position(|g| g.match_id == effect)?; + Some((l.name.clone(), at, fx.len())) + }) +} + +#[test] +fn puppet_arrives_and_its_pins_are_reported_mesh_by_mesh() { + let bytes = bytes_with_effect(PUPPET, |t| { + let arap = group(vec![("ADBE FreePin3 Outlines", group(vec![])), ("ADBE FreePin3 Mesh Group", group(vec![("ADBE FreePin3 Mesh Atom", full_mesh(t))]))]); + sspc(1, Some(arap), vec![("ADBE FreePin3 On Transparent", record(t, 1.0))]) + }); + let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID); + let g = g.expect("an EffectCraft Puppet in the Glow's place"); + assert!(g.enabled); + assert_eq!(g.name, "Puppet"); + // No pin is made up: the rest places are not decoded. + assert_eq!(effectcraft_effects::puppet::pin_count(&g), 0); + // In the Glow's place in its layer's stack (the second of two effects there). + assert!(stack_place(SYNTHETIC, "ec.stylize.glow").is_some_and(|(_, at, n)| at > 0 && n > 1)); + assert_eq!(stack_place(&bytes, effectcraft_effects::puppet::ID), stack_place(SYNTHETIC, "ec.stylize.glow")); + let l = losses(&r, LossCategory::Effects, PUPPET); + assert_eq!(l.len(), 2, "{l:#?}"); + assert!(!l.iter().any(|d| d.contains("has the effect")), "not the old one-line report: {l:#?}"); + assert!(l.iter().any(|d| d.ends_with("property ADBE FreePin3 On Transparent (1) not imported")), "{l:#?}"); + let mesh = l.iter().find(|d| d.contains("imported without its meshes and pins")).expect("the mesh report"); + assert!( + mesh.ends_with( + ": Mesh 1 (Triangles 500, Expansion 5): Deform 4 pins (1 keyed) [ADBE FreePin3 PosPin Atom, ADBE FreePin3 Test Atom], \ + Overlap no pins, Stiffness 1 pin (0 keyed) [ADBE FreePin3 StarchPin Atom]; not recognised: ADBE FreePin3 Test Prop, ADBE FreePin3 Test Group" + ), + "{mesh}" + ); + assert_eq!(losses(&r, LossCategory::DamagedData, PUPPET), Vec::::new()); +} + +#[test] +fn many_meshes_are_listed_up_to_ten_and_names_once() { + let bytes = bytes_with_effect(PUPPET, |_| { + let mesh = || group(vec![("ADBE FreePin3 Test Group", group(vec![]))]); + let meshes = (0..12).map(|_| ("ADBE FreePin3 Mesh Atom", mesh())).collect(); + sspc(1, Some(group(vec![("ADBE FreePin3 Mesh Group", group(meshes))])), vec![]) + }); + let (_, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID); + let l = losses(&r, LossCategory::Effects, PUPPET); + assert_eq!(l.len(), 1, "{l:#?}"); + assert!(l[0].ends_with("Mesh 9: no pin groups; Mesh 10: no pin groups; and 2 more; not recognised: ADBE FreePin3 Test Group"), "{}", l[0]); +} + +#[test] +fn a_puppet_without_a_mesh_loses_nothing_and_keeps_its_fx_switch() { + let bytes = bytes_with_effect(PUPPET, |_| sspc(0, Some(group(vec![("ADBE FreePin3 Mesh Group", group(vec![]))])), vec![])); + let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID); + let g = g.expect("an EffectCraft Puppet"); + assert!(!g.enabled, "switched off in the Effect Controls"); + assert_eq!(losses(&r, LossCategory::Effects, PUPPET), Vec::::new()); +} + +#[test] +fn a_puppet_whose_mesh_tree_is_not_found_is_reported() { + let bytes = bytes_with_effect(PUPPET, |t| sspc(1, None, vec![("ADBE FreePin3 Other Group", group(vec![("x", record(t, 1.0))]))])); + let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID); + assert!(g.is_some(), "the effect still arrives"); + let l = losses(&r, LossCategory::Effects, PUPPET); + assert_eq!(l.len(), 1, "{l:#?}"); + assert!( + l[0].ends_with( + "imported without meshes or pins: its mesh group (ADBE FreePin3 ARAP Group / ADBE FreePin3 Mesh Group) was not found or is not a group; \ + not recognised: ADBE FreePin3 Other Group" + ), + "{}", + l[0] + ); +} diff --git a/docs/aep-format.md b/docs/aep-format.md index d6ec7fb14..db0d79d1c 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-05 · **Change:** 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-07 · **Change:** Puppet (`ADBE FreePin3`, 3.7): the property tree from an external scripting listing, **?** until a byte sample exists (#3093); 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 @@ -610,6 +610,52 @@ presets, and After Effects draws exactly the picked objects as selected (`crypto `crypto-07`: box and disc, the two objects of `mat_paint`). API: `CryptomatteData` (refuses data without `cry1`, a length past the end or a non-zero byte at 112). +**Puppet (`ADBE FreePin3`; ?: external listing, not the corpus, #3093).** No file of the +corpus, the committed fixtures or the samples holds a Puppet. The tree below comes from outside +the corpus: a public scripting listing of the effect's properties (a forum post, an After Effects +release from before the Advanced and Bend pins and Density). It is not bytes we have seen; that +After Effects writes it by the general rule (a `tdmn` and the `LIST tdgp` or `tdbs` it names, +nested in the instance's value group `sspc/tdgp`, as masks, shapes and layer styles are) is +**H**: + +```text +Puppet ADBE FreePin3 + On Transparent ADBE FreePin3 On Transparent + arap ADBE FreePin3 ARAP Group (hidden) + Auto-traced Shapes ADBE FreePin3 Outlines + Mesh ADBE FreePin3 Mesh Group (hidden) + Mesh 1 ADBE FreePin3 Mesh Atom + Mesh ADBE FreePin3 Mesh (hidden: the triangulation) + Triangles ADBE FreePin3 Mesh Tri Count + Expansion ADBE FreePin3 Mesh Expansion + Deform ADBE FreePin3 PosPins + Puppet Pin 1 ADBE FreePin3 PosPin Atom + Vertex Offset ADBE FreePin3 PosPin Vtx Offset (hidden, 2 values) + Vertex Index ADBE FreePin3 PosPin Vtx Index (hidden; 4294967295 = none) + Position ADBE FreePin3 PosPin Position + Overlap ADBE FreePin3 HghtPins (hidden while empty) + Stiffness ADBE FreePin3 StarchPins (hidden while empty) +``` + +A pin's rest place is not a property: it is a vertex of the hidden mesh (Vertex Index) plus an +offset, so it cannot be read without decoding the mesh data. The unit of Position (layer pixels, +or a fraction of the layer as effect points are) is **?**. The match names of the Overlap and +Starch pin atoms and their properties, of the Advanced and Bend pins (Scale, Rotation) and of +Density, Mesh Rotation Refinement and the Puppet Engine are **?**, and so is which of them are +`parT` parameters; whether After Effects stores some `parT` parameters of a fresh Puppet even at +their defaults (the importer would then report them every time) is **?**; whether a Puppet +without pins has an (empty) Mesh Group, or no ARAP group at all, is **?**. The importer +(`import/puppet.rs`) therefore brings the effect in as EffectCraft's Puppet without meshes or +pins and reports, per mesh, Triangles, Expansion and per pin group the pins, how many are keyed +or carry an expression, and their atom match names; every entry outside this tree is named in the +report. The sample that settles it: a fresh Puppet with no pins in one composition and, in +another, a 400 x 800 solid in a larger composition (pixels against fractions) with a Puppet of +three Position pins (one keyed, one moved off its rest place without +keys), one Advanced, one Bend, one Starch and one Overlap pin, Triangles, Expansion and Density +off their defaults, a second mesh, and a scripting read-back of every property's match name and +value, the hidden ones included. Pins added by script are reported to come out disabled, so the +pins are placed by hand or by a preset. + ### 3.8 Masks and bezier paths (`tdgp[ADBE Mask Parade]`) | Field | Chunk path | Offset | Type | Meaning | Evidence | Conf. | @@ -894,6 +940,7 @@ another value). **Effects** - `pard` byte 48 (flags: **?**), 68 / 72 (the point default as a percent pair: **V** in the corpus for 074 and 368, not exposed), 120 (the float slider default again as f32: **V**, not exposed), 124 (display decimals: **H**), 132..147 (fixed slider slider-max, default copy and flags; the low bit of @144 is set on the two percent-displayed sliders and clear on Scatter: **H** "display as percent"), the colour default copy at @60, and the stale memory after the parameter name. The fixed slider valid minimum is not decoded (the float slider one is, 389). +- Puppet (`ADBE FreePin3`, 3.7): the hidden mesh data, a pin's Vertex Index and Vertex Offset (its rest place), the unit of a pin's Position, the Overlap, Starch, Advanced and Bend pins, Density, Mesh Rotation Refinement and the Puppet Engine: no sample (**?**). - Effects renamed, or stacked twice on ONE layer (`Gradient Ramp 2`: does the second keep an empty `parT`?); which instance keeps the full `parT` when a second instance is created on a layer below the first; expressions on effect parameters; keyed per-channel Exposure values and a keyed checkbox; Curves / Levels (arrays), Fractal Noise, and whether the `tdpi` of a Layer Control holds an id or an index after the referenced layer is reordered or deleted. **Masks, shapes** diff --git a/docs/aep-import.md b/docs/aep-import.md index ed3ab57f5..5d268e503 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-05 · **Change:** 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-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: 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` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -58,7 +58,7 @@ outside the sampled ones keeps the current value and is reported as `project set | 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) | | Text animators | every animator with its name and its range selectors (Start, End, Offset, or the Index range when Units is Index; Units, Based On, Mode, Amount, Shape, Smoothness, Ease High / Low, Randomize Order, Random Seed) and the properties Anchor Point, Position, Scale, Skew, Skew Axis, Rotation, Opacity, Fill Color, Stroke Color, Stroke Width, Tracking Amount, Line Spacing, Blur, Character Offset and Character Range, with keys and expressions (579-594, 114, 728). Values are stored as After Effects shows them; popups are 1-based in the file | | Masks | every mask of an imported layer, in stack order (on text and shape layers, which have no source, the stored mask box is in layer pixels: 705, 706): name, path (vertices and tangents in layer pixels, open or closed), mode (None, Add, Subtract, Intersect, Lighten, Darken, Difference), inverted, feather (x, y in pixels), opacity (percent), expansion (pixels); path keyframes (time, linear or hold sides, roving, label), feather / opacity / expansion keyframes with ease | -| Effects | **Curves** (961-964: the five curves' points from the arbitrary data as EffectCraft's `x,y` points in 0..1; render within 6 levels: EffectCraft interpolates monotone, After Effects stores a natural spline) and **Mesh Warp** (965, 985: Rows, Columns, Quality and the Distortion Mesh as per-vertex offsets in layer pixels; Bezier handles moved on their own are kept as eight extra numbers of the vertex for the export and reported, EffectCraft draws the mesh bilinear; the header is rows + 1, columns + 1; render 985: 1.4 % of pixels > 2 levels around the moved vertex), **Tint**, **Gradient Ramp**, **Exposure**, **Invert**, **Fast Box Blur**, **Gaussian Blur**, **Drop Shadow**, **Glow**, **Fill**, **Hue/Saturation**, **Levels**, **Timewarp** (Method, Adjust Time By, Speed with keys, Source Frame; 529-533), **Directional Blur**, **Radial Blur**, **CC Radial Blur**, **Venetian Blinds**, **Channel Blur**, **Black & White**, **Tritone**, **Luma Key**, **Simple Choker**, **Matte Choker**, **Transform** (all parameters; 711-723) and the expression controls **Slider**, **Angle**, **Checkbox**, **Color** and **Point Control** (table below) on any imported layer (also text and shape layers and layers with masks), in stack order, with the instance name, every parameter (static value or default) and its keyframes; also the second and later instances of a type, whose parameter definitions are only in the project's shared definition list. Other effects are reported (below) | +| Effects | **Curves** (961-964: the five curves' points from the arbitrary data as EffectCraft's `x,y` points in 0..1; render within 6 levels: EffectCraft interpolates monotone, After Effects stores a natural spline) and **Mesh Warp** (965, 985: Rows, Columns, Quality and the Distortion Mesh as per-vertex offsets in layer pixels; Bezier handles moved on their own are kept as eight extra numbers of the vertex for the export and reported, EffectCraft draws the mesh bilinear; the header is rows + 1, columns + 1; render 985: 1.4 % of pixels > 2 levels around the moved vertex), **Tint**, **Gradient Ramp**, **Exposure**, **Invert**, **Fast Box Blur**, **Gaussian Blur**, **Drop Shadow**, **Glow**, **Fill**, **Hue/Saturation**, **Levels**, **Timewarp** (Method, Adjust Time By, Speed with keys, Source Frame; 529-533), **Directional Blur**, **Radial Blur**, **CC Radial Blur**, **Venetian Blinds**, **Channel Blur**, **Black & White**, **Tritone**, **Luma Key**, **Simple Choker**, **Matte Choker**, **Transform** (all parameters; 711-723) and the expression controls **Slider**, **Angle**, **Checkbox**, **Color** and **Point Control** (table below) on any imported layer (also text and shape layers and layers with masks), in stack order, with the instance name, every parameter (static value or default) and its keyframes; also the second and later instances of a type, whose parameter definitions are only in the project's shared definition list. **Puppet** arrives in its place with its name and fx switch but without meshes and pins (a pin's rest place lies in the mesh data, not decoded yet); per mesh the report gives Triangles, Expansion and each pin group's pins, keyed pins and atom match names. Other effects are reported (below) | | Layer styles | Drop Shadow, Inner Shadow, Outer Glow, Inner Glow, Bevel and Emboss, Satin, Color Overlay, Gradient Overlay and Stroke when applied (switch group `tdsb` 1): every parameter, starting from After Effects' defaults (Use Global Light is off there), with keys on scalars and colours and expressions; Blending Options: Fill Opacity, the channel switches, Blend Interior Styles as Group, Use Blend Ranges, and Global Light Angle / Altitude, which the composition adopts (one setting per composition here). Units and popups through `effectcraft_aep::style_map` (aep-format 3.12b). Render check against After Effects (`proof\styles`): drop shadow, inner shadow, satin, colour overlay, stroke within a few levels; glow and bevel intensities and the Gradient Overlay direction differ in EffectCraft's renderer | | 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 | From 5f73e5b5e935aae74587d18f20034f93a85e811e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 17:03:59 +0100 Subject: [PATCH 20/94] =?UTF-8?q?Time=20stretch:=20hold=20a=20loaded=20or?= =?UTF-8?q?=20imported=20stretch=20to=20=C2=B11=20%=20to=20=C2=B19900=20%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project file, an .aep, a timeline document or a Lottie file could give a layer a time stretch of 1e-300 %, 0 or 1e300 %. Layer and comp time divide by it and the audio mix read about usize::MAX source samples for one block, which overflowed or tried a huge allocation and aborted the app. - limits::stretch brings a stretch within After Effects' range (±1 % to ±9900 %, Layer.stretch in the scripting guide), sign kept; 0 and NaN are 100 %. Opening a project file corrects each layer and says so; a stretch written as null (NaN in an auto-save) now opens instead of failing. - .aep import and Lottie import clamp and report the stretch; timeline import clamps the clip speed and warns. - layer.timeStretch refuses a non-number and more than 9900 %; the Time Stretch dialog's ranges match. - Layer::layer_time / comp_time and the audio mix use the clamped stretch, so a value set in memory can't divide by zero or read without bound. Fixes #3247 --- crates/aep/src/import/build.rs | 9 +- crates/aep/tests/it/import/synthetic.rs | 41 ++++++++ crates/engine/src/cmd/layer_time.rs | 7 ++ crates/engine/src/tests/limits.rs | 18 ++++ crates/interchange/src/import.rs | 24 ++++- crates/interchange/src/tests.rs | 18 ++++ crates/lottie/src/import.rs | 7 +- crates/lottie/src/tests.rs | 15 +++ crates/project/src/layer.rs | 28 +++++- crates/project/src/limits.rs | 113 +++++++++++++++++++++- crates/render/src/audio.rs | 7 +- crates/render/src/tests.rs | 1 + crates/render/src/tests/stretch_limits.rs | 96 ++++++++++++++++++ crates/ui-egui/src/panels/key_dialogs.rs | 16 ++- 14 files changed, 389 insertions(+), 11 deletions(-) create mode 100644 crates/render/src/tests/stretch_limits.rs diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 4ec346328..ded1e64ad 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -681,7 +681,14 @@ fn apply_timing(l: &mut Layer, h: &LayerHeader, tm: &TimeMap, name: &str, col: & col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch is zero or unreadable, 100 % is used")); Rational { num: 1, den: 1 } }); - l.stretch = stretch.to_f64().map_or(100.0, |r| r * 100.0); + let stored = stretch.to_f64().map_or(100.0, |r| r * 100.0); + // A ratio such as 1/2^31 (5e-8 %) or 2^31/1 is outside what After Effects allows (±1 % to + // ±9900 %); taken as stored, the layer and audio time would divide by ~0 (#3247). The in and + // out points keep the comp times the file stores. + l.stretch = effectcraft_project::limits::stretch(stored); + if l.stretch.to_bits() != stored.to_bits() { + col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch of {stored:e} % is outside ±1 % to ±9900 %, {} % is used", l.stretch)); + } // A stored time with a zero denominator (or a sum that overflows) has no value; it is read as 0 // and reported, so the layer's timing never changes silently. let unreadable: Vec<&str> = [("start", h.start), ("in point", h.in_relative), ("out point", h.out_relative)] diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 18709f9e5..e2fb20da4 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -273,6 +273,47 @@ fn layer_times_with_a_zero_denominator_are_reported() { assert!(losses(&r.unwrap(), LossCategory::DamagedData).is_empty()); } +/// The time stretch of every imported layer. +fn stretches(p: &Project) -> Vec { + p.items.values().filter_map(|i| if let ItemKind::Comp(c) = &i.kind { Some(c) } else { None }).flat_map(|c| c.layers.iter().map(|l| l.stretch)).collect() +} + +#[test] +fn a_time_stretch_outside_one_to_9900_percent_is_clamped_and_reported() { + // ldta byte 8 is the stretch numerator (i32), byte 108 the denominator (u32). 1/(2^31 - 1) is + // about 5e-8 %: imported as stored, the layer and audio time divided by ~0 (#3247). + for (num, den, want) in [(1i32, i32::MAX as u32, 1.0), (-1, i32::MAX as u32, -1.0), (i32::MAX, 1, 9900.0), (i32::MIN, 1, -9900.0)] { + let bytes = mutate(&mut |c| { + if c.id == *b"ldta" { + set_data(c, 8, &num.to_be_bytes()); + set_data(c, 108, &den.to_be_bytes()); + } + }); + let (p, r) = run(&bytes, true); + let r = r.unwrap(); + let found = losses(&r, LossCategory::DamagedData); + assert!(found.iter().any(|d| d.contains("time stretch") && d.contains("outside")), "{num}/{den}: {found:?}"); + let s = stretches(&p); + assert!(!s.is_empty() && s.iter().all(|v| *v == want), "{num}/{den}: {s:?}"); + let (p, r) = run(&bytes, false); + assert!(r.is_err(), "{num}/{den}: strict import must refuse"); + assert!(p.items.is_empty()); + } + // A small stretch within the range (1/64: 1.5625 %) and -9900 % itself are no damage. + for (num, den, want) in [(1i32, 64u32, 1.5625), (-99, 1, -9900.0)] { + let bytes = mutate(&mut |c| { + if c.id == *b"ldta" { + set_data(c, 8, &num.to_be_bytes()); + set_data(c, 108, &den.to_be_bytes()); + } + }); + let (p, r) = run(&bytes, true); + let r = r.unwrap(); + assert!(!losses(&r, LossCategory::DamagedData).iter().any(|d| d.contains("time stretch")), "{num}/{den}: {:?}", r.report.losses); + assert!(stretches(&p).iter().all(|v| *v == want), "{num}/{den}: {:?}", stretches(&p)); + } +} + /// The project with `nhed` bytes from `at` replaced. fn header(at: usize, bytes: &[u8]) -> Vec { mutate(&mut |c| { diff --git a/crates/engine/src/cmd/layer_time.rs b/crates/engine/src/cmd/layer_time.rs index 4ecf725ec..a6fe3d990 100644 --- a/crates/engine/src/cmd/layer_time.rs +++ b/crates/engine/src/cmd/layer_time.rs @@ -59,9 +59,16 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { (None, Some(d)) if span > 0.0 => old * d / span, _ => old, }; + if !new.is_finite() { + return Err(bad("layer.timeStretch", "the stretch factor must be a number")); + } if new.abs() < 1.0 { return Err(bad("layer.timeStretch", "the stretch factor must be at least 1 %")); } + // After Effects' largest stretch; a project file holds no more either (#3247). + if new.abs() > effectcraft_project::limits::MAX_STRETCH { + return Err(bad("layer.timeStretch", "the stretch factor must be at most 9900 %")); + } // The hold point keeps showing the same layer time. let h = match hold { "current" => cti, diff --git a/crates/engine/src/tests/limits.rs b/crates/engine/src/tests/limits.rs index 524719bbf..9c156c4f7 100644 --- a/crates/engine/src/tests/limits.rs +++ b/crates/engine/src/tests/limits.rs @@ -103,6 +103,24 @@ fn commands_cap_sizes_at_30000_px() { assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97)); } +/// Time Stretch takes After Effects' range, ±1 % to ±9900 %: before, 1e300 % was set, and a +/// project file holding it opened at 9900 % (#3247). +#[test] +fn time_stretch_refuses_more_than_9900_percent() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let stretch = |s: &Session| s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == layer).unwrap().stretch; + for pct in [1e300, -1e300, 9900.5] { + assert!(s.execute("layer.timeStretch", json!({"layers": [layer], "percent": pct})).is_err(), "{pct}"); + assert_eq!(stretch(&s), 100.0, "{pct}"); + } + for pct in [limits::MAX_STRETCH, -limits::MAX_STRETCH] { + s.execute("layer.timeStretch", json!({"layers": [layer], "percent": pct})).unwrap(); + assert_eq!(stretch(&s), pct); + } +} + #[test] fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() { // Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 9789ceb89..22e22b4f2 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -522,11 +522,31 @@ impl Importer<'_> { // ---- timing let start = tick_in(ti.start); let dur = tick_in(ti.duration); - let speed = if ti.speed.abs() < 1e-9 { 1.0 } else { ti.speed.abs() }; + // A zero, tiny (below 1e-9) or non-finite speed plays at 100 %. Otherwise the layer's + // stretch (100 / speed) is held to the range the app supports (±1 % to ±9900 %): a speed + // of 1e300 or 1e-300 from a damaged file made the layer and audio time divide by ~0 + // (#3247). The speed follows the stretch so the start time below matches it. + let raw = ti.speed.abs(); + let (speed, stretch) = if !raw.is_finite() || raw < 1e-9 { + // A speed of exactly 0 keeps its old silent 100 %; a tiny or non-finite one is named. + if !raw.is_finite() || raw > 0.0 { + self.warn(format!("clip \"{}\": a speed of {} is not supported (100 % used)", ti.name, ti.speed)); + } + (1.0, 100.0) + } else { + let stretch = effectcraft_project::limits::stretch(100.0 / raw); + if stretch.to_bits() == (100.0 / raw).to_bits() { + (raw, stretch) + } else { + let speed = 100.0 / stretch; + self.warn(format!("clip \"{}\": a speed of {:e} % is outside what a layer supports ({} % used)", ti.name, raw * 100.0, speed * 100.0)); + (speed, stretch) + } + }; let reverse = ti.reverse || ti.speed < 0.0; let src_in = tick_in(ti.source_in); let span = Tick((dur.0 as f64 * speed).round() as i64); - l.stretch = if reverse { -100.0 / speed } else { 100.0 / speed }; + l.stretch = if reverse { -stretch } else { stretch }; l.start_time = if reverse { start + Tick(((src_in + span).0 as f64 / speed).round() as i64) } else { start - Tick((src_in.0 as f64 / speed).round() as i64) }; l.in_point = start; diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index f4c9158f8..b451ecf81 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -314,6 +314,24 @@ fn imports_fcp7_xml_fcpxml_and_otio() { } } +/// A clip speed past what a layer's stretch supports (±1 % to ±9900 %) is held to it and named. +/// Before, a speed of 1e6 gave a stretch of 1e-4 % and the layer and audio time divided by ~0 +/// (#3247). +#[test] +fn an_extreme_clip_speed_is_held_to_the_stretch_range() { + for (speed, stretch) in [(1e6, 1.0), (1e-6, 9900.0)] { + let (mut doc, main) = edit(); + doc.sequence_mut(main).unwrap().video_tracks[1].items[0].speed = speed; + let (bytes, _) = fc::export(&doc, main, fc::Format::Otio, &Default::default()).unwrap(); + let (p, r) = import_doc(&bytes, "Main.otio", &mut probe); + let main_comp = r.comps.iter().copied().find(|c| p.item(*c).unwrap().name == "Main").expect("Main comp"); + let c = p.comp(main_comp).unwrap(); + let stretched: Vec = c.layers.iter().map(|l| l.stretch).filter(|s| (s - 100.0).abs() > 1e-9).collect(); + assert_eq!(stretched, [stretch], "speed {speed}"); + assert!(r.warnings.iter().any(|w| w.contains("speed") && w.contains("outside")), "speed {speed}: {:?}", r.warnings); + } +} + #[test] fn imports_aaf_and_omf() { let (doc, main) = edit(); diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 53b1ba71c..57ef6427a 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -431,7 +431,12 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ // Below 1 % (or 0) the layer time divides by ~0, and layer.timeStretch refuses it too. let sr = f(j, "sr").unwrap_or(1.0); l.stretch = if sr.is_finite() && sr.abs() >= 0.01 { - sr * 100.0 + // Past ±9900 % too (#3247): the largest stretch a layer supports, sign kept. + let pct = effectcraft_project::limits::stretch(sr * 100.0); + if pct.to_bits() != (sr * 100.0).to_bits() { + cx.im.warn(format!("{name}: a time stretch of {} % is not supported ({pct} % used)", sr * 100.0)); + } + pct } else { cx.im.warn(format!("{name}: a time stretch of {} % is not supported (100 % used)", sr * 100.0)); 100.0 diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index d26b3642d..414c875f0 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -520,6 +520,21 @@ fn zero_time_stretch_is_not_imported() { assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings); } +#[test] +fn a_time_stretch_past_9900_percent_is_held_to_it() { + // `sr` 1e300 was imported as 1e302 % (#3247); the sign of a reversed layer is kept. + for (sr, want) in [(1e300, 9900.0), (-1e300, -9900.0), (99.0, 9900.0)] { + let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [], + "layers": [{"ty": 1, "ind": 1, "sc": "#ff0000", "sw": 20, "sh": 20, "sr": sr, "ip": 0, "op": 30, "st": 0, "ks": {}}]}); + let mut q = Project::default(); + let imp = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap(); + let l = &q.comp(imp.comp).unwrap().layers[0]; + assert_eq!(l.stretch, want, "sr {sr}"); + let warned = imp.warnings.iter().any(|w| w.contains("time stretch")); + assert_eq!(warned, sr != 99.0, "sr {sr}: {:?}", imp.warnings); + } +} + #[test] fn embedded_glyphs_are_reported_when_dropped() { let glyph = |ch: &str, fam: &str| json!({"ch": ch, "size": 100, "style": "Regular", "w": 60, "fFamily": fam, "data": {"shapes": []}}); diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index 34eeb50c0..bc747bd88 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -470,6 +470,20 @@ pub enum AutoOrient { TowardsPointOfInterest, } +/// A stretch written as `null` (a NaN or infinite stretch: JSON holds neither, and auto-saves +/// write it so) reads as NaN, so the project still opens and loading brings it to 100 % with a +/// note ([`crate::limits::stretch`], #3247) instead of the whole file being refused. An infinite +/// stretch therefore also reopens at 100 %, not at ±9900 %. +fn stretch_or_nan<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(Option::::deserialize(d)?.unwrap_or(f64::NAN)) +} + +/// `t × mul / div` in ticks, within [`Tick::MIN`]..=[`Tick::MAX`] (the cast saturates, NaN is 0), +/// so a layer time of an extreme stretch can't overflow later tick arithmetic. +fn scaled(t: Tick, mul: f64, div: f64) -> Tick { + Tick(((t.0 as f64 * mul / div) as i64).clamp(Tick::MIN.0, Tick::MAX.0)) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Layer { pub id: LayerId, @@ -484,6 +498,7 @@ pub struct Layer { pub in_point: Tick, pub out_point: Tick, /// Time stretch in percent (100 = normal, negative = reversed). + #[serde(deserialize_with = "stretch_or_nan")] pub stretch: f64, pub switches: Switches, pub blend_mode: BlendMode, @@ -555,15 +570,24 @@ impl Layer { t >= self.in_point && t < self.out_point } + /// The time stretch the layer plays at: [`Layer::stretch`] within + /// [`limits::stretch`](crate::limits::stretch) (a damaged value that got past loading, 0 or + /// NaN, never divides the layer's time by zero). + pub fn effective_stretch(&self) -> f64 { + crate::limits::stretch(self.stretch) + } + /// Layer time for a comp time (stretch-aware). pub fn layer_time(&self, comp_t: Tick) -> Tick { let d = comp_t - self.start_time; - if (self.stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / self.stretch) as i64) } + let s = self.effective_stretch(); + if (s - 100.0).abs() < 1e-9 { d } else { scaled(d, 100.0, s) } } /// Comp time for a layer time. pub fn comp_time(&self, layer_t: Tick) -> Tick { - if (self.stretch - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + Tick((layer_t.0 as f64 * self.stretch / 100.0) as i64) } + let s = self.effective_stretch(); + if (s - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + scaled(layer_t, s, 100.0) } } pub fn transform(&self) -> Option<&PropGroup> { diff --git a/crates/project/src/limits.rs b/crates/project/src/limits.rs index 998f40225..7a74c992b 100644 --- a/crates/project/src/limits.rs +++ b/crates/project/src/limits.rs @@ -27,6 +27,30 @@ pub const MAX_PIXEL_ASPECT: f64 = 10.0; pub const MOTION_BLUR_SAMPLES: (u32, u32) = (2, 64); /// Composition Settings ▸ Advanced ▸ Adaptive Sample Limit. pub const ADAPTIVE_SAMPLE_LIMIT: (u32, u32) = (16, 256); +/// The smallest time stretch, in percent, either way (After Effects sets a stretch between 0 and +/// 1 % to 1 %, between -1 % and 0 to -1 %; `layer.timeStretch` refuses less). +pub const MIN_STRETCH: f64 = 1.0; +/// The largest time stretch, in percent, either way (After Effects' `Layer.stretch` range is +/// -9900 to 9900). +pub const MAX_STRETCH: f64 = 9900.0; + +/// A layer's time stretch from [`MIN_STRETCH`] to [`MAX_STRETCH`] % either way, its sign (a +/// reversed layer) kept; 100 % for 0 or NaN. Layer and comp time divide by the stretch, and the +/// audio mix reads `100 / stretch` source samples per sample, so a stretch of 1e-300 % or NaN +/// from a damaged file must not reach them (#3247). +pub fn stretch(v: f64) -> f64 { + if v.is_nan() || v == 0.0 { + return 100.0; + } + // Infinity clamps to the largest stretch with its sign. + v.signum() * v.abs().clamp(MIN_STRETCH, MAX_STRETCH) +} + +/// A stretch for a note: a plain number in a readable range, scientific notation beyond it (a +/// plain 1e-300 would print 300 digits). +fn percent(v: f64) -> String { + if v == 0.0 || (1e-3..1e7).contains(&v.abs()) { format!("{v} %") } else { format!("{v:e} %") } +} /// A width or height from 1 to [`MAX_SIZE`]. pub fn size(v: u64) -> u32 { @@ -97,9 +121,11 @@ fn repair_comp(name: &str, c: &Comp, notes: &mut Vec) -> Option { let duration = c.duration.max(rate.frame_duration()); let samples = c.motion_blur_samples.clamp(MOTION_BLUR_SAMPLES.0, MOTION_BLUR_SAMPLES.1); let adaptive = c.motion_blur_adaptive_limit.clamp(ADAPTIVE_SAMPLE_LIMIT.0, ADAPTIVE_SAMPLE_LIMIT.1); + let stretch_ok = |l: &crate::Layer| stretch(l.stretch).to_bits() == l.stretch.to_bits(); let unchanged = (width, height, rate, duration, samples, adaptive) == (c.width, c.height, c.frame_rate, c.duration, c.motion_blur_samples, c.motion_blur_adaptive_limit) - && pa.to_bits() == c.pixel_aspect.to_bits(); + && pa.to_bits() == c.pixel_aspect.to_bits() + && c.layers.iter().all(stretch_ok); if unchanged { return None; } @@ -125,6 +151,12 @@ fn repair_comp(name: &str, c: &Comp, notes: &mut Vec) -> Option { } (n.width, n.height, n.pixel_aspect, n.frame_rate, n.duration) = (width, height, pa, rate, duration); (n.motion_blur_samples, n.motion_blur_adaptive_limit) = (samples, adaptive); + // Only the layers out of range are copied (the others stay shared). + for l in n.layers.iter_mut_where(|l| !stretch_ok(l)) { + let fixed = stretch(l.stretch); + note(notes, name, &format!("layer '{}' time stretch", l.name), percent(l.stretch), percent(fixed)); + l.stretch = fixed; + } Some(n) } @@ -217,6 +249,85 @@ mod tests { ); } + #[test] + fn a_stretch_is_held_to_one_to_9900_percent_with_its_sign() { + assert_eq!(stretch(f64::NAN), 100.0); + assert_eq!(stretch(0.0), 100.0); + assert_eq!(stretch(-0.0), 100.0); + assert_eq!(stretch(1e-300), MIN_STRETCH); + assert_eq!(stretch(-1e-300), -MIN_STRETCH); + assert_eq!(stretch(0.5), MIN_STRETCH); + assert_eq!(stretch(1e300), MAX_STRETCH); + assert_eq!(stretch(f64::INFINITY), MAX_STRETCH); + assert_eq!(stretch(f64::NEG_INFINITY), -MAX_STRETCH); + for v in [1.0, -1.0, 33.3, 100.0, -100.0, 200.0, 9900.0, -9900.0] { + assert_eq!(stretch(v).to_bits(), v.to_bits(), "{v} is in range"); + } + } + + /// A layer whose stretch got past loading (0, NaN, 1e-300 %, set in memory) still maps time + /// through a stretch in range: before, 0 and 1e-300 % saturated every layer time to the end of + /// time and NaN collapsed it to 0 (#3247). + #[test] + fn layer_and_comp_time_use_a_stretch_in_range() { + let mut p = crate::Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(10.0)); + let mut l = crate::build::layer(&mut p, &comp, "L", crate::LayerSource::Camera, (64, 64), None); + l.start_time = Tick::from_seconds_f64(1.0); + let at = |s: f64| Tick::from_seconds_f64(s); + for (bad, effective) in [(0.0, 100.0), (f64::NAN, 100.0), (1e-300, 1.0), (-1e-300, -1.0), (1e300, 9900.0), (f64::NEG_INFINITY, -9900.0)] { + l.stretch = bad; + assert_eq!(l.effective_stretch(), effective, "{bad}"); + // One comp second after the start is 100 / stretch layer seconds. + let want = Tick((at(1.0).0 as f64 * 100.0 / effective) as i64); + assert_eq!(l.layer_time(at(2.0)), want, "{bad}"); + // And back, within the rounding of the two conversions. + assert!((l.comp_time(l.layer_time(at(2.0))) - at(2.0)).abs() <= Tick(100), "{bad}"); + } + } + + #[test] + fn a_stretch_out_of_range_in_a_file_is_corrected_and_reported() { + let mut p = crate::Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(2.0)); + let mut c = comp.clone(); + for (name, s) in [("tiny", 1e-300), ("reversed tiny", -1e-300), ("zero", 0.0), ("huge", 1e300), ("reversed huge", -5e4), ("fine", -50.0)] { + let mut l = crate::build::layer(&mut p, &comp, name, crate::LayerSource::Camera, (64, 64), None); + l.stretch = s; + c.layers.push(l); + } + // Added through `add_item`, the comp is held to the limits too: write the values back in. + let id = p.add_item("Comp", effectcraft_color::Label::None, None, ItemKind::Comp(Arc::new(c.clone()))); + assert!(p.comp(id).unwrap().layers.iter().all(|l| l.stretch.abs() >= MIN_STRETCH && l.stretch.abs() <= MAX_STRETCH)); + let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap(); + for (k, l) in c.layers.iter().enumerate() { + v["items"][id.0.to_string()]["kind"]["layers"][k]["stretch"] = serde_json::json!(l.stretch); + } + let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap(); + let got: Vec = back.comp(id).unwrap().layers.iter().map(|l| l.stretch).collect(); + assert_eq!(got, [1.0, -1.0, 100.0, 9900.0, -9900.0, -50.0]); + assert_eq!( + notes, + [ + "Comp: layer 'tiny' time stretch 1e-300 % → 1 %", + "Comp: layer 'reversed tiny' time stretch -1e-300 % → -1 %", + "Comp: layer 'zero' time stretch 0 % → 100 %", + "Comp: layer 'huge' time stretch 1e300 % → 9900 %", + "Comp: layer 'reversed huge' time stretch -50000 % → -9900 %", + ] + ); + // A NaN stretch is written as null (an auto-save): the file opens, at 100 %, and says so. + // Before, the whole project was refused. + v["items"][id.0.to_string()]["kind"]["layers"][0]["stretch"] = serde_json::Value::Null; + let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap(); + assert_eq!(back.comp(id).unwrap().layers.iter().next().unwrap().stretch, 100.0); + assert_eq!(notes.first().map(String::as_str), Some("Comp: layer 'tiny' time stretch NaN % → 100 %")); + let mut nan = p.clone(); + nan.comp_mut(id).unwrap().layers.iter_mut().for_each(|l| l.stretch = f64::NAN); + let (back, _) = crate::Project::from_json_repaired(&nan.to_json_compact()).unwrap(); + assert!(back.comp(id).unwrap().layers.iter().all(|l| l.stretch == 100.0)); + } + #[test] fn a_comp_in_range_is_not_copied() { let c = Comp::new(1920, 1080, FrameRate::FPS_24, Tick::from_seconds_f64(2.0)); diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index e2861340c..00342847d 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -155,7 +155,9 @@ impl MixEnv<'_> { return out; } let m = b - a; - let speed = 100.0 / l.stretch; + // Within ±1 % to ±9900 % (#3247): a stretch of 1e-300 % set in memory read about + // usize::MAX source samples for one block. + let speed = 100.0 / l.effective_stretch(); let src_n = ((m as f64 * speed).ceil() as usize).max(1); let lt = l.layer_time(sample_tick(i0 + a as i64, rate)); let buf = match &l.source { @@ -291,7 +293,8 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut let any_solo = comp.layers.iter().any(|l| l.switches.solo && audible(project, l)); let s0 = start.to_units_floor(rate as i64); for l in &comp.layers { - if !audible(project, l) || (any_solo && !l.switches.solo) || l.stretch <= 0.0 { + // The stretch the layer's time uses (#3247): 0 and NaN play at 100 %, as `layer_time`. + if !audible(project, l) || (any_solo && !l.switches.solo) || l.effective_stretch() <= 0.0 { continue; } // Sample range of the block that falls inside the layer's [in, out). diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index c77fc2d94..930bd810b 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -22,6 +22,7 @@ mod nested; mod paint; mod remap; mod roi; +mod stretch_limits; mod styles; mod time; diff --git a/crates/render/src/tests/stretch_limits.rs b/crates/render/src/tests/stretch_limits.rs new file mode 100644 index 000000000..17bdff317 --- /dev/null +++ b/crates/render/src/tests/stretch_limits.rs @@ -0,0 +1,96 @@ +//! A project file whose layers carry a time stretch outside ±1 % to ±9900 % (1e-300 %, 0, +//! 1e300 %…) opens with the stretch brought into that range, and a frame of it draws and its audio +//! mixes with bounded source reads. Before, a stretch of 1e-300 % asked the footage for about +//! `usize::MAX` samples and the mix overflowed or tried a huge allocation, aborting the app (#3247). + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, build, limits}; +use effectcraft_time::Tick; + +use super::{setup, solid}; +use crate::{FootageSource, Image, audio::mix_comp, render_frame}; + +/// Silent audio footage that records the longest read asked of it and refuses a read past a +/// thousand seconds (so a runaway read can't take the machine's memory: the mix then fills the +/// read with silence itself). +#[derive(Default)] +struct Probe { + longest: AtomicUsize, +} + +impl FootageSource for Probe { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, rate: u32) -> Option> { + self.longest.fetch_max(frames, Ordering::Relaxed); + (frames <= rate as usize * 1000).then(|| vec![0.0; frames * 2]) + } +} + +/// The project file of a comp holding a red solid and a sound, both with their stretch set to +/// `stretch` in the file text. +fn file_with_stretch(stretch: f64) -> (String, ItemId) { + let (mut p, cid, comp) = setup(); + let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + let f = Footage { + path: "tone.wav".into(), + kind: FootageKind::Audio, + has_audio: true, + frame_rate: comp.frame_rate, + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f)); + let sound = build::layer(&mut p, &comp, "sound", LayerSource::Footage { item: fid }, (0, 0), None); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(red); + c.layers.push(sound); + // Both layers are at 100 %; `{:e}` writes a JSON number for any finite value, and NaN is + // written as null, as serializing the project writes it. + let text = p.to_json(); + assert_eq!(text.matches("\"stretch\": 100.0").count(), 2); + let value = if stretch.is_nan() { "null".to_string() } else { format!("{stretch:e}") }; + (text.replace("\"stretch\": 100.0", &format!("\"stretch\": {value}")), cid) +} + +#[test] +fn a_file_with_an_extreme_stretch_opens_in_range_draws_and_mixes_with_bounded_reads() { + for (stored, opened) in + [(1e-300, 1.0), (-1e-300, -1.0), (0.0, 100.0), (1e300, limits::MAX_STRETCH), (-1e300, -limits::MAX_STRETCH), (0.5, 1.0), (f64::NAN, 100.0)] + { + let (text, cid) = file_with_stretch(stored); + let (p, notes) = Project::from_json_repaired(&text).unwrap(); + assert!(notes.iter().filter(|n| n.contains("time stretch")).count() == 2, "{stored}: {notes:?}"); + let c = p.comp(cid).unwrap(); + assert!(c.layers.iter().all(|l| l.stretch == opened), "{stored}: {:?}", c.layers.iter().map(|l| l.stretch).collect::>()); + // A frame draws: the solid has no keys and keeps its in and out points, so it is red. + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + assert_eq!(img.get(10, 10), [1.0, 0.0, 0.0, 1.0], "{stored}"); + // The audio mixes, reading at most 100 source samples per comp sample (a stretch of 1 %). + let (rate, n) = (8000, 4000); + let probe = Probe::default(); + let mixed = mix_comp(&p, &probe, None, cid, Tick::from_seconds_f64(0.5), n, rate); + assert_eq!(mixed.len(), n * 2, "{stored}"); + assert!(probe.longest.load(Ordering::Relaxed) <= 100 * n + 1, "{stored}: a read of {} samples", probe.longest.load(Ordering::Relaxed)); + } +} + +/// A stretch set in memory (not through a file, so not repaired) mixes as the clamped stretch the +/// layer's time uses: before, 1e-300 % read about `usize::MAX` source samples and overflowed. +#[test] +fn an_in_memory_extreme_stretch_mixes_with_bounded_reads() { + for stretch in [1e-300, 0.0, f64::NAN, 1e300] { + let (text, cid) = file_with_stretch(100.0); + let (mut p, _) = Project::from_json_repaired(&text).unwrap(); + p.comp_mut(cid).unwrap().layers.iter_mut().for_each(|l| l.stretch = stretch); + let (rate, n) = (8000, 4000); + let probe = Probe::default(); + let mixed = mix_comp(&p, &probe, None, cid, Tick::from_seconds_f64(0.5), n, rate); + assert_eq!(mixed.len(), n * 2, "{stretch}"); + assert!(probe.longest.load(Ordering::Relaxed) <= 100 * n + 1, "{stretch}: a read of {} samples", probe.longest.load(Ordering::Relaxed)); + } +} diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 2a5579837..db3144ffd 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -262,7 +262,14 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Stretch Factor:")); let before = d.percent; - drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, DragSpec { speed: 0.5, range: -10000.0..=10000.0, suffix: " %" }, true); + drag( + ui, + app, + "dialog.timeStretch.percent", + &mut d.percent, + DragSpec { speed: 0.5, range: -effectcraft_project::limits::MAX_STRETCH..=effectcraft_project::limits::MAX_STRETCH, suffix: " %" }, + true, + ); if (before - d.percent).abs() > 1e-9 && d.orig_percent.abs() > 1e-9 { d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs(); } @@ -271,7 +278,12 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let before = d.duration; drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, DragSpec { speed: 0.05, range: 0.01..=100000.0, suffix: " s" }, true); if (before - d.duration).abs() > 1e-9 && d.orig_duration > 1e-9 { - d.percent = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration; + let wanted = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration; + // Within what Time Stretch accepts (±1 % to ±9900 %, #3247); the duration follows. + d.percent = effectcraft_project::limits::stretch(wanted); + if d.percent.to_bits() != wanted.to_bits() && d.orig_percent.abs() > 1e-9 { + d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs(); + } } ui.end_row(); }); From 99e24f5867e1460be9a820f1558cc1fce6134a11 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 8 Nov 2026 01:33:26 +0100 Subject: [PATCH 21/94] .aep import: image sequences without a filename prefix or with Force alphabetical order (#3213) * AEP import: read image sequences without a filename prefix and with Force alphabetical order (#3174) After Effects leaves out an empty filename prefix, so only one Utf8 (the suffix, such as `.png`) follows the alias. The single part is now placed by the first file name stored in `opti`: a name starting with it has it as the prefix, one ending with it as the suffix; without that evidence a part starting with `.` is the suffix and anything else is not read. A sequence imported with Force alphabetical order stores its files in `LIST StVc` (`StVS` with the count as a little-endian u32, then one Utf8 per file name) instead of prefix and suffix; that list now replaces the numbering. A list with a name that is not a plain file name (a separator, a colon, `.` or `..`) is not read, since the names are joined to the folder. A folder alias whose naming still cannot be read is reported as an import loss instead of silently linking the folder. docs/aep-format.md describes the empty prefix and StVc/StVS. * AEP import: a listed sequence file name with a control character such as NUL is not read (#3174) A NUL or another control character in a Force alphabetical order file name now makes the whole list unreadable, like a separator or a dot directory, so the import reports the naming instead of linking files that cannot exist. * AEP import: a sequence file list without a readable count is not read (#3174) After Effects writes the StVS count before the names. A StVc list without StVS, or with one shorter than its u32, is now reported as unreadable naming instead of taking every listed name. * Doc comments for the functions this PR touches --- crates/aep/src/container/footage.rs | 89 ++++++++++++- crates/aep/src/import/build.rs | 36 ++++-- crates/aep/tests/it/import/synthetic.rs | 161 ++++++++++++++++++++++++ docs/aep-format.md | 5 +- 4 files changed, 274 insertions(+), 17 deletions(-) diff --git a/crates/aep/src/container/footage.rs b/crates/aep/src/container/footage.rs index 3c9148155..da0369d64 100644 --- a/crates/aep/src/container/footage.rs +++ b/crates/aep/src/container/footage.rs @@ -2,6 +2,7 @@ //! of a layer's source. use crate::container::bytes::{be_f32, be_f64, be_u32, cstr, le_u32, text}; +use crate::container::chunk::Chunk; use crate::container::header::Rational; use crate::container::typed::{Item, ItemKind, Layer, Project}; @@ -159,19 +160,25 @@ fn json_string_at(json: &[u8], mut i: usize) -> Option { } } -/// The numbering of an image sequence item (353, 603). +/// The numbering of an image sequence item (353, 603), or its file list when it was imported with +/// Force alphabetical order. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SequenceInfo { /// The file name before the frame number (`seq_`, `shot_`): the first `Utf8` after the alias. + /// An empty prefix is not stored (`0001.png`): the one `Utf8` is then the suffix. pub prefix: String, /// The file name after the frame number (`.png`, `.exr`): the second `Utf8` after the alias. pub suffix: String, /// The first and last frame number (`sspc` `u32` at 172 and 176: 1 and 3 in 353, 101 and 103 - /// in 603). + /// in 603; both `0xFFFFFFFF` for a sequence with a file list). pub start: u32, pub end: u32, /// The digits of the frame number (`sspc` `u32` at 180: 3 for `seq_001`, 4 for `shot_0101`). pub digits: u32, + /// The file names in order for a sequence imported with Force alphabetical order (`LIST StVc` + /// after the alias: `StVS` with the count as a little-endian `u32`, then one `Utf8` per file); + /// empty for a numbered sequence. When set, it replaces the numbering. + pub files: Vec, } impl SequenceInfo { @@ -180,6 +187,62 @@ impl SequenceInfo { let width = usize::try_from(self.digits.min(16)).unwrap_or(0); format!("{}{n:0width$}{}", self.prefix, self.suffix) } + + /// The number of files: the listed files, or the frame numbers from start to end (0 when the + /// end lies before the start). + pub fn frame_count(&self) -> u32 { + if self.files.is_empty() { + self.end.checked_sub(self.start).map_or(0, |d| d.saturating_add(1)) + } else { + u32::try_from(self.files.len()).unwrap_or(u32::MAX) + } + } + + /// The name of the `i`-th file (from 0): from the file list, or numbered from `start`; `None` + /// past the last file. + pub fn nth_file_name(&self, i: u32) -> Option { + if self.files.is_empty() { + let n = self.start.checked_add(i).filter(|n| *n <= self.end)?; + Some(self.file_name(n)) + } else { + self.files.get(usize::try_from(i).ok()?).cloned() + } + } +} + +/// The file names of a `LIST StVc` (Force alphabetical order): the `Utf8` children in order, at most +/// as many as the count in `StVS` (little-endian `u32`) says. Empty names count toward that number +/// and are then skipped. Without a readable count (no `StVS`, or one shorter than 4 bytes) the list +/// is unreadable (empty). A name that is not a plain file name (holding `/`, `\`, `:` or a control +/// character such as NUL, or being `.` or `..`) makes the whole list unreadable (empty): the names +/// are joined to the folder. +fn sequence_file_list(list: &Chunk<'_>) -> Vec { + // After Effects writes the count first (the one sample); a list without a readable count is + // not read rather than guessed. + let Some(count) = list.child(b"StVS").and_then(|c| c.bytes()).and_then(|b| le_u32(b, 0)).and_then(|n| usize::try_from(n).ok()) else { + return Vec::new(); + }; + let names: Vec = + list.children().iter().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).take(count).map(text).filter(|n| !n.is_empty()).collect(); + if names.iter().any(|n| n.contains(|c: char| matches!(c, '/' | '\\' | ':') || c.is_control()) || n == "." || n == "..") { + return Vec::new(); + } + names +} + +/// The prefix and suffix of a sequence whose naming holds one `Utf8` (`part`): After Effects leaves +/// out an empty prefix (`0001.png` stores only `.png`). The stored first file name (`opti`) decides: +/// a name starting with `part` has it as the prefix, one ending with it as the suffix. Without that +/// evidence, a `part` starting with `.` is the suffix (an extension); anything else is not read. +fn single_name_part(part: &str, first_file: Option<&str>) -> Option<(String, String)> { + if let Some(first) = first_file.filter(|_| !part.is_empty()) { + match (first.starts_with(part), first.ends_with(part)) { + (true, false) => return Some((part.to_string(), String::new())), + (false, true) => return Some((String::new(), part.to_string())), + _ => {} + } + } + part.starts_with('.').then(|| (String::new(), part.to_string())) } impl<'f, 'a> Item<'f, 'a> { @@ -194,10 +257,24 @@ impl<'f, 'a> Item<'f, 'a> { let sspc = pin.child(b"sspc")?.bytes()?; let kids = pin.children(); let after = kids.iter().position(|c| c.is_list(b"Als2"))?; - let mut names = kids.iter().skip(after + 1).take_while(|c| !c.is(b"opti")).filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text); - let prefix = names.next()?; - let suffix = names.next()?; - Some(SequenceInfo { prefix, suffix, start: be_u32(sspc, 172)?, end: be_u32(sspc, 176)?, digits: be_u32(sspc, 180)? }) + let naming = || kids.iter().skip(after.saturating_add(1)).take_while(|c| !c.is(b"opti")); + let (start, end, digits) = (be_u32(sspc, 172)?, be_u32(sspc, 176)?, be_u32(sspc, 180)?); + // Force alphabetical order: the file list replaces the numbering. + if let Some(list) = naming().find(|c| c.is_list(b"StVc")) { + let files = sequence_file_list(list); + if files.is_empty() { + return None; + } + return Some(SequenceInfo { prefix: String::new(), suffix: String::new(), start, end, digits, files }); + } + let names: Vec = naming().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text).take(2).collect(); + let (prefix, suffix) = match names.as_slice() { + [prefix, suffix] => (prefix.clone(), suffix.clone()), + // An empty prefix is not stored (`0001.png` has only `.png`). + [one] => single_name_part(one, f.name.as_deref())?, + _ => return None, + }; + Some(SequenceInfo { prefix, suffix, start, end, digits, files: Vec::new() }) } /// The rate a clip is conformed to (Interpret Footage ▸ Conform to frame rate; `sspc` `u32` at diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 4ec346328..48ae1c1a5 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -206,6 +206,8 @@ fn new_comp(h: &CompHeader, renderer: Option, map: &TimeMap, name: comp } +/// Adds one footage item to the project under the given parent. +/// Unreadable footage is recorded as a loss, solids and null placeholders are handled separately, and file and sequence footage is linked to its files. fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_>, name: String, label: Label, parent: ItemId, null_only: &HashSet) { let Some(f) = item.footage() else { cx.col.lose(LossCategory::DamagedData, format!("footage '{name}': data unreadable, not imported")); @@ -240,31 +242,42 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ } let mut path = f.path.clone().unwrap_or_default(); // An image sequence's alias is its folder; its files are named by the stored numbering - // (353, 603). The first file stands for the sequence, as in EffectCraft. + // (353, 603), or listed one by one when imported with Force alphabetical order. The first + // file stands for the sequence, as in EffectCraft. let mut sequence = Vec::new(); - if let Some(seq) = item.sequence_info().filter(|s| !path.is_empty() && s.end >= s.start) { - let frames = (seq.end - seq.start).saturating_add(1); + if let Some(seq) = item.sequence_info().filter(|s| !path.is_empty() && s.frame_count() > 0) { + let frames = seq.frame_count(); let sep = if path.contains('\\') { '\\' } else { '/' }; let folder = path.trim_end_matches(['\\', '/']).to_string(); - // One name is the folder, a separator and the file name; the number is as wide as the stored - // digits or as the largest number, whichever is longer (an upper bound for every frame). - let name_bytes = (folder.len() + 1 + seq.prefix.len() + seq.suffix.len() + usize::try_from(seq.digits.min(16)).unwrap_or(16).max(10)) as u64; - let bytes = name_bytes.saturating_mul(u64::from(frames)); + // One name is the folder, a separator and the file name. A numbered name is as wide as the + // stored digits or as the largest number, whichever is longer (an upper bound for every + // frame); listed names are counted as they are. + let len = |n: usize| u64::try_from(n).unwrap_or(u64::MAX); + let file_bytes = if seq.files.is_empty() { + len(seq.prefix.len().saturating_add(seq.suffix.len()).saturating_add(usize::try_from(seq.digits.min(16)).unwrap_or(16).max(10))) + .saturating_mul(u64::from(frames)) + } else { + seq.files.iter().fold(0u64, |sum, n| sum.saturating_add(len(n.len()))) + }; + let bytes = len(folder.len().saturating_add(1)).saturating_mul(u64::from(frames)).saturating_add(file_bytes); if frames > MAX_SEQUENCE_FRAMES || frames > cx.sequence_budget || bytes > cx.sequence_bytes { // Not expanded: the item keeps its first file (a still standing for the sequence). cx.col.lose( LossCategory::DamagedData, format!("footage '{name}': a sequence of {frames} frames is longer than the import expands; only its first file is linked"), ); - path = format!("{folder}{sep}{}", seq.file_name(seq.start)); + path = format!("{folder}{sep}{}", seq.nth_file_name(0).unwrap_or_default()); } else { cx.sequence_budget -= frames; cx.sequence_bytes -= bytes; - sequence = (seq.start..=seq.end).map(|n| format!("{folder}{sep}{}", seq.file_name(n))).collect(); + sequence = (0..frames).filter_map(|i| seq.nth_file_name(i)).map(|n| format!("{folder}{sep}{n}")).collect(); } if let Some(first) = sequence.first() { path = first.clone(); } + } else if f.is_sequence && !path.is_empty() { + // The alias is a folder, but the file names could not be read: the item links the folder. + cx.col.lose(LossCategory::DamagedData, format!("footage '{name}': image sequence naming not read, linked to its folder")); } let present = !path.is_empty() && (cx.options.exists)(&path); if !present { @@ -318,6 +331,11 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ /// sequence the name with the frame range (`shot_[0101-0103].exr`). fn default_footage_name(item: &AepItem<'_, '_>, f: &crate::Footage) -> Option { if let Some(s) = item.sequence_info() { + // A listed sequence (Force alphabetical order) has no numbering: its first file names it. + // Assumption: what After Effects shows for such an item without a stored name was not sampled. + if let Some(first) = s.files.first() { + return Some(first.clone()); + } let w = usize::try_from(s.digits.min(16)).unwrap_or(0); return Some(format!("{}[{:0w$}-{:0w$}]{}", s.prefix, s.start, s.end, s.suffix)); } diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 18709f9e5..5d4d89c56 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -77,6 +77,167 @@ fn a_sequence_with_long_names_is_not_expanded_past_the_byte_budget() { assert_eq!(longest, 0, "the sequence was not expanded"); } +/// The project with the sequence's naming (the chunks between `Als2` and `opti` in its `Pin `) +/// replaced by `naming`, and the `sspc` numbering (start, end, digits at 172, 176, 180) set to +/// `numbering` when given. +fn with_sequence_naming(naming: &[Chunk<'static>], numbering: Option<[u32; 3]>) -> Vec { + let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap(); + let mut patched = 0; + visit(&mut file.chunks, &mut |c| { + if !c.is_list(b"Pin ") { + return; + } + let Body::List { children, .. } = &mut c.body else { return }; + let Some(als) = children.iter().position(|k| k.is_list(b"Als2")) else { return }; + let Some(opti) = children.iter().position(|k| k.is(b"opti")) else { return }; + assert!(als < opti); + let tail = children.split_off(opti); + children.truncate(als + 1); + children.extend(naming.iter().map(shorten)); + children.extend(tail); + if let Some(numbering) = numbering { + let sspc = children.iter_mut().find(|k| k.is(b"sspc")).unwrap(); + for (at, v) in [172, 176, 180].into_iter().zip(numbering) { + set_data(sspc, at, &v.to_be_bytes()); + } + } + patched += 1; + }); + assert_eq!(patched, 1, "the synthetic project has one sequence"); + write(&file).unwrap() +} + +/// `bytes` with the first file name the sequence stores in its png `opti` (byte 58, NUL-terminated) +/// set to `name`. +fn with_first_file(bytes: &[u8], name: &str) -> Vec { + let mut file: AepFile<'_> = parse(bytes).unwrap(); + let mut patched = 0; + visit(&mut file.chunks, &mut |c| { + if c.is(b"opti") && c.bytes().is_some_and(|d| d.get(..4) == Some(&b"png!"[..])) { + let mut z = name.as_bytes().to_vec(); + z.push(0); + set_data(c, 58, &z); + patched += 1; + } + }); + assert_eq!(patched, 1, "the synthetic project has one png item"); + write(&file).unwrap() +} + +/// The imported sequence item's footage. +fn the_footage(p: &Project) -> effectcraft_project::Footage { + p.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).expect("the sequence") +} + +/// The file names (without the folder) of a footage's sequence. +fn file_names(f: &effectcraft_project::Footage) -> Vec { + f.sequence.iter().map(|s| s.rsplit(['\\', '/']).next().unwrap_or_default().to_string()).collect() +} + +/// A copy of `c` that borrows for any lifetime (a chunk is covariant in its lifetime). +fn shorten<'a>(c: &Chunk<'static>) -> Chunk<'a> { + c.clone() +} + +/// A `Utf8` string chunk holding `s`. +fn utf8(s: &str) -> Chunk<'static> { + Chunk::data(*b"Utf8", s.as_bytes().to_vec()) +} + +/// A sequence whose naming has only a suffix (no filename prefix) imports as a sequence of its files. +#[test] +fn a_sequence_without_a_filename_prefix_imports_its_files() { + // `0001.png` .. `0003.png`: After Effects omits the empty prefix, so only `.png` follows the + // alias. Before, `.png` was read as the prefix, the suffix was missing and the item became a + // video linked to its folder. + let (p, r) = run(&with_sequence_naming(&[utf8(".png")], None), true); + r.unwrap(); + let f = the_footage(&p); + assert_eq!(f.kind, FootageKind::Sequence); + assert_eq!(file_names(&f), ["0001.png", "0002.png", "0003.png"]); + assert!(f.path.ends_with("0001.png"), "{}", f.path); +} + +/// A single `Utf8` naming part is read as the suffix or the prefix depending on how the stored first file name matches it. +#[test] +fn a_single_naming_part_is_placed_by_the_stored_first_file_name() { + // `0001_v2.png` ..: the one `Utf8` is the suffix, as the first file name (`opti`) ends with it. + let bytes = with_first_file(&with_sequence_naming(&[utf8("_v2.png")], None), "0001_v2.png"); + let (p, r) = run(&bytes, true); + r.unwrap(); + let f = the_footage(&p); + assert_eq!(f.kind, FootageKind::Sequence); + assert_eq!(file_names(&f), ["0001_v2.png", "0002_v2.png", "0003_v2.png"]); + // `shot_0001` ..: a name without an extension; the one `Utf8` is the prefix. + let bytes = with_first_file(&with_sequence_naming(&[utf8("shot_")], None), "shot_0001"); + let (p, r) = run(&bytes, true); + r.unwrap(); + assert_eq!(file_names(&the_footage(&p)), ["shot_0001", "shot_0002", "shot_0003"]); + // Without that evidence (the stored first file is `shot_0001.png`), a part that is no extension + // is not guessed: the item is reported. + let bytes = with_sequence_naming(&[utf8("_v2.png")], None); + reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder"); +} + +/// A file list containing a name with a separator, drive colon, dot directory or control character is refused and reported. +#[test] +fn a_file_list_with_a_name_that_is_not_a_plain_file_name_is_reported() { + // The listed names are joined to the folder: a separator, a drive colon or a dot directory + // would point outside it, and a NUL or another control character is no file name, so such a + // list is not read. + for hostile in ["..\\..\\x.png", "../x.png", "C:x.png", "..", ".", "x\0.png", "x\n.png"] { + let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 2u32.to_le_bytes().to_vec()), utf8("a.png"), utf8(hostile)]); + let bytes = with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])); + reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder"); + } +} + +/// A file list with a missing or too short count chunk is refused and reported. +#[test] +fn a_file_list_without_a_readable_count_is_reported() { + // No `StVS`, or one too short for its `u32`: the list is not read rather than taken whole. + for count in [None, Some(vec![2u8, 0])] { + let mut kids: Vec> = count.map(|c| Chunk::data(*b"StVS", c)).into_iter().collect(); + kids.extend([utf8("a.png"), utf8("b.png")]); + let bytes = with_sequence_naming(&[Chunk::list(*b"StVc", kids)], Some([u32::MAX, u32::MAX, 0])); + reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder"); + } +} + +/// A force-alphabetical sequence imports its stored file list in the stored order. +#[test] +fn a_sequence_imported_with_force_alphabetical_order_imports_its_file_list() { + // Force alphabetical order: `LIST StVc` with `StVS` (the count, little-endian) and one `Utf8` + // per file instead of the prefix and suffix; the numbering in `sspc` is not set. Before, the + // item became a video linked to its folder. + let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 3u32.to_le_bytes().to_vec()), utf8("b_take.png"), utf8("a_take.png"), utf8("c_take.png")]); + let (p, r) = run(&with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])), true); + let r = r.unwrap(); + let f = the_footage(&p); + assert_eq!(f.kind, FootageKind::Sequence); + // The stored order is kept. + assert_eq!(file_names(&f), ["b_take.png", "a_take.png", "c_take.png"]); + assert!(f.path.ends_with("b_take.png"), "{}", f.path); + assert!(losses(&r, LossCategory::DamagedData).is_empty(), "{:?}", r.report.losses); + // The count bounds the list. + let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 2u32.to_le_bytes().to_vec()), utf8("x.png"), utf8("y.png"), utf8("z.png")]); + let (p, r) = run(&with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])), true); + r.unwrap(); + assert_eq!(file_names(&the_footage(&p)), ["x.png", "y.png"]); +} + +/// A sequence with no naming or an empty file list is reported and linked to its folder. +#[test] +fn a_sequence_whose_naming_cannot_be_read_is_reported() { + // No naming at all after the alias. Before, the item silently linked its folder. + let bytes = with_sequence_naming(&[], None); + reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder"); + // An empty file list is no naming either. + let empty = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 0u32.to_le_bytes().to_vec())]); + let bytes = with_sequence_naming(&[empty], Some([u32::MAX, u32::MAX, 0])); + reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder"); +} + #[test] fn a_solid_with_a_nan_channel_is_repaired_and_reported() { let nan = f32::NAN.to_be_bytes(); diff --git a/docs/aep-format.md b/docs/aep-format.md index d6ec7fb14..99a1199f0 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-05 · **Change:** 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-07 · **Change:** 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 @@ -279,7 +279,8 @@ Footage items beyond solids and stills (`Pin /sspc`, 222 bytes; audio and image | bits per pixel | 60 | u32 | RGBA still 32, RGB sequence 24, 0 for solids and audio | 126, 352 | V | | **sample rate** | 160 | f64 | 48 000.0 for the WAV; the same for an mp4 with sound (48 000.0 and 44 100.0 read back from AAC tracks of those rates), absent for an mp4 without (the importer takes a clip's audio from it) | 350, read-back vid-audio | V | | **file size** | 208 | u32 | bytes of the source file(s): png 149, WAV 96 044 (44 + 48 000 x 2), three png of 168 bytes 504 | 126, 350, 352 | V | -| **sequence numbering** | 172, 176, 180 | u32 x 3 | first and last frame number and the digits of a sequence: 1, 3, 3 for `seq_001.png`..`seq_003.png` (353), 101, 103, 4 for `shot_0101.exr`..`shot_0103.exr` (603); 0 for single files (604, 605, 607). The name around the number is the pair of `Utf8` chunks between `Als2` and `opti` in `Pin ` (`seq_` / `.png`, `shot_` / `.exr`), absent on single files. `Item::sequence_info`, `SequenceInfo::file_name` | 353, 603, 604, 605, 607 | V | +| **sequence numbering** | 172, 176, 180 | u32 x 3 | first and last frame number and the digits of a sequence: 1, 3, 3 for `seq_001.png`..`seq_003.png` (353), 101, 103, 4 for `shot_0101.exr`..`shot_0103.exr` (603); 0 for single files (604, 605, 607). The name around the number is the pair of `Utf8` chunks between `Als2` and `opti` in `Pin ` (`seq_` / `.png`, `shot_` / `.exr`), absent on single files. **An empty prefix is not written**: `0001.png`..`0005.png` leave one `Utf8` (`.png`) there, so a single `Utf8` is the prefix when the first file name in `opti` (byte 58: `0001.png`) starts with it and the suffix when it ends with it; without that evidence the reader takes one starting with `.` as the suffix and reads nothing else. `Item::sequence_info`, `SequenceInfo::file_name` | 353, 603, 604, 605, 607; the prefix-less sequence of issue 3174 (After Effects 25.2) | V | +| **sequence file list** (Force alphabetical order) | `Pin /LIST StVc` | `StVS` (4 bytes, `u32` LE), then `Utf8` x count | a sequence imported with Force alphabetical order has, between `Als2` and `opti`, a `LIST StVc` instead of the prefix and suffix `Utf8`s: `StVS` holds the file count as a **little-endian** `u32` (`05 00 00 00`), followed by one `Utf8` per file name in order (`shot_0001.png`..`shot_0005.png`). The numbering at @172 / @176 is then `0xFFFFFFFF` and the digits @180 0; duration and rate are stored as for a numbered sequence (5/30, 30). The list replaces the numbering; a list without a readable `StVS` count, or with a name that is not a plain file name (`/`, `\`, `:`, a control character such as NUL, `.`, `..`), is not read. Without a stored item name the importer names the item after its first listed file (an assumption: what After Effects shows was not sampled). `SequenceInfo::files`, `SequenceInfo::nth_file_name` | the alphabetical sequence of issue 3174 (After Effects 25.2) | V | | **sequence folder time** | 118 | u32 (unaligned) | seconds since 1904-01-01 UTC: the modification time of a sequence's folder when After Effects scanned it (860: 0xe6fb443a = 2026-10-19 03:51:22, the folder's and its files' time). With the folder's time After Effects keeps the stored numbering on open; with another (an older time, the first or the last file's time while the folder's differs: x1, x2 against x3) it scans the folder again and warns about a gap in a modal dialog | 860, `export-tests\sparse860\exp` x1-x3 | V | | still duration denominator | 42 | u32 | 1 for png (126, 607), 600 for jpeg (605), 100 for OpenEXR (604): a still's duration is 0 over a per-format denominator | 126, 604, 605, 607 | V | From 514e16c027cae577ee40bac5ef27790365775af6 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 8 Nov 2026 01:57:47 +0100 Subject: [PATCH 22/94] .aep import: effects without definitions borrow them from another instance (#3223) Refs #3170 (the Color Control part). Some files have no project-level effect definition list (EfdG); it was seen in 17 projects, all from inside After Effects-made Motion Graphics templates. In those, one instance per effect type keeps the full parameter definitions (parT) and every other instance of the type has an empty parT. The others were reported "without readable parameter definitions" and dropped (129 Color Controls of one template). The definition index now falls back to the parT of the first instance of the same match name that has its own (DefinitionSource::OtherInstance). Values still come from each instance's own records; a value record that borrowed definitions do not name is reported instead of vanishing. The "Color ... holds an unreadable value" report came from 0.21.1, which read only written records (tdsb 1). Reading default-state records (tdsb 3, #3126) probably fixes it, but that is not confirmed for Color Control; a synthetic test now pins a default-state colour record. --- crates/aep/src/container/effects.rs | 65 +++++- crates/aep/src/import/effects.rs | 16 ++ crates/aep/tests/it/container/robustness.rs | 6 +- crates/aep/tests/it/import/robustness.rs | 12 +- crates/aep/tests/it/import/synthetic.rs | 237 ++++++++++++++++++++ docs/aep-format.md | 2 +- 6 files changed, 322 insertions(+), 16 deletions(-) diff --git a/crates/aep/src/container/effects.rs b/crates/aep/src/container/effects.rs index e336d84e5..2fa77d017 100644 --- a/crates/aep/src/container/effects.rs +++ b/crates/aep/src/container/effects.rs @@ -12,6 +12,11 @@ //! project-level `LIST EfdG` (one `EfDf` per effect type: a `tdmn` with the match name, then a copy //! of one instance's `sspc`), see [`Project::effect_params`]. That copy also carries the values of //! the instance it was copied from, so values are only ever read from the instance's own `tdgp`. +//! +//! Some files have no `EfdG` at all (seen in 17 projects, all from inside After Effects-made +//! Motion Graphics templates). In those, one instance per effect type kept its full `parT` (which +//! one is not known) and it is the only place the definitions are written, so the other instances +//! of that type borrow them from it (#3170). use std::collections::{HashMap, HashSet}; @@ -138,7 +143,11 @@ pub enum DefinitionSource { /// The project-level `EfdG/EfDf` copy for the effect's match name (the instance's own `parT` /// is empty, 383). Project, - /// Neither has any: the effect has no readable parameter definitions. + /// The own `parT` of another instance of the same match name in the file: the instance's + /// own `parT` is empty and the file has no project-level copy of the type (a project without + /// `EfdG`, #3170). + OtherInstance, + /// None of these has any: the effect has no readable parameter definitions. Missing, } @@ -155,7 +164,8 @@ pub struct EffectParam<'f, 'a> { pub name: String, /// The popup items (`pdnm`, split at `|`; for a button its label, e.g. `Swap Colors`). pub items: Option>, - /// True when the definition is the project-level copy (see [`DefinitionSource::Project`]). + /// True when the definition is borrowed: the project-level copy ([`DefinitionSource::Project`]) + /// or another instance's ([`DefinitionSource::OtherInstance`]). pub borrowed_definition: bool, pard: &'f [u8], stored: Option>, @@ -214,6 +224,22 @@ impl<'f, 'a> Effect<'f, 'a> { self.chunk.child_list(b"parT").is_some_and(has_pard) } + /// The match names of the value records in the instance's own `tdgp` (properties only, not + /// groups), in file order, each once. + pub fn value_record_names(&self) -> Vec { + let mut seen: HashSet = HashSet::new(); + self.chunk + .child_list(b"tdgp") + .and_then(PropGroup::from_chunk) + .map(|g| g.entries()) + .unwrap_or_default() + .into_iter() + .filter(|e| matches!(e.node, PropNode::Property(_))) + .map(|e| e.match_name) + .filter(|n| seen.insert(n.clone())) + .collect() + } + /// The parameters in the order of the instance's own `parT`. Empty for an instance whose /// `parT` is empty; [`Project::effect_params`] falls back to the project's definitions. pub fn params(&self) -> Vec> { @@ -299,8 +325,25 @@ impl<'f, 'a> Project<'f, 'a> { /// [`Self::effect_definitions`] indexed by match name, for looking up many effects: build it /// once per file, not once per effect (a file can hold many definitions and many instances). + /// For a type without a project-level copy it also keeps the own `parT` of the first instance + /// in the file (items, layers and effects in file order) that has one. pub fn effect_definition_index(&self) -> EffectDefinitions<'f, 'a> { - EffectDefinitions { by_name: self.effect_definitions().into_iter().collect() } + let by_name: HashMap> = self.effect_definitions().into_iter().collect(); + let mut instances: HashMap> = HashMap::new(); + for item in self.all_items() { + let Some(comp) = item.comp() else { continue }; + for layer in comp.layers() { + for fx in layer.effects() { + if by_name.contains_key(&fx.match_name) || instances.contains_key(&fx.match_name) { + continue; + } + if let Some(part) = fx.chunk.child_list(b"parT").filter(|p| has_pard(p)) { + instances.insert(fx.match_name, part); + } + } + } + } + EffectDefinitions { by_name, instances } } /// Where the parameter definitions of `effect` come from. Builds the index each call: use @@ -316,10 +359,13 @@ impl<'f, 'a> Project<'f, 'a> { } } -/// The project-level effect definitions by match name ([`Project::effect_definition_index`]). +/// The borrowable effect definitions by match name ([`Project::effect_definition_index`]). #[derive(Debug, Clone, Default)] pub struct EffectDefinitions<'f, 'a> { + /// The project-level copies (`EfdG`). by_name: HashMap>, + /// For a type without a project-level copy: the own `parT` of the first instance that has one. + instances: HashMap>, } impl<'f, 'a> EffectDefinitions<'f, 'a> { @@ -329,20 +375,23 @@ impl<'f, 'a> EffectDefinitions<'f, 'a> { DefinitionSource::Instance } else if self.by_name.contains_key(effect.match_name()) { DefinitionSource::Project + } else if self.instances.contains_key(effect.match_name()) { + DefinitionSource::OtherInstance } else { DefinitionSource::Missing } } /// The parameters of `effect`: from its own `parT`, or, when that is empty, from the - /// project-level definition of its match name ([`EffectParam::borrowed_definition`]). Values - /// always come from the instance's own records (absent = the `pard` default); the project - /// copy's values belong to another instance and are never read. + /// project-level definition of its match name, else from another instance of it that has + /// its own ([`EffectParam::borrowed_definition`]). Values always come from the instance's own + /// records (absent = the `pard` default); the values next to borrowed definitions belong to + /// another instance and are never read. pub fn params(&self, effect: &Effect<'f, 'a>) -> Vec> { if effect.has_own_definitions() { return effect.params(); } - match self.by_name.get(effect.match_name()) { + match self.by_name.get(effect.match_name()).or_else(|| self.instances.get(effect.match_name())) { Some(part) => effect.params_from(part, true), None => Vec::new(), } diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 8409a6f7e..b097c3600 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -99,6 +99,22 @@ pub(crate) fn import_effects<'f, 'a>( 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() + ), + ); + } + } if map.extra == Extra::Cryptomatte { import_cryptomatte(&fx, ¶ms, cx, &shown, &mut group, col); } else { diff --git a/crates/aep/tests/it/container/robustness.rs b/crates/aep/tests/it/container/robustness.rs index 27622d887..5c3422a10 100644 --- a/crates/aep/tests/it/container/robustness.rs +++ b/crates/aep/tests/it/container/robustness.rs @@ -237,9 +237,11 @@ fn exercise_layer(project: &Project<'_, '_>, comp_size: (u32, u32), layer: &Laye if let Some(root) = layer.properties() { exercise_group(&root, 0); } + // One index for the layer's effects: building it walks every layer of the file. + let defs = project.effect_definition_index(); for e in layer.effects() { - let _ = (e.match_name().len(), e.name(), e.declared_param_count(), e.has_own_definitions(), project.definition_source(&e)); - for p in e.params().into_iter().chain(project.effect_params(&e)) { + let _ = (e.match_name().len(), e.name(), e.declared_param_count(), e.has_own_definitions(), defs.source(&e)); + for p in e.params().into_iter().chain(defs.params(&e)) { let _ = (p.index, p.kind, p.name.len(), p.items.as_ref().map(Vec::len), p.pard().len(), p.value(), p.stored_value(), p.pard_default()); let _ = (p.is_default(), p.popup_item_count(), p.popup_selected_index(), p.popup_selected_item(), p.valid_max(), p.is_animated()); let _ = (p.valid_min(), p.slider_range()); diff --git a/crates/aep/tests/it/import/robustness.rs b/crates/aep/tests/it/import/robustness.rs index 1a42665bc..902c9cc4b 100644 --- a/crates/aep/tests/it/import/robustness.rs +++ b/crates/aep/tests/it/import/robustness.rs @@ -626,9 +626,10 @@ fn bogus_effect_structure_is_reported_not_imported_blindly() { "{r:?}" ); - // No project-level definitions: a Ramp with an empty parT cannot be read and is reported; one - // with its own parT still arrives. (In the single-case file one of the two had its own; in - // the combined file an earlier case holds the definitions, so both take the project copy.) + // No project-level definitions: a Ramp with an empty parT borrows the definitions of a Ramp + // in the file that has its own parT (#3170). In the single-case file one of the two has its + // own, so both arrive; in the case cut from the combined file an earlier case (not kept) + // held them, so neither has readable definitions and both are reported. let own = { let bytes = skip!(crate::common::try_fixture("383-fx-ramp-on-100x100-start-10-20.aep")); let file = parse(&bytes).unwrap(); @@ -646,8 +647,9 @@ fn bogus_effect_structure_is_reported_not_imported_blindly() { } })); let r = report_of(&bytes); - assert_eq!(r.imported.effects, own, "{r:?}"); - assert_eq!(r.count(effectcraft_aep::import::LossCategory::Effects), 2 - own, "{r:?}"); + let arrive = if own > 0 { 2 } else { 0 }; + assert_eq!(r.imported.effects, arrive, "{r:?}"); + assert_eq!(r.count(effectcraft_aep::import::LossCategory::Effects), 2 - arrive, "{r:?}"); assert!(try_all(&bytes)); // 368, the first Gradient Ramp of its file, has its own parT: it still arrives. let bytes = skip!(mutate("368-fx-ramp-default.aep", &mut |c| { if let Body::List { list_type, .. } = &mut c.body diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 5d4d89c56..a023bb162 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -553,6 +553,186 @@ fn default_state_effect_records_import_like_written_ones() { assert_eq!(clip, vec![("clipToOutputBlack".to_string(), effectcraft_keyframe::Value::Enum(1))]); } +/// Sets the `cdat` of the value record `param` in the value group `tdgp` (the first `f64`), and +/// its state to `tdsb` when given. True when the group has that record. +fn patch_record(tdgp: &mut Chunk<'_>, param: &str, tdsb: Option, value: f64) -> bool { + let kids = tdgp.children_mut(); + let Some(at) = kids.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 false; + }; + let Some(record) = kids.get_mut(at + 1).filter(|k| k.is_list(b"tdbs")) else { return false }; + for k in record.children_mut() { + if k.is(b"tdsb") + && let Some(state) = tdsb + { + set_data(k, 0, &state.to_be_bytes()); + } else if k.is(b"cdat") { + set_data(k, 0, &value.to_be_bytes()); + } + } + true +} + +/// The project without its project-level effect definitions (`EfdG`; some files have none, seen +/// in the projects inside After Effects-made Motion Graphics templates) and with a second Glow +/// just above the first on the layer: a copy of it whose `parT` is empty, as such files write +/// every instance of a type but one. `edit_copy` is applied to every chunk of the copy; with +/// `empty_original` the original's `parT` is emptied too. +fn second_glow_without_definitions(edit_copy: &mut dyn FnMut(&mut Chunk<'_>), empty_original: bool) -> Vec { + fn empty_part(fx: &mut Chunk<'_>) { + for k in fx.children_mut() { + if let Body::List { list_type, children, .. } = &mut k.body + && list_type == b"parT" + { + children.clear(); + } + } + } + let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap(); + let mut added = 0; + visit(&mut file.chunks, &mut |c| { + if let Body::List { list_type, children, .. } = &mut c.body { + if list_type == b"EfdG" { + *list_type = *b"XfdG"; + return; + } + if list_type != b"tdgp" { + 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"ADBE Glo2"))) else { + return; + }; + let (Some(name), Some(mut fx)) = (children.get(at).cloned(), children.get(at + 1).filter(|k| k.is_list(b"sspc")).cloned()) else { return }; + empty_part(&mut fx); + edit_copy(&mut fx); + visit(fx.children_mut(), edit_copy); + if empty_original && let Some(original) = children.get_mut(at + 1) { + empty_part(original); + } + children.insert(at, fx); + children.insert(at, name); + added += 1; + } + }); + assert_eq!(added, 1, "one Glow on the layer"); + write(&file).unwrap() +} + +/// The imported Glows in stack order, each as its property values by property id. +fn glow_values(p: &Project) -> Vec> { + let glow = effectcraft_project::GroupKind::Effect { effect: "ec.stylize.glow".to_string() }; + let mut glows = Vec::new(); + for item in p.items.values() { + if let ItemKind::Comp(c) = &item.kind { + for layer in &c.layers { + for node in layer.effects().map(|fx| fx.children.as_slice()).unwrap_or_default() { + if let effectcraft_project::Node::Group(g) = node + && g.kind == glow + { + let mut values = Vec::new(); + g.walk("", &mut |_, prop| values.push((prop.match_id.clone(), prop.value.clone()))); + glows.push(values); + } + } + } + } + } + glows +} + +/// The value of property `id` in `values`. +fn value_of<'v>(values: Option<&'v Vec<(String, effectcraft_keyframe::Value)>>, id: &str) -> Option<&'v effectcraft_keyframe::Value> { + values?.iter().find(|(i, _)| i == id).map(|(_, v)| v) +} + +/// Every instance of an effect type but one has an empty `parT`; without a project-level copy the +/// others borrow the definitions of that one (#3170: 129 Color Controls of a template were +/// reported "without readable parameter definitions" and dropped). +#[test] +fn an_instance_without_definitions_imports_with_those_of_another_instance() { + // Both Glows import; before, the copy was reported and not imported. + let (p, r) = run(&second_glow_without_definitions(&mut |_| {}, false), true); + let r = r.unwrap(); + let found = losses(&r, LossCategory::Effects); + assert!(!found.iter().any(|d| d.contains("without readable parameter definitions") || d.contains("no parameter definition")), "{found:?}"); + assert_eq!(r.report.imported.effects, 3, "Levels and both Glows: {:?}", r.report.losses); + let glows = glow_values(&p); + assert_eq!(glows.len(), 2); + assert_eq!(glows.first(), glows.get(1), "the copy has the same records, so the same values"); + + // The values come from each instance's own records: the copy's A & B Midpoint (`-0011`) set + // to 0.75 arrives as 75 % in the copy only; the Glow it borrows from keeps its own. + let mut patched = 0; + let bytes = second_glow_without_definitions( + &mut |c| { + if c.is_list(b"tdgp") && patch_record(c, "ADBE Glo2-0011", None, 0.75) { + patched += 1; + } + }, + false, + ); + assert_eq!(patched, 1, "the copy's record"); + let (p, r) = run(&bytes, true); + r.unwrap(); + let glows = glow_values(&p); + let seventy_five = effectcraft_keyframe::Value::Scalar(75.0); + assert_eq!(value_of(glows.first(), "abMidpoint"), Some(&seventy_five)); + let own = value_of(glows.get(1), "abMidpoint"); + assert!(own.is_some() && own != Some(&seventy_five), "{own:?}"); +} + +/// A value record that the borrowed definitions do not name is reported, not dropped silently. +#[test] +fn a_value_record_the_borrowed_definitions_do_not_name_is_reported() { + let bytes = second_glow_without_definitions( + &mut |c| { + if c.is(b"tdmn") && c.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2-0011")) { + set_data(c, 0, b"ADBE Glo2-0099\0"); + } + }, + false, + ); + let (_, r) = run(&bytes, true); + let r = r.unwrap(); + let found = losses(&r, LossCategory::Effects); + assert!(found.iter().any(|d| d.contains("ADBE Glo2-0099") && d.contains("no parameter definition")), "{found:?}"); + assert_eq!(r.report.imported.effects, 3, "{:?}", r.report.losses); +} + +/// When no instance of the type keeps its definitions, every instance is reported. +#[test] +fn instances_without_any_definitions_are_reported() { + let (p, r) = run(&second_glow_without_definitions(&mut |_| {}, true), true); + let r = r.unwrap(); + let found = losses(&r, LossCategory::Effects); + assert_eq!(found.iter().filter(|d| d.contains("ADBE Glo2") && d.contains("without readable parameter definitions, not imported")).count(), 2, "{found:?}"); + assert_eq!(r.report.imported.effects, 1, "Levels only: {:?}", r.report.losses); + assert!(glow_values(&p).is_empty()); +} + +/// The reader's side: the copy's definitions come from the other instance. +#[test] +fn an_instance_without_definitions_reads_those_of_another_instance() { + let bytes = second_glow_without_definitions(&mut |_| {}, false); + let file = parse(&bytes).unwrap(); + let project = effectcraft_aep::Project::new(&file).unwrap(); + let defs = project.effect_definition_index(); + let mut sources = Vec::new(); + for item in project.all_items() { + let Some(comp) = item.comp() else { continue }; + for layer in comp.layers() { + for fx in layer.effects().iter().filter(|fx| fx.match_name() == "ADBE Glo2") { + sources.push(defs.source(fx)); + let params = defs.params(fx); + assert_eq!(params.len(), 16, "Glow's parameters"); + assert_eq!(params.iter().all(|p| p.borrowed_definition), defs.source(fx) == effectcraft_aep::DefinitionSource::OtherInstance); + } + } + } + // The copy is above the instance it borrows from (earlier in the stack). + assert_eq!(sources, [effectcraft_aep::DefinitionSource::OtherInstance, effectcraft_aep::DefinitionSource::Instance]); +} + /// A record in a state other than 1 or 3 is not read and is reported. #[test] fn an_effect_record_in_another_state_is_reported_as_damaged() { @@ -564,3 +744,60 @@ fn an_effect_record_in_another_state_is_reported_as_damaged() { assert!(damaged.iter().any(|l| l.contains("unreadable value")), "tdsb {tdsb}: {:?}", r.report.losses); } } + +/// The project with a value record for Glow's Color A (`ADBE Glo2-0012`, which the project does +/// not store) in state `tdsb` holding the ARGB doubles `argb` (0..255): a copy of the A & B +/// Midpoint record (`-0011`) made a 4-component colour without its expression. +fn with_color_a(tdsb: u32, argb: [f64; 4]) -> Vec { + let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap(); + let mut added = 0; + visit(&mut file.chunks, &mut |c| { + let Body::List { list_type, children, .. } = &mut c.body else { return }; + if list_type != b"tdgp" { + 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"ADBE Glo2-0011"))) else { + return; + }; + let (Some(mut name), Some(mut record)) = (children.get(at).cloned(), children.get(at + 1).filter(|k| k.is_list(b"tdbs")).cloned()) else { return }; + set_data(&mut name, 0, b"ADBE Glo2-0012\0"); + if let Body::List { children: kids, .. } = &mut record.body { + kids.retain(|k| !k.is(b"Utf8")); + for k in kids.iter_mut() { + if k.is(b"tdsb") { + set_data(k, 0, &tdsb.to_be_bytes()); + } else if k.is(b"tdb4") { + // 4 components; no expression (bytes 76, 77 and 120). + set_data(k, 3, &[4]); + for byte in [76, 77, 120] { + set_data(k, byte, &[0]); + } + } else if k.is(b"cdat") { + *k = Chunk::data(*b"cdat", argb.iter().flat_map(|v| v.to_be_bytes()).collect::>()); + } + } + } + children.insert(at + 2, record); + children.insert(at + 2, name); + added += 1; + }); + assert_eq!(added, 2, "the instance and the EfdG copy"); + write(&file).unwrap() +} + +/// A colour recorded in the default state (`tdsb` 3) reads like a written one (#3170's "parameter +/// Color ... holds an unreadable value" on 0.21.1, which read only written records). +#[test] +fn a_default_state_colour_record_holds_the_value() { + let argb = [255.0, 51.0, 153.0, 255.0]; + let (written, r1) = run(&with_color_a(1, argb), true); + let (default_state, r3) = run(&with_color_a(3, argb), true); + let (r1, r3) = (r1.unwrap(), r3.unwrap()); + assert!(losses(&r3, LossCategory::DamagedData).is_empty(), "{:?}", r3.report.losses); + assert_eq!(r3.report.losses, r1.report.losses); + let colour = |p: &Project| value_of(glow_values(p).first(), "colorA").cloned(); + let (unchanged, _) = run(SYNTHETIC, true); + assert!(colour(&written).is_some()); + assert_eq!(colour(&default_state), colour(&written)); + assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default"); +} diff --git a/docs/aep-format.md b/docs/aep-format.md index 99a1199f0..d6eff25f8 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -461,7 +461,7 @@ The expression engine is project-wide (`LIST ExEn/Utf8`, `javascript-1.0`). API: | **default** | `pard` | 56 | by type | float slider: f64 (Slider 0, **Blurriness 25**); fixed slider and angle: 16.16 (drop shadow opacity 127.5, direction 135, distance 5, softness 0; Fill opacity 1.0 = 100 %; **Tint Amount to Tint 100, Ramp Scatter 0, Blend With Original 0**); **colour: ARGB bytes (drop shadow `ff000000` black, Fill `ffff0000` red; Ramp Start `ff000000`, End `ffffffff`; Tint Map Black To `00000000` and Map White To `00ffffff`: alpha 0, which is what After Effects reads back)**; popup: u32 index (1); **point: two 16.16 fractions of the layer size at 56 and 60 (Point Control 0.5, 0.5; Ramp Start 0.5, 0 and End 0.5, 1)** | 071, 077, 080, 214, 215, 356, 368, 383 | V | | **valid maximum** | `pard` | 128 / 108 | 16.16 / f32 | the largest valid value of a fixed slider (128: Tint Amount 100, Ramp Scatter 512, Blend With Original **1.0**) and of a float slider (108: Blurriness 30 000; **Exposure 100, Offset 2, Gamma Correction 10**). The stored scale differs per slider (below), so this is the reader's only hint at it | 356, 368, 078, 389 | V | | **float slider valid minimum, slider range** | `pard` | 104; 112 / 116 | f32; f32 x 2 | the smallest valid value (Exposure -100, Offset -2, Gamma Correction 0.1 as f32) and the range the slider covers (Exposure -4..4, Offset -0.5..0.5, Gamma 0.1..10); the slider range is repeated as `tdum` / `tduM` (f64) in each value record (391-394). @120 holds the default again as f32 (Gamma 1.0, Blurriness 25), u16 @124 the display decimals (**H**: Exposure 2, Offset 4) | 389, 391-394 | V | -| **definitions of a second instance** | top-level `LIST EfdG` | | | one `EfDf` per effect TYPE (`tdmn` = match name, then a copy of ONE instance's `sspc`, including that instance's value records); the `EfDC` count before them is a little-endian u32. When two layers carry the same effect type, only one instance keeps a full `parT`; **the other has an empty `LIST parT` (4 bytes)** but its own `tdgp` (input layer `-0000` with its own `tdpi`, the button record, and its value records). Its definitions come from the `EfdG` copy by match name, its values from its own records, never from the copy (383: the copy holds S100's Start [0.1, 0.2]; L's Ramp reads the 368 defaults). Which instance keeps the full `parT` is **?** (in 383 S100 is both first in the file and newest). The `EfDf` order follows the stack order (365 / 366); `Pefl/pjef` does not | 365, 366, 383 | V | +| **definitions of a second instance** | top-level `LIST EfdG` | | | one `EfDf` per effect TYPE (`tdmn` = match name, then a copy of ONE instance's `sspc`, including that instance's value records); the `EfDC` count before them is a little-endian u32. When two layers carry the same effect type, only one instance keeps a full `parT`; **the other has an empty `LIST parT` (4 bytes)** but its own `tdgp` (input layer `-0000` with its own `tdpi`, the button record, and its value records). Its definitions come from the `EfdG` copy by match name, its values from its own records, never from the copy (383: the copy holds S100's Start [0.1, 0.2]; L's Ramp reads the 368 defaults). Which instance keeps the full `parT` is **?** (in 383 S100 is both first in the file and newest). The `EfDf` order follows the stack order (365 / 366); `Pefl/pjef` does not. **Some files have no `EfdG` at all** (seen in 17 projects, all from inside After Effects-made Motion Graphics templates). In each of them every effect type (196 in all; 2 to 14 Color Controls per file) has exactly one instance with a full `parT` and an empty one in every other instance, and the reader takes the others' definitions from that instance (`DefinitionSource::OtherInstance`, #3170). Which instance keeps it is **?** here too | 365, 366, 383; templates | V | | popup items | `parT/pdnm/Utf8` | | text | items separated by `\|` | 077 (`Item 1\|Item 2\|Item 3`), 214 | V | | stored value | `sspc/tdgp/-NNNN` | | `tdbs` | present only for a parameter that differs from its default; `tdsb` 1, `cdat` as in 3.4. **Absent record = the `pard` default.** Some parameters are recorded in the default state (`tdsb` 3) and the record still holds the value After Effects uses: every one of the 25 such records in the corpus and the samples differs from its `pard` default and reads back as recorded (Lumetri Color's hidden controls High Dynamic Range, HDR Specular, the second Look, Legacy Tone And Saturation, Face Detection, Auto Color, Auto Intensity and SemanticAutoTone Progress, 1200; Cryptomatte's Selection Mode on, 1400). No effect record in the default state holds a value that does not count. The reader takes the value in either state (`EffectParam::recorded_value`, `EffectParam::value`); `EffectParam::stored_value` keeps to written records, and a record in any other state (no sample has one) is not read: the importer reports it as damaged data. That other effects record values so is H (no sample shows one) | see below; 1200, 1400 | V (1200, 1400), H (other effects) | From 84382487bbeaef3d45dde3e1c76a91597132ac57 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 8 Nov 2026 02:30:59 +0100 Subject: [PATCH 23/94] Disk cache: entries keep the files they read, keys from memory (#3184) (#3191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Disk cache: record the source files each frame reads Part of #3184 (step 1): renders now report which footage files they read, each with its identity (size and modification time) as read. Nothing uses the list yet: images, cache keys and cache behaviour are unchanged. - render: `reads` module with `FileStamp`, `ReadFile`, `ReadFiles` (sorted, deduplicated, bounded; marked incomplete when it can't vouch for every file) and the thread-safe `ReadLog`. `Renderer::reads` collects a frame's files; `FootageSource::recording` lets a source note what it reads. `file_stamp` uses the same identity. - Layer cache entries keep the files their buffer was made of and report them on every hit; derived entries (styles, blend space, flattened) keep their base's. Buffers from the disk cache or the browser store don't know their files yet and mark the list incomplete. - Media pool: every file read notes its identity, taken from the file it opens. Decoded frames, opened movies, GIFs, models and EXR channels keep it with the cache entry, so a frame served from memory reports its file without touching the disk. - Viewer frame worker collects each frame's list and carries it to where the disk entry is written (step 2 stores it). Co-Authored-By: Claude Opus 5.5 * Automation: confined footage passes on the files it reads Part of #3184: ConfinedFootage forwards FootageSource::recording to the source it wraps, so renders of an automation session list their files instead of coming back incomplete. The read tests also render through the viewer's CancellableFootage wrapper. Co-Authored-By: Claude Opus 5.5 * Disk cache: frame entries keep their source files, comp key from memory Part of #3184 (step 2). Fixes #3175: opening a comp no longer stamps every footage file of the project before its first frame can show. - The comp's content key (`disk_cache::comp_content_key`) is taken from the project in memory alone: no footage or proxy file is stamped, no `VirtualFileId` is asked. `comp_content_key_with` is gone; `frame_key` hashes a new entry version, so entries of the earlier format are never looked up again (LRU evicts them). - Frame entries (formats 2 and 3) store the files the frame read (`EntryFiles`: size and modification time as read; size and content hash through `VirtualFileId` for files from memory; missing files), then the pixels. Formats 0 and 1 read as misses and are deleted. - Check on hit: `get_frame`/`get_frame_f16` look only at the listed files (one metadata call each) on the frame worker. A changed, back or unreadable file, or a damaged entry, is a miss: the entry is deleted and the frame rendered and stored again. - A frame whose file list is incomplete is shown but not stored. The RAM preview keeps each frame's files, so `persist_missing` writes only frames with a complete list. - Remote frame worker: `FrameServer` records each frame's reads (`take_files`); the web worker stores them with the entry and the page checks them through the file table before using it. The browser's media epoch existed only for the old key and is removed. Export does not use frame entries. - The cache indicator still only asks whether an entry exists. Tests: entry format round trip and damage, the check per file, and in the viewer: a changed file re-renders exactly its frames (and a reopen reads the rest from disk); a frame checks only its own files in a project with a large unused sequence and the key looks at no file; missing, unreadable files and damaged entries never serve a stale frame. Co-Authored-By: Claude Opus 5.5 * Disk cache: layer entries keep their source files, no footage salt Part of #3184 (step 3 of 4). Layer buffers on disk (and in the browser's layer store) now carry the files they were made of, like frame entries since step 2, and are checked against exactly those files when used. The project-wide footage salt is gone: nothing stamps every footage file any more. - Layer entry format 4: the buffer's EntryFiles, then the pixels. A hit checks only the listed files (metadata, or the VirtualFileId hook for files from memory; Missing must still be missing); a change, a damaged entry or the old format 0 is a miss and the entry is deleted, so the re-rendered buffer replaces it. A hit reports its file list, so frames made of buffers from disk are complete and get stored again. - Buffers whose files can't all be named are not written. - Layer disk keys: layer_disk_key = hash of the layer entry version, the renderer version and the layer cache key (which names the footage items with their paths and settings, from memory). - PrefetchStore checks on use; an entry made of changed files is dropped and forgotten, so the new buffer is queued over it. FrameServer passes its virtual-file hook to its layer cache. - Removed: footage_salt(_with), file_stamp, footage_stamp, set_disk_salt, set_store_salt, Session::refresh_disk_salt, poll_disk_salt, footage_fingerprint, disk_salt, disk_salt_background, disk_salt_job, DiskSaltJob, the UI's per-frame poll, the salt in FrameServer and in the availability worker (with its cache_enabled plumbing). The disk cache is attached as soon as it is configured. - Docs: architecture.md, web.md. Tests: layer entries check their files (size, time, missing/back, folder, damaged, old format, incomplete); virtual files; prefetch store; media tests with a real pool across restarts (only the changed file is read, projects with the same item ids never share an entry, missing, unreadable and damaged never serve stale buffers); engine tests that the cache is attached at once and edits do no file work (#1926, #3106). Co-Authored-By: Claude Opus 5.5 * Disk cache: no-salt worker test, sequence paths in the frame-blend key Part of #3184 (step 3 of 4). - A frame-blended footage frame's layer-cache key now hashes the sequence's file list: with no project-wide salt, two projects whose items share an id and a first frame must not share its disk entry. - Test: a frame worker syncing, patching and rendering a project with an unused 5000-frame sequence asks its file hook only about the file its buffer reads (the salt asked about every file at every sync). Co-Authored-By: Claude Opus 5.5 * Disk cache: the comp key covers what its expressions can read With an expression anywhere in the project, every comp's disk key hashed every item of the project, so any edit (another comp, an unused footage item) threw away every comp's frames on disk. The key now covers what the comp can draw plus what the expressions among it can reach (Project::pixel_reach): - without expressions: the items the comp draws, without silent layers, plus the footage its precomp layers swap in (Media Replacement); - with expressions: every layer of the comps they reach, their markers, the comps and footage they name with string literals (comp("Name"), footage("Name")) together with which items have those names now, and the footage named like a reached source (its data is read by name); - the whole project when an expression computes a name, uses the object model's constructors or internals, dynamic code, computed keys or writes, or text the scanner can't read for sure (regex literals, template strings, escapes): expression_reach errs towards reaching more. Markers count only where an expression can read them (#1083). Memory only, no file is looked at. Tests: scanner and reach unit tests in the project crate; engine tests for the key (own-comp expressions keep the key through edits elsewhere, comp("Other") follows Other and not a third comp, a second comp taking the name counts, a computed name counts every item, markers read from another comp); a ui-egui test that the disk frames outside an expression layer are carried when the comp it names changes. Part of #3184. Part of #1492. Co-Authored-By: Claude Opus 5.5 * Disk cache: Reload Footage drops the entries of its files, docs File > Reload Footage now deletes the frame and layer entries whose file lists name the reloaded item's files (DiskCache::drop_files), so a file replaced by one with the same size and modification time, which the check on hit can't tell apart, is read again. The index knows keys, not files: the writer thread reads the files section at the start of each frame and layer entry (not the pixels) after the writes queued before it, and deletes the entries that list one of the files. Until it is done, an entry made of one of them is a miss and frames aren't linked to new keys. The entries are gone for good, so they don't come back after a restart. Automatic reloads don't drop anything: they follow a changed stamp, which the check on hit catches. The browser needs nothing (its file identities are content hashes). Removed: disk_cache::seal, which steps 2 and 3 left without callers (the tests use seal_format). Updated the module, seal_format and forget_media docs, and docs/architecture.md / docs/web.md describe the final design: what is cached, keys from memory, file lists checked on hit, nothing scans the project, incomplete lists stay in memory, Reload Footage drops entries, edits keep the frames they can't change. Tests: render unit tests (same size and time passes the check but not the drop; other files' entries stay; an entry queued before the drop is dropped; a layer entry too; written again afterwards it is used; the drop survives a restart; a pending drop is a miss), and an engine test through file.reloadFootage. Closes #3184. Part of #1492. Co-Authored-By: Claude Opus 5.5 * Disk cache: file stamps keep the whole modification time A file's modification time was nanoseconds since 1970 as u64: a time before 1970 read as "no time" (two such files of one size looked alike) and times after 2554 were truncated. It is now i128 nanoseconds, negative before the epoch; None only means the file system has no time. The content-key hash writes a presence tag and the full value. Co-Authored-By: Claude Opus 5.5 * Automation: a refused footage read leaves the render's file list incomplete Footage outside the read roots shows nothing and was noted as no file at all, so a picture made without it had a complete, empty list and could be kept on disk for a later session with other roots. A refused read during `recording` now marks the list incomplete. Co-Authored-By: Claude Opus 5.5 * Render: a buffer kept by another worker comes with its own files LayerCache::insert returns the files of the buffer it keeps, and Renderer::keep notes them in the render's log and returns them, so a render that uses another worker's buffer lists that buffer's files (a file changed between the two reads then has two identities and the list is incomplete). Co-Authored-By: Claude Opus 5.5 * Render: sampleImage and text fonts note the files they read EvalCtx carries the render's read log. A sampleImage render records its footage reads, keeps them with its per-thread cached buffer and notes them in the evaluating render's log on every use. Text notes the font files it is drawn with, with the identity their bytes were read with; a family that isn't installed, a fallback or missing glyph, or a font from memory leaves the list incomplete. Co-Authored-By: Claude Opus 5.5 * Disk cache: frame entries store the whole modification time A frame entry's file list stores a file's modification time as i128 nanoseconds since 1970 (negative before), as the file stamps now keep it; the frame entry format version goes to frame-entry-files-2 so entries in the old layout are not read. Co-Authored-By: Claude Opus 5.5 * Disk cache: layer entries store the whole modification time Layer entries share the frame entries' file list layout, which now stores the full i128 modification time: the layer entry format version goes to layer-entry-files-2. The module doc names the font files text is drawn with among an entry's source files, and what keeps a list incomplete. Co-Authored-By: Claude Opus 5.5 * Layer cache: buffers that read other layers are keyed by the switches passed down A layer whose effect reads another layer (Gradient Wipe, Set Matte, Blend…) gets that layer drawn by its own renderer. Through a precomp layer, Switches Affect Nested Comps, the switches inherited from the precomp layers above and motion blur on change those pixels, but the key held only the layers' own switches: a buffer made with the setting on was reused (from memory or the disk cache) with it off. Such layers now derive their key with what the renderer passes down. Co-Authored-By: Claude Opus 5.5 * Layer cache: masked buffers are keyed by the mask motion blur gate A mask with Mask Motion Blur On blurs even when the layer's own switch is off, as long as the render's gate is open (Render Settings motion blur, and with Switches Affect Nested Comps the precomp layers above). The key held the layer's own motion blur state only, so a buffer blurred with the gate open was reused with it closed (and the other way round), also from the disk cache. Masked layer, input and footprint keys now tell a closed gate apart. Co-Authored-By: Claude Opus 5.5 * Render: cached buffers list what their expressions and substituted characters read - Buffers made under `Renderer::recorded` (layer content, a Time effect's input, an adjustment layer's footprint) evaluate their masks, shapes and effects with the context that notes into the buffer's own read log, so a file an expression reads (`sampleImage`, also at shutter times mask motion blur adds and the cache key doesn't see) is in the buffer's files. - A styled layer served from the cache lists the files of the cached body and passes too, so the buffers made of it (flattened, blending space) don't get a complete list when one of them came with an incomplete one. - A character put in by Character Offset or Character Value without a relayout notes the font it is drawn with; a fallback face (or none) leaves the list incomplete. Co-Authored-By: Claude Opus 5.5 * Render: text lists the font file of a run that draws no glyph; tests clean their temp dirs An empty first paragraph draws no glyph, but in a box its face sets the first baseline that places every later line. The read list took faces only from glyphs, so it could be complete without that file. Note each style run's resolved face as well. The font-reads and confine tests use tempfile::tempdir(): a fresh directory per run, removed on panic too. Co-Authored-By: Claude Opus 5.5 * Disk cache: Reload Footage closes the gaps around its drop (#3184) - A reload while the disk cache is off is kept on the session (pending_disk_drops) and applied when the cache is switched on, before anything is read from it. - Each render takes the cache's drop generation before it reads (frame worker; layer cache at the miss). An entry listing a file dropped after that is refused under the queue's lock, so a render that was under way during the reload can't store pixels from before it. Such a frame is shown with an incomplete file list, so persist_missing skips it too. The per-file record is bounded (past it, everything older is refused). - An entry read while its drop was pending is a miss even when the writer ends the sweep between the read and the check (a stale hit found under load): the pending drops and the generation are taken before the entry is read. - Test: a reopened project serves its frame from disk with no source file opened or decoded. Co-Authored-By: Claude Opus 5.5 * Disk cache: RAM frames keep the drop generation their render started at Reload Footage empties the RAM preview, but an idle pass of persist_missing may already hold a frame from before the reload. It took the cache's drop generation when it ran, after the drop, so the old pixels could be written back and, with the file's size and time unchanged, served again after a restart. Each RAM frame now keeps the disk cache its render started with and that cache's drop generation then. persist_missing writes a frame only to that cache, with that generation, and skips it once one of its files was dropped since; a frame rendered without that cache is not written from there. Tests use tempfile directories (the layer drop test, the frame_files folders under target/). Co-Authored-By: Claude Opus 5.5 * Doc comments for the functions this PR touches * Disk cache: cap the free-space reserve at 10 GB, skip encodes the limit refuses The reserve the cache leaves free on its disk was max(2 GB, 5% of the disk) (#2551). On a 4 TB disk with 190 GB free that is 200 GB, so a cache set to 20 GB had a limit of 0: every write was refused, the cache never held anything, and only a log warning said so. The reserve is now clamp(5% of the disk, 2 GB, 10 GB). It is there to keep the cache from filling the disk, not to keep a share of a large disk free: 10 GB leaves the system room to work on any disk, a small disk still keeps 2 GB, and a 100 GB disk keeps 5 GB as before. After Effects has only a maximum cache size and no reserve; a clamp keeps the floor for small disks, where a fixed 10 GB would take most of the space. Writes the limit refuses cost no work: DiskCache::accepts_writes (one atomic read) is checked before a frame is converted (put_disk_frame) and before frames and layers are compressed (put_frame, put_frame_f16, put_layer). The frame workers spent about 8 ms per frame and 14 ms per layer on encodes that were then refused. persist_missing returns at once at limit 0 instead of converting the RAM frames on every idle pass. Settings > Disk shows the limit in effect in MB under Maximum Disk Cache Size, with a note when free disk space lowers it (settings.disk.cacheLimit). * Disk cache: refuse writes below 64 MB of room, show this disk's reserve Review follow-ups to the reserve fix: - accepts_writes also refuses while free disk space leaves less than 64 MB of room (a limit the setting asks for is always used): a few frames would fill it, and the rest would be compressed only to be refused. - While writes are refused the idle passes ask for no repaint, so the UI keeps a frame coming every 5 s: the free space is read again and the RAM frames are written once there is room. - Settings > Disk names the space kept free on this disk (the reserve the last reading applied; the rule from MIN/MAX_DISK_RESERVE when it is unknown), and the "no room" note follows accepts_writes. - The settings.disk.cacheLimit automation label is machine-readable and the same in every language: limitMb, maxMb, reserveMb, spaceLimited, writes. - cargo fmt. * Disk cache: follow 3D model sources in the comp's reach The reach walk followed comp, footage and solid sources but not LayerSource::Model. An expression reading a model layer's source (thisComp.layer("Model").source.name) left the model item out of the comp's content key, so renaming the model kept the key and the old frames on disk were served. Every source item is followed now (LayerSource::item), which also counts the model when no expression reads it (Advanced 3D draws it). The same omission is fixed in the RAM preview's content walk (frames.rs) and in the layer key's walk of referenced layers (cache/dependencies.rs). * Disk cache: a render's drop generation travels with its reads; no disk entries for Expression Selectors Drop generation per render: the layer cache recovered a buffer's drop generation from the latest miss of its key, so a second render of the key that started after File > Reload Footage lent its generation to a render still running from before, and that render's buffer (the file as it was) was written after the sweep. Now Renderer::recorded starts each buffer's read log at the store's drop generation before anything is read (ReadLog::starting_at); ReadFiles carries the earliest generation through merges, so derived buffers (styles, the blend-space copy, the flattened layer) and frames that use a buffer from memory carry it too. LayerCache::insert keeps a buffer made of a file dropped since its reads began neither on disk nor in memory; a list that doesn't say counts as made before every drop. DiskCache::dropped_since takes the list's own generation too. missed_at keeps only the miss time. Expression Selectors: the layer key evaluates a text Expression Selector's amount for the first character only, so a per-character dependency such as textIndex == 1 ? 0 : footage("ref.png").width was not in the key, and after the footage item's width changed a reopened session reused the old layer entry. A text layer with an Expression Selector now marks its reads unkeyed (ReadFiles::is_unkeyed), which merges like the generation; such buffers stay in memory and are never written to disk. Frame entries are unaffected: the comp's frame key hashes what the expressions name. The equality and hash of ReadFiles ignore both new fields. * Disk cache: never serve a RAM layer buffer made before a drop of its files A render that passed the drop check could sit in put_layer's encode while Reload Footage cleared the layer cache and dropped the file, then publish its old pixels to memory after the purge; get, and a fresh render's insert, then served them. The disk-hit promotion went through the same insert_mem. Every way the cache hands out a RAM buffer (get, the kept-first path of insert, an existing entry in insert_mem) now checks the entry's files against the store's drops from their own generation (ReadFiles::since): an entry made before a drop of one of its files is forgotten and the lookup misses, so a fresh render's buffer replaces it. insert_mem also re-checks a buffer just before publishing it. --------- Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 3 + apps/effectcraft-web/src/app.rs | 2 +- apps/effectcraft-web/src/diskcache.rs | 24 +- apps/effectcraft-web/src/frames.rs | 7 +- apps/effectcraft-web/src/store.rs | 18 +- apps/effectcraft/src/main.rs | 3 - crates/automation/Cargo.toml | 1 + crates/automation/src/confine.rs | 105 +- crates/engine/src/analysis/tracking.rs | 2 +- crates/engine/src/background/remote.rs | 53 +- crates/engine/src/background/remote/tests.rs | 138 +- crates/engine/src/cmd/effect.rs | 5 + crates/engine/src/cmd/layer/parent.rs | 1 + crates/engine/src/cmd/layer_time.rs | 1 + crates/engine/src/cmd/path_nulls.rs | 4 + crates/engine/src/cmd/prop.rs | 3 + crates/engine/src/cmd/puppet.rs | 13 +- crates/engine/src/cmd/rig3d.rs | 2 + crates/engine/src/cmd/three_d.rs | 27 +- crates/engine/src/footage/availability.rs | 43 +- crates/engine/src/footage/footage_check.rs | 2 +- crates/engine/src/footage/footage_reload.rs | 45 +- crates/engine/src/lib.rs | 17 +- crates/engine/src/session.rs | 2 +- crates/engine/src/session/edit.rs | 117 -- .../engine/src/settings/session_settings.rs | 14 +- crates/engine/src/tests/crypto_pick.rs | 1 + crates/engine/src/tests/disk_cache.rs | 352 +++-- crates/engine/src/tests/rig3d.rs | 14 +- crates/engine/src/tests/sequence.rs | 21 +- crates/expr/src/host.rs | 2 + crates/expr/src/lib.rs | 1 + crates/expr/src/sample.rs | 24 +- crates/expr/src/tests.rs | 39 + crates/gpu/src/tests/adv3d.rs | 2 + crates/host/src/tests.rs | 34 + crates/media/Cargo.toml | 4 + crates/media/src/exr_channels.rs | 84 +- crates/media/src/lib.rs | 26 +- crates/media/src/pool.rs | 202 ++- crates/media/src/reads.rs | 120 ++ crates/media/src/reads/tests.rs | 390 +++++ crates/media/src/stream.rs | 17 +- crates/project/src/lib.rs | 2 + crates/project/src/reach.rs | 405 +++++ crates/project/src/reach/tests.rs | 198 +++ crates/render/Cargo.toml | 3 + crates/render/src/accel.rs | 23 +- crates/render/src/cache.rs | 597 +++++-- crates/render/src/cache/dependencies.rs | 2 +- crates/render/src/disk_cache.rs | 1390 +++++++++++++---- crates/render/src/disk_cache/entry_files.rs | 276 ++++ crates/render/src/eval.rs | 6 +- crates/render/src/footage.rs | 19 + crates/render/src/fx_host.rs | 3 +- crates/render/src/layers.rs | 87 +- crates/render/src/lib.rs | 5 + crates/render/src/pipeline.rs | 236 ++- crates/render/src/reads.rs | 348 +++++ crates/render/src/tests.rs | 174 +++ crates/render/src/tests/collapse.rs | 13 +- crates/render/src/tests/mask_blur.rs | 29 + crates/render/src/tests/styles.rs | 31 + crates/render/src/tests/time.rs | 1 + crates/render/src/text.rs | 72 +- crates/render/src/text/tests.rs | 87 ++ crates/render/src/three_d/adv/scene.rs | 11 +- crates/render/src/three_d/adv/tests.rs | 4 + crates/render/src/three_d/compose.rs | 6 +- crates/script/src/tests/ui_more.rs | 1 + crates/text/src/fonts.rs | 33 +- crates/ui-egui/src/app/frame.rs | 4 - crates/ui-egui/src/frames.rs | 339 ++-- crates/ui-egui/src/frames/tests.rs | 548 ++++++- crates/ui-egui/src/menus/invoke.rs | 1 + .../src/panels/effect_controls/panel.rs | 1 + crates/ui-egui/src/panels/layer_panel.rs | 1 + crates/ui-egui/src/panels/properties.rs | 1 + crates/ui-egui/src/panels/roto_tool.rs | 1 + crates/ui-egui/src/panels/settings.rs | 110 +- crates/ui-egui/src/panels/text_panels.rs | 1 + crates/ui-egui/src/panels/timeline.rs | 1 + crates/ui-egui/src/panels/viewer.rs | 2 + crates/ui-egui/src/panels/viewer/shapes.rs | 2 + crates/ui-egui/src/panels/viewer/view.rs | 1 + crates/ui-egui/src/prefs_live.rs | 6 + .../ui-egui/tests/it/ui_unreadable_footage.rs | 3 +- crates/ui-egui/tests/it/ui_web_gpu_storage.rs | 9 +- docs/architecture.md | 47 +- docs/preferences.md | 7 +- docs/web.md | 15 +- 91 files changed, 5945 insertions(+), 1202 deletions(-) create mode 100644 crates/media/src/reads.rs create mode 100644 crates/media/src/reads/tests.rs create mode 100644 crates/project/src/reach.rs create mode 100644 crates/project/src/reach/tests.rs create mode 100644 crates/render/src/disk_cache/entry_files.rs create mode 100644 crates/render/src/reads.rs diff --git a/Cargo.lock b/Cargo.lock index 5b426c8f8..f897737db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1468,6 +1468,7 @@ dependencies = [ "effectcraft-script", "image", "serde_json", + "tempfile", "thiserror 2.0.21", ] @@ -1741,6 +1742,7 @@ version = "0.21.0" dependencies = [ "craft-docpath", "effectcraft-color", + "effectcraft-effects", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", @@ -1877,6 +1879,7 @@ dependencies = [ "lz4_flex", "rayon", "serde", + "tempfile", "web-time", ] diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 2882b16cf..99e71117f 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -329,7 +329,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { if n > 0 { app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers"))))); } - app.frames.set_virtual_media(files::STORE.media_epoch(), Arc::new(|path| files::STORE.file_identity(path))); + app.frames.set_virtual_id(Arc::new(|path| files::STORE.file_identity(path))); let (tx, rx) = std::sync::mpsc::channel(); api::set_sender(tx); app = app.with_control(rx); diff --git a/apps/effectcraft-web/src/diskcache.rs b/apps/effectcraft-web/src/diskcache.rs index ef9d46f8c..72e9a9e11 100644 --- a/apps/effectcraft-web/src/diskcache.rs +++ b/apps/effectcraft-web/src/diskcache.rs @@ -4,23 +4,28 @@ //! //! - **Writes** happen in the frame workers: a render request carries the frame's disk-cache key //! (`effectcraft_engine::remote::FrameMsg::Render`), the worker posts the frame, then writes -//! the sealed entry (`disk_cache::frame_entry`: LZ4, checksum) with a synchronous access -//! handle into a temporary file moved into place (`js/host.js` `cacheWrite`), and reports -//! `FrameReply::Stored`. +//! the sealed entry (`disk_cache::frame_entry`: the source files the frame read, LZ4, +//! checksum) with a synchronous access handle into a temporary file moved into place +//! (`js/host.js` `cacheWrite`), and reports `FrameReply::Stored`. A frame whose files can't +//! all be named is not stored. //! - **The index** lives here, on the page ([`DiskIndex`], shared with the desktop's cache): //! rebuilt at startup from the folder listing (oldest files first), updated by the workers' //! `Stored` replies and by reads; eviction deletes the least recently used files. //! - **Reads** are asynchronous: a frame whose key is indexed is read and decoded here instead -//! of rendering it (`crate::frames`); a damaged or missing file is a miss and is forgotten. +//! of rendering it (`crate::frames`), if the files its entry lists still have the size and +//! content hash it was made of (the file table's identities); a damaged or missing entry, or +//! one whose files changed, is a miss and is deleted. //! //! - **Layer buffers** too (`layers/<32 hex>.ecc`): a layer lookup happens in the middle of a //! synchronous render, where the browser's file system can't be read, so the frame workers //! back their layer caches with a `PrefetchStore`. They report each frame's layer-cache //! misses; before the next request the page names the recent misses that are on disk //! ([`layer_prefetch`], `disk_cache::LayerPrefetch`), and the worker reads those entries -//! into memory before rendering, so the lookups find them. Slow buffers are written by the -//! worker after its frame and indexed here like frames (one least-recently-used order for -//! both kinds). +//! into memory before rendering, so the lookups find them; an entry is used only while the +//! files it lists (the file table's size and content hash) are unchanged, and a buffer whose +//! files can't all be named is not written (#3184). Slow buffers are written by the worker +//! after its frame and indexed here like frames (one least-recently-used order for both +//! kinds). use std::cell::RefCell; @@ -187,11 +192,12 @@ pub fn note_miss() { } /// Read and decode a cached frame; `None` (forgotten and deleted) when the file is missing or -/// damaged. +/// damaged, or the source files it was made of changed since. pub async fn read(key: u128) -> Option { let name = disk_cache::entry_name(key); let bytes = JsFuture::from(cache_read(&name)).await.ok().and_then(|b| b.dyn_into::().ok()).map(|b| b.to_vec()); - let frame = bytes.as_deref().and_then(disk_cache::read_frame_entry); + let identity = |path: &str| crate::files::STORE.file_identity(path); + let frame = bytes.as_deref().and_then(|b| disk_cache::read_frame_entry(b, Some(&identity))); ST.with(|s| { let mut s = s.borrow_mut(); if frame.is_some() { diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs index 69089e308..0159fe23b 100644 --- a/apps/effectcraft-web/src/frames.rs +++ b/apps/effectcraft-web/src/frames.rs @@ -473,10 +473,13 @@ pub async fn worker_frame(json: String) { g.settled().await; replies = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| s.resume()).unwrap_or_default()); } + // The source files the frame read, kept with its entry (checked by the page before use); a + // frame whose files can't all be named is not stored (#3184). + let (files, virtual_id) = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| (s.take_files(), s.virtual_id().cloned())).unwrap_or_default()); for (r, px) in replies { post_reply(&r, px.as_deref()); - if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key)) = (&r, &px, &disk) { - let entry = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px); + if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key), Some(files)) = (&r, &px, &disk, &files) { + let Some(entry) = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px, files, virtual_id.as_deref()) else { continue }; let name = format!("{key}.ecc"); match JsFuture::from(crate::diskcache::cache_write(&name, js_sys::Uint8Array::from(&entry[..]))).await { Ok(n) => post_reply(&FrameReply::Stored { key: key.clone(), bytes: n.as_f64().unwrap_or(entry.len() as f64) as u64, layer: false }, None), diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index 0cc877948..a4947600b 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -20,7 +20,6 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use effectcraft_engine::config::{ConfigStore, FileOps}; @@ -187,13 +186,12 @@ pub struct Store { pub on_change: OnceLock, /// Clock in milliseconds since the Unix epoch (tests pin it). pub clock: OnceLock f64>, - /// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash. - media_epoch: Arc, } impl Default for Store { + /// An empty store with no mirror contents, change callback or clock. fn default() -> Self { - Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) } + Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new() } } } @@ -218,22 +216,12 @@ impl Store { pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) { let now = self.now(); let key = file_key(path); - let replaced = self.lock().get(&key).is_none_or(|e| !Arc::ptr_eq(&e.data, &data)); self.lock().put(&key, data, now, persist); - if replaced { - self.media_epoch.fetch_add(1, Ordering::Relaxed); - } if persist { self.changed(); } } - /// The generation preview cache lookups key on. It changes when [`Self::put_file`] writes - /// a different blob, and it is not a content hash. - pub fn media_epoch(&self) -> Arc { - self.media_epoch.clone() - } - /// Size and content hash of the file under `path`. The hash is stored on the entry so a /// later key does not walk the bytes again. pub fn file_identity(&self, path: &str) -> Option<(u64, u64)> { @@ -541,11 +529,9 @@ mod tests { assert_ne!(a, b); assert_eq!(&data[..], b"blue"); assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it"); - let epoch = s.media_epoch().load(Ordering::Relaxed); let id = s.file_identity("/collision.png").expect("identity"); assert_eq!(id, (4, content_hash(b"blue"))); assert_eq!(s.file_identity("/collision.png"), Some(id), "the hash is kept on the entry"); - assert!(epoch >= 2, "each new blob bumps the generation the preview keys on"); } /// A write that failed is flushed again with the key's latest state (#215). diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 07d327a21..5350addbe 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -276,9 +276,6 @@ fn main() -> eframe::Result { // written on background threads (M13.14). session.check_footage_on_open = true; session.autosave.background = true; - // The disk cache's footage stamps too: an imported or opened project with thousands of - // sequence frames on a network share took half a minute to stamp. - session.disk_salt_background = true; // Settings, shortcut presets and the crash-recovery sentinel live in the platform // config directory. Agent-driven runs (`--control`) skip crash recovery. if let Some(dir) = effectcraft_host::config_dir() { diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 916cc3501..e0caf3218 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -19,3 +19,4 @@ effectcraft-model = { workspace = true } [dev-dependencies] effectcraft-script = { workspace = true } effectcraft-host = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 6d8ce2819..570726932 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -346,32 +346,60 @@ impl ConfinedFootage { fn inside(&self, f: &Footage) -> bool { std::iter::once(&f.path).chain(&f.sequence).filter(|p| !p.is_empty()).all(|p| self.confinement.check(p, Access::Read).is_ok()) } + + /// [`Self::inside`] for a read: a refused one is counted on this thread, so the render + /// [`recording`](FootageSource::recording) it can't list what it made without the footage. + fn readable(&self, f: &Footage) -> bool { + let inside = self.inside(f); + if !inside { + let _ = REFUSED_READS.try_with(|n| n.set(n.get().wrapping_add(1))); + } + inside + } +} + +thread_local! { + /// Footage reads refused on this thread (see [`ConfinedFootage::readable`]). + static REFUSED_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The number of reads this thread has refused for lying outside the allowed roots. +fn refused_reads() -> u64 { + REFUSED_READS.try_with(std::cell::Cell::get).unwrap_or(0) } impl FootageSource for ConfinedFootage { + /// The frame from the inner source, or `None` when the footage lies outside the allowed roots. fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { - self.inside(footage).then(|| self.inner.frame(item, footage, t)).flatten() + self.readable(footage).then(|| self.inner.frame(item, footage, t)).flatten() } + /// The frame scaled to `width` by `height`, or `None` when the footage lies outside the allowed roots. fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option> { - self.inside(footage).then(|| self.inner.frame_at_size(item, footage, t, width, height)).flatten() + self.readable(footage).then(|| self.inner.frame_at_size(item, footage, t, width, height)).flatten() } + /// The frame with an explicit size and float curve, or `None` when the footage lies outside the allowed roots. fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { - self.inside(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten() + self.readable(footage).then(|| self.inner.frame_with(item, footage, t, size, 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.inside(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten() + self.readable(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten() } + /// The auxiliary channels of the footage frame, or `None` when the footage lies outside the allowed roots. fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { - self.inside(footage).then(|| self.inner.aux(item, footage, t)).flatten() + self.readable(footage).then(|| self.inner.aux(item, footage, t)).flatten() } + /// Forwards the auxiliary footage paths to the inner source. fn set_aux_footage(&self, paths: &[String]) { self.inner.set_aux_footage(paths) } + /// The 3D model for the footage, or `None` when the footage lies outside the allowed roots. fn model(&self, item: ItemId, footage: &Footage) -> Option> { - self.inside(footage).then(|| self.inner.model(item, footage)).flatten() + self.readable(footage).then(|| self.inner.model(item, footage)).flatten() } + /// The rasterised vector footage at `scale`, or `None` when the footage lies outside the allowed roots. fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option> { - self.inside(footage).then(|| self.inner.vector_frame(item, footage, scale)).flatten() + self.readable(footage).then(|| self.inner.vector_frame(item, footage, scale)).flatten() } /// Says why footage outside the roots shows nothing, so the agent can act on it. fn problem(&self, footage: &Footage) -> Option { @@ -392,15 +420,29 @@ impl FootageSource for ConfinedFootage { fn set_conform_folder(&self, folder: Option) { self.inner.set_conform_folder(folder) } + /// The inner source's cache budget. fn cache_budget(&self) -> Option { self.inner.cache_budget() } + /// Runs `f` under the inner source's cancel flag. fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) { self.inner.cancellable(cancel, f) } + /// Whether the inner source has been called off. fn called_off(&self) -> bool { self.inner.called_off() } + /// The files read inside the roots, as the inner source notes them. Footage outside reads + /// nothing, and what is made without it can't be listed by its files: a refused read makes + /// the list incomplete, so it never outlives the session in a disk cache (another session + /// with other roots would read the footage). + fn recording(&self, reads: &effectcraft_engine::render::reads::ReadLog, f: &mut dyn FnMut()) { + let refused = refused_reads(); + self.inner.recording(reads, f); + if refused_reads() != refused { + reads.mark_incomplete(); + } + } } /// The session's file reads, only inside the read roots: what a project or an imported file @@ -593,6 +635,55 @@ mod tests { } } + /// Footage outside the roots shows nothing: a render that asked for it can't be listed by its + /// files (#3184), or a disk cache would keep the empty picture for a session with other + /// roots. Reads inside the roots record as the inner source tells. + #[test] + fn a_refused_footage_read_makes_the_render_unlisted() { + use effectcraft_engine::render::reads::{FileStamp, ReadFile, ReadLog}; + /// Reads every file, noting it. + struct Noting; + impl FootageSource for Noting { + /// Returns a 1x1 frame and notes the footage path it was asked for. + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + NOTED.with(|n| n.borrow_mut().push(f.path.clone())); + Some(Arc::new(Image::new(1, 1))) + } + /// Runs `f`, then reports every path noted meanwhile to `reads` as a file with a missing stamp. + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + f(); + for p in NOTED.with(|n| std::mem::take(&mut *n.borrow_mut())) { + reads.note(ReadFile::new(p, FileStamp::Missing)); + } + } + } + thread_local! { + static NOTED: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + } + // (a fresh directory, removed when the test ends, panicking or not) + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); + let (inside, outside) = (base.join("in"), base.join("elsewhere")); + for d in [&inside, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(std::slice::from_ref(&inside), &[]).unwrap(); + let src = ConfinedFootage::new(Arc::new(Noting), c); + let footage = |d: &std::path::Path| Footage { path: d.join("a.png").to_string_lossy().into_owned(), ..Default::default() }; + let read = |f: &Footage| { + let log = ReadLog::new(); + let mut got = None; + src.recording(&log, &mut || got = src.frame(ItemId(1), f, Tick::ZERO)); + (got.is_some(), log.into_files()) + }; + let (shown, files) = read(&footage(&inside)); + assert!(shown && files.is_complete() && files.len() == 1, "inside the roots: {files:?}"); + let (shown, files) = read(&footage(&outside)); + assert!(!shown && !files.is_complete(), "outside the roots: {files:?}"); + // The next render on the thread is not affected. + assert!(read(&footage(&inside)).1.is_complete()); + } + #[test] fn relative_and_outside_paths_of_newly_listed_commands_are_refused() { let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id())); diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs index 221b28602..912a8adfe 100644 --- a/crates/engine/src/analysis/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -208,7 +208,7 @@ pub(crate) fn source_frame( t: Tick, expr: Option<&dyn ExprHost>, ) -> Option<(Arc, [f64; 2])> { - let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[], reads: None }; if let effectcraft_project::LayerSource::Footage { item } = &layer.source && let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind) && f.has_video diff --git a/crates/engine/src/background/remote.rs b/crates/engine/src/background/remote.rs index 27f9396e1..aea210ec5 100644 --- a/crates/engine/src/background/remote.rs +++ b/crates/engine/src/background/remote.rs @@ -20,13 +20,16 @@ //! calls [`FrameServer::resume`] for the next. Backend Auto picks CPU or GPU per comp from //! the frames' total times ([`effectcraft_render::AutoPick`]). //! - Disk cache: a render request may carry the frame's disk-cache key ([`FrameMsg::Render`] -//! `disk`); the transport stores the finished frame under it (the browser: in the Origin -//! Private File System) and reports [`FrameReply::Stored`]. +//! `disk`); the transport stores the finished frame under it with the source files it read +//! ([`FrameServer::take_files`], `disk_cache::frame_entry`; the browser: in the Origin Private +//! File System) and reports [`FrameReply::Stored`]. A frame whose files can't all be named is +//! not stored. The page checks an entry's files before it uses it (#3184). //! - Layer buffers on disk: with `layers`, the server's layer cache is backed by a //! [`PrefetchStore`] ([`FrameServer::set_layer_store`]): before the request the transport //! fetches the entries named in `prefetch` (recent misses that are on disk, chosen by the -//! page, `disk_cache::LayerPrefetch`), so the lookups in the middle of the render find them; -//! the reply lists the layer keys that missed and those served from the store +//! page, `disk_cache::LayerPrefetch`), so the lookups in the middle of the render find them +//! (each entry keeps the source files its buffer was made of and is used only while they are +//! unchanged, #3184); the reply lists the layer keys that missed and those served from the store //! ([`FrameReply::Frame`] `layer_misses` / `layer_hits`), and slow buffers queue up for the //! transport to write after the frame ([`PrefetchStore::take_writes`], reported as //! [`FrameReply::Stored`] with `layer`). @@ -37,6 +40,7 @@ use std::sync::Arc; use effectcraft_project::{ItemId, Project}; +use effectcraft_render::reads::{ReadFiles, ReadLog}; use effectcraft_render::{Accelerator, AutoKey, Backend, ExprHost, FootageSource, Image, LayerCache, LayerStore, PrefetchStore, RenderOpts, Renderer}; use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; @@ -177,7 +181,7 @@ pub enum FrameReply { passes: u32, #[serde(default)] gpu: bool, - /// Layer-cache lookups that missed the store (salted keys, 32 hex digits). + /// Layer-cache lookups that missed the store (disk keys, 32 hex digits). #[serde(default, rename = "layerMisses", skip_serializing_if = "Vec::is_empty")] layer_misses: Vec, /// Layer buffers served from the store (fetched from disk). @@ -260,11 +264,12 @@ pub struct FrameServer { revision: Option, /// The frame being rendered in passes. pending: Option, - /// The disk cache's layer buffers (see the module docs) and the project's salt. + /// The disk cache's layer buffers (see the module docs). layer_store: Option>, - salt: u64, /// Size and content hash for footage paths with no native metadata (the browser file table). virtual_id: Option Option<(u64, u64)> + Send + Sync>>, + /// The source files the last finished frame read ([`FrameServer::take_files`]). + files: Option, } /// A frame whose readbacks are in flight. @@ -284,6 +289,7 @@ struct Pending { pub const MAX_PASSES: u32 = 24; impl FrameServer { + /// A frame server over `footage` and the optional expression host, with an empty layer cache. pub fn new(footage: Arc, expr: Option>) -> FrameServer { FrameServer { footage, @@ -295,17 +301,30 @@ impl FrameServer { revision: None, pending: None, layer_store: None, - salt: 0, virtual_id: None, + files: None, } } - /// Identity of virtual footage files, used when native metadata cannot describe the path. - /// Called from `FrameServer::load`, not from the UI thread's cache lookup. + /// Identity of virtual footage files, used when native metadata cannot describe the path: + /// what the disk entries of frames and layer buffers keep for them and are checked against. pub fn set_virtual_id(&mut self, id: Option Option<(u64, u64)> + Send + Sync>>) { + self.cache.set_virtual_id(id.clone()); self.virtual_id = id; } + /// The source files the frame of the last [`FrameReply::Frame`] read, with their identities + /// as read: what its disk entry keeps (`disk_cache::frame_entry`). Taken once. + pub fn take_files(&mut self) -> Option { + self.files.take() + } + + /// The identity of footage paths with no native metadata (the browser file table), to store + /// with a frame's disk entry. + pub fn virtual_id(&self) -> Option<&Arc Option<(u64, u64)> + Send + Sync>> { + self.virtual_id.as_ref() + } + /// The store behind the layer cache when a request asks for `layers` (the browser: the /// disk cache's layer buffers, fetched before the request). pub fn set_layer_store(&mut self, store: Option>) { @@ -316,11 +335,6 @@ impl FrameServer { self.layer_store.as_ref() } - /// The salt of the replica's layer keys on disk (keys are `(salt, key)`). - pub fn salt(&self) -> u64 { - self.salt - } - /// Render with `accel` when the request's backend asks for it (the worker's GPU). Its miss /// gate keeps placeholder buffers of unfinished passes out of the layer cache. pub fn set_accel(&mut self, accel: Option>) { @@ -347,8 +361,6 @@ impl FrameServer { fn load(&mut self, revision: u64) -> Result<(), String> { let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?; - self.salt = effectcraft_render::disk_cache::footage_salt_with(&p, self.virtual_id.as_deref()); - self.cache.set_store_salt(self.salt); self.project = Some(Arc::new(p)); self.revision = Some(revision); Ok(()) @@ -392,7 +404,7 @@ impl FrameServer { } FrameMsg::Render { id, revision, comp, time, opts, layers, .. } => { let store = self.layer_store.clone().filter(|_| layers); - self.cache.set_store(store.map(|s| (s as Arc, self.salt))); + self.cache.set_store(store.map(|s| s as Arc)); let fail = |error: String, stale: bool| vec![(FrameReply::Failed { id, error, stale }, None)]; if self.revision != Some(revision) { return fail(format!("the replica is at {:?}, not {revision}", self.revision), true); @@ -432,12 +444,16 @@ impl FrameServer { /// Run the next pass of the frame in progress; its reply once it is done. pub fn resume(&mut self) -> Vec<(FrameReply, Option>)> { let Some(mut pr) = self.pending.take() else { return vec![] }; + self.files = None; pr.passes += 1; let accel = self.accel.clone().filter(|_| pr.opts.backend != Backend::Cpu); let give_up = pr.passes > MAX_PASSES; + // (each pass renders the whole frame: the last one's files are the frame's) + let reads = ReadLog::new(); let mut r = Renderer::new(&pr.project, self.footage.as_ref(), pr.opts); r.expr = self.expr.as_deref(); r.cache = Some(&self.cache); + r.reads = Some(&reads); if !give_up { r.accel = accel.as_deref(); } @@ -451,6 +467,7 @@ impl FrameServer { } let ms = pr.t0.elapsed().as_secs_f64() * 1000.0; let gpu = r.accel.is_some() && r.active_accel().is_some(); + self.files = Some(reads.files()); let hex = |v: Vec| v.into_iter().map(|k| format!("{k:032x}")).collect::>(); let (layer_misses, layer_hits) = match &self.layer_store { Some(st) => (hex(st.take_misses()), hex(st.take_hits())), diff --git a/crates/engine/src/background/remote/tests.rs b/crates/engine/src/background/remote/tests.rs index e2db10b8e..e6d797f9d 100644 --- a/crates/engine/src/background/remote/tests.rs +++ b/crates/engine/src/background/remote/tests.rs @@ -69,7 +69,8 @@ fn layer_buffers_go_through_the_disk_store() { assert!(!layer_misses.is_empty() && layer_hits.is_empty()); let writes = store_a.take_writes(); assert!(!writes.is_empty()); - assert!(writes.iter().all(|(k, _)| (*k >> 64) as u64 == a.salt()), "keys are salted per project"); + // Sealed with the files each buffer was made of (none here: the demo reads no footage). + assert!(writes.iter().all(|(_, e)| effectcraft_render::disk_cache::read_layer_entry(e).is_some_and(|(_, f)| f.is_empty()))); let mut index = DiskIndex::default(); let mut disk = std::collections::HashMap::new(); for (k, e) in writes { @@ -375,3 +376,138 @@ fn every_frame_begin_has_a_frame_end() { assert!(matches!(w.handle(req(6, s.revision, comp, cpu))[0].0, FrameReply::Frame { id: 6, .. })); assert_eq!(load(), (3, 3)); } + +/// Footage that reads one file from memory (the browser's file table) for every frame. +struct OneVirtualFile { + /// Tell which files it reads (else every render's list is incomplete). + records: bool, +} + +impl FootageSource for OneVirtualFile { + fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option> { + Some(Arc::new(Image::filled(4, 4, [1.0, 0.5, 0.0, 1.0]))) + } + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + if self.records { + reads.note(effectcraft_render::reads::ReadFile::new("/clip.png", effectcraft_render::reads::FileStamp::Virtual)); + } else { + reads.mark_incomplete(); + } + f() + } +} + +/// The worker hands the transport the files each frame read, so its disk entry keeps them; the +/// page reads the entry back only while those files are unchanged. A frame whose files can't +/// all be named is not stored (#3184). +#[test] +fn the_worker_reports_the_files_of_each_frame_for_its_disk_entry() { + use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build}; + use effectcraft_render::disk_cache::{frame_entry, read_frame_entry}; + use effectcraft_time::FrameRate; + let mut p = Project::default(); + let f = Footage { path: "/clip.png".into(), width: 4, height: 4, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() }; + let fid = p.add_item("clip.png", effectcraft_color::Label::None, None, ItemKind::Footage(f)); + let mut comp = Comp::new(4, 4, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (4, 4), None)].into(); + let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); + let id = |mark: u64| move |path: &str| (path == "/clip.png").then_some((10, mark)); + let (one, two) = (id(1), id(2)); + for records in [true, false] { + let mut w = FrameServer::new(Arc::new(OneVirtualFile { records }), None); + w.set_virtual_id(Some(Arc::new(one))); + let mut m = Mirror::default(); + assert!(w.handle(round(&m.sync(1, &p).unwrap())).is_empty()); + assert!(w.take_files().is_none(), "no frame yet"); + let out = w.handle(round(&FrameMsg::Render { + id: 1, + revision: 1, + comp: cid, + time: Tick::ZERO, + opts: Box::default(), + disk: Some(format!("{:032x}", 5u128)), + layers: false, + prefetch: vec![], + })); + let (FrameReply::Frame { width, height, .. }, Some(px)) = &out[0] else { panic!("{:?}", out[0].0) }; + let files = w.take_files().expect("the frame's files"); + assert!(w.take_files().is_none(), "taken once"); + let vid = w.virtual_id().cloned(); + let entry = frame_entry(*width, *height, px, &files, vid.as_deref()); + if !records { + assert!(!files.is_complete() && entry.is_none(), "an incomplete list is not stored"); + continue; + } + assert_eq!(files.files().iter().map(|f| &*f.path).collect::>(), ["/clip.png"]); + let entry = entry.expect("stored"); + assert_eq!(read_frame_entry(&entry, Some(&one)).map(|f| f.rgba), Some(px.clone())); + assert!(read_frame_entry(&entry, Some(&two)).is_none(), "the file was replaced: a miss"); + } +} + +/// No salt work at all (#3184, replacing the background salt of #3106): syncing a project with a +/// large image sequence nobody shows, patching it and rendering with the layer store never asks +/// the file hook about the sequence (the salt asked about every file at every sync). Only the +/// file the rendered buffer reads is asked about: to store its entry and, in another worker, to +/// check it before the buffer from the store is used. +#[test] +fn the_worker_asks_only_about_the_files_its_buffers_read() { + use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, Project, build}; + use effectcraft_time::FrameRate; + use std::sync::Mutex; + let mut p = Project::default(); + let f = Footage { path: "/clip.png".into(), width: 4, height: 4, has_video: true, ..Default::default() }; + let fid = p.add_item("clip.png", effectcraft_color::Label::None, None, ItemKind::Footage(f)); + let seq = Footage { + kind: FootageKind::Sequence, + path: "/seq/f_00000.png".into(), + sequence: (0..5000).map(|i| format!("/seq/f_{i:05}.png")).collect(), + width: 4, + height: 4, + has_video: true, + ..Default::default() + }; + p.add_item("seq", effectcraft_color::Label::None, None, ItemKind::Footage(seq)); + let mut comp = Comp::new(4, 4, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (4, 4), None)].into(); + let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); + let asked: Arc>> = Arc::default(); + let worker = || { + let mut w = FrameServer::new(Arc::new(OneVirtualFile { records: true }), None); + let seen = asked.clone(); + w.set_virtual_id(Some(Arc::new(move |path: &str| { + seen.lock().unwrap().push(path.to_string()); + (path == "/clip.png").then_some((10, 1)) + }))); + let store = Arc::new(PrefetchStore::new(1 << 20).with_min_ms(0)); + w.set_layer_store(Some(store.clone())); + (w, store) + }; + let render = |w: &mut FrameServer, revision: u64, prefetch: Vec| { + let out = w.handle(round(&FrameMsg::Render { id: 1, revision, comp: cid, time: Tick::ZERO, opts: Box::default(), disk: None, layers: true, prefetch })); + let FrameReply::Frame { layer_hits, .. } = &out[0].0 else { panic!("{:?}", out[0].0) }; + layer_hits.len() + }; + // Worker A: synced, patched, rendered; its buffer is queued with its file. + let (mut a, store_a) = worker(); + let mut m = Mirror::default(); + assert!(a.handle(round(&m.sync(1, &p).unwrap())).is_empty()); + let mut edited = p.clone(); + edited.item_mut(cid).unwrap().name = "Renamed".into(); + assert!(a.handle(round(&m.sync(2, &edited).unwrap())).is_empty()); + assert!(asked.lock().unwrap().is_empty(), "syncing looks at no file"); + assert_eq!(render(&mut a, 2, vec![]), 0); + let writes = store_a.take_writes(); + assert!(!writes.is_empty(), "the clip's buffer is stored"); + // Worker B: fetched before its frame, checked, used. + let (mut b, store_b) = worker(); + let mut m = Mirror::default(); + assert!(b.handle(round(&m.sync(2, &edited).unwrap())).is_empty()); + for (k, e) in &writes { + assert!(store_b.provide(*k, e)); + } + assert!(render(&mut b, 2, writes.iter().map(|(k, _)| format!("{k:032x}")).collect()) > 0, "served from the store"); + let asked = asked.lock().unwrap(); + assert!(!asked.is_empty(), "the hook names the buffer's file"); + assert!(asked.iter().all(|p| p == "/clip.png"), "only the file the buffer read: {:?}", &asked[..asked.len().min(5)]); +} diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index 69bdb5a4a..1d352d192 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -345,6 +345,7 @@ fn warning(s: &mut Session, p: &Value) -> Result { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))})) } @@ -378,6 +379,7 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default()); r.expr = s.expr.as_deref(); @@ -515,6 +517,7 @@ impl CryptoProbe { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?; @@ -586,6 +589,7 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let frame = probe_frame(&ctx, layer, pos); let o = s.render_opts(Default::default()); @@ -780,6 +784,7 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string(); let driven = prop.expr.as_ref().is_some_and(|e| e.enabled); diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index 3611ef047..cd923c950 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -249,6 +249,7 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op footage: Some(s.footage.as_ref()), property_overrides: &[], media_overrides: &[], + reads: None, }; let space = |id: Option| -> effectcraft_geom::Mat3 { id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) diff --git a/crates/engine/src/cmd/layer_time.rs b/crates/engine/src/cmd/layer_time.rs index 4ecf725ec..2f8661885 100644 --- a/crates/engine/src/cmd/layer_time.rs +++ b/crates/engine/src/cmd/layer_time.rs @@ -194,6 +194,7 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result { footage: Some(s.footage.as_ref()), property_overrides: &[], media_overrides: &[], + reads: None, }; let now: Vec<(effectcraft_project::LayerId, f64)> = comp .layers diff --git a/crates/engine/src/cmd/path_nulls.rs b/crates/engine/src/cmd/path_nulls.rs index 93d71cd67..6da43afde 100644 --- a/crates/engine/src/cmd/path_nulls.rs +++ b/crates/engine/src/cmd/path_nulls.rs @@ -73,6 +73,8 @@ struct PathInfo { span: (f64, f64), } +/// Reads a mask or shape path at the current time and returns its comp-space vertices, value, expression reference and layer name. +/// Errors when the property is missing, is not a path or has no vertices. fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; @@ -87,6 +89,7 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result

Result Result { convert_one_expr(s, cid, lid, uid) } +/// Replaces the expression on one property with keyframes sampled from its evaluated values. +/// Returns the number of properties converted. fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Result { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone(); @@ -1397,6 +1399,7 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res footage: Some(s.footage.as_ref()), property_overrides: &[], media_overrides: &[], + reads: None, }; keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr))); t += fd; diff --git a/crates/engine/src/cmd/puppet.rs b/crates/engine/src/cmd/puppet.rs index d894516c2..bd20bd885 100644 --- a/crates/engine/src/cmd/puppet.rs +++ b/crates/engine/src/cmd/puppet.rs @@ -39,8 +39,17 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) let fx = layer.effects()?; let index = fx.groups().position(|g| g.uid == fx_uid)?; let g = fx.groups().nth(index)?; - let ctx = - EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + reads: None, + }; let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); diff --git a/crates/engine/src/cmd/rig3d.rs b/crates/engine/src/cmd/rig3d.rs index dbcaa3c3c..58a4281a8 100644 --- a/crates/engine/src/cmd/rig3d.rs +++ b/crates/engine/src/cmd/rig3d.rs @@ -47,6 +47,7 @@ impl Env { fn of(s: &Session, cid: ItemId) -> Env { Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() } } + /// The evaluation context of `comp` in `proj` at the session's current time. fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> { EvalCtx { project: proj, @@ -57,6 +58,7 @@ impl Env { footage: Some(self.footage.as_ref()), property_overrides: &[], media_overrides: &[], + reads: None, } } } diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs index 009a33b2e..3355b68e3 100644 --- a/crates/engine/src/cmd/three_d.rs +++ b/crates/engine/src/cmd/three_d.rs @@ -418,14 +418,24 @@ fn set_view_camera(s: &mut Session, p: &Value) -> Result { Ok(json!({"view": v.id()})) } +/// Returns the active comp's 3D view state, with the comp size, for the 3D view commands. fn get_3d(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let (w, h) = (comp.width as f64, comp.height as f64); let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default(); let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); - let ctx = - EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + reads: None, + }; let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx)); let f = cam.forward(); let lights: Vec = comp @@ -567,8 +577,17 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { /// through each layer's world matrix; cameras and lights contribute their position). fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ctx = - EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + reads: None, + }; let mut out = vec![]; for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { let m = ctx.world_matrix(l); diff --git a/crates/engine/src/footage/availability.rs b/crates/engine/src/footage/availability.rs index 7ad2bc03f..75e2a1e1c 100644 --- a/crates/engine/src/footage/availability.rs +++ b/crates/engine/src/footage/availability.rs @@ -215,20 +215,18 @@ impl Scanner { } } -/// A disk observation prepared entirely by the existing worker, including the potentially -/// expensive sequence stamps used for disk-cache keys. The UI only swaps a current snapshot. +/// A disk observation prepared entirely by the existing worker. The UI only swaps a current +/// snapshot (the disk cache needs nothing: its entries check their own files when read, #3184). pub(crate) struct Update { before: Arc, after: Arc, paths: BTreeSet, newly_missing: usize, - cache_enabled: bool, - salt: Option<(u64, u64)>, } -/// Prepare a replacement only when availability changed. Project cloning and optional disk -/// salt computation belong on the worker; apply later rejects an outdated project snapshot. -pub(crate) fn prepare(project: &Arc, sources: &[Source], missing: &[bool], cache_enabled: bool) -> Option { +/// Prepare a replacement only when availability changed. Project cloning belongs on the +/// worker; apply later rejects an outdated project snapshot. +pub(crate) fn prepare(project: &Arc, sources: &[Source], missing: &[bool]) -> Option { let mut after = project.clone(); let mut paths = BTreeSet::new(); let mut newly_missing = 0; @@ -245,22 +243,17 @@ pub(crate) fn prepare(project: &Arc, sources: &[Source], missing: &[boo if paths.is_empty() { return None; } - let salt = cache_enabled.then(|| (Session::footage_fingerprint(&after), crate::render::disk_cache::footage_salt(&after))); - Some(Update { before: project.clone(), after, paths, newly_missing, cache_enabled, salt }) + Some(Update { before: project.clone(), after, paths, newly_missing }) } impl Update { - /// False means a newer project or cache configuration needs a fresh observation. + /// False means a newer project needs a fresh observation. pub fn apply(self, s: &mut Session) -> bool { - if !Arc::ptr_eq(&s.project, &self.before) || s.disk_cache.is_some() != self.cache_enabled { + if !Arc::ptr_eq(&s.project, &self.before) { return false; } let dirty = s.is_dirty(); s.project = self.after; - if let Some((fingerprint, salt)) = self.salt { - s.disk_salt = Some((fingerprint, salt)); - s.layer_cache.set_disk_salt(salt); - } s.bump(); if !dirty { s.mark_saved(); @@ -371,7 +364,7 @@ mod tests { fn stale_observation_cannot_mark_a_replacement_missing() { let (mut s, id) = session(Arc::new(Disk::default())); let old = sources(&s); - let update = prepare(&s.project, &old, &[true, true], false).unwrap(); + let update = prepare(&s.project, &old, &[true, true]).unwrap(); if let ItemKind::Footage(f) = &mut Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind { f.path = "/new.png".into(); } @@ -398,9 +391,12 @@ mod tests { assert_eq!(disk.looks.load(Ordering::Relaxed), 2, "one file per source, no scan of an unmounted folder"); } + /// An availability change does no disk-cache work (#3184): the cache stays attached and + /// no file is looked at for it, on the worker or when the UI applies the observation (it + /// used to stamp every footage file for a new layer salt). #[test] - fn applying_prepared_cache_salt_does_not_restat_files_on_the_ui_thread() { - let dir = std::env::temp_dir().join(format!("ec-availability-salt-{}", std::process::id())); + fn applying_an_availability_change_does_no_disk_cache_work() { + let dir = std::env::temp_dir().join(format!("ec-availability-cache-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("plate.png"); std::fs::write(&file, b"plate").unwrap(); @@ -413,16 +409,13 @@ mod tests { ); let cache = crate::render::disk_cache::DiskCache::open(dir.join("cache"), 1 << 20).unwrap(); s.disk_cache = Some(cache.clone()); - s.layer_cache.set_disk(Some((cache, 0))); + s.layer_cache.set_disk(Some(cache.clone())); s.mark_saved(); - let update = prepare(&s.project, &sources(&s), &[false], true).unwrap(); - let expected = update.salt.unwrap().1; - // The filesystem can change again after the worker's observation. Applying it must - // retain that observation's salt, rather than scan the disk a second time on the UI. + let update = prepare(&s.project, &sources(&s), &[false]).unwrap(); std::fs::remove_file(file).unwrap(); - assert_ne!(crate::render::disk_cache::footage_salt(&update.after), expected); assert!(update.apply(&mut s)); - assert_eq!(s.disk_salt.map(|(_, salt)| salt), Some(expected)); + assert!(s.layer_cache.disk().is_some_and(|d| Arc::ptr_eq(&d, &cache)), "still attached"); + assert_eq!(cache.file_checks(), 0, "no file looked at for the disk cache"); assert!(!s.is_dirty()); drop(s); std::fs::remove_dir_all(dir).unwrap(); diff --git a/crates/engine/src/footage/footage_check.rs b/crates/engine/src/footage/footage_check.rs index 9e941a6ed..42401d195 100644 --- a/crates/engine/src/footage/footage_check.rs +++ b/crates/engine/src/footage/footage_check.rs @@ -272,7 +272,7 @@ mod tests { let id = source.id; let mut recovered = source.footage.clone(); recovered.path = "/new/project/plate.png".into(); - let observed = super::super::availability::prepare(&s.project, std::slice::from_ref(&source), &[true], false).unwrap(); + let observed = super::super::availability::prepare(&s.project, std::slice::from_ref(&source), &[true]).unwrap(); assert!(observed.apply(&mut s)); assert!(matches!(&s.project.item(id).unwrap().kind, ItemKind::Footage(f) if f.missing)); let result = apply(&mut s, vec![(source, Found::Moved(Box::new(recovered)))]); diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index 7065620af..e56e16a96 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -6,7 +6,8 @@ //! or PDF page an item shows and its interpretation (alpha, conformed frame rate, pixel aspect, //! fields, colour). A Photoshop layer is found again by name when layers were added or removed //! around it. The media layer then forgets the file and the layer cache is emptied, so the next -//! render reads the new pixels. +//! render reads the new pixels; File ▸ Reload Footage also deletes the disk cache entries made of +//! the file (#3184). //! //! The automatic part compares each footage file's size and modification time with what they //! were when the file was last seen. The desktop app keeps a [`Watcher`]: a worker thread that @@ -76,7 +77,6 @@ pub struct Watched { reload: BTreeSet, sources: Arc>, project: Option>, - cache_enabled: bool, reload_mode: String, stored_paths: crate::doc_path::StoredPaths, } @@ -85,8 +85,7 @@ impl PartialEq for Watched { /// Compare watched inputs, excluding unrelated project edits whose latest Arc is refreshed /// separately without waking the worker or restarting partial sequence scans. fn eq(&self, other: &Self) -> bool { - self.cache_enabled == other.cache_enabled - && self.stored_paths == other.stored_paths + self.stored_paths == other.stored_paths && self.reload_mode == other.reload_mode && self.reload == other.reload && (Arc::ptr_eq(&self.sources, &other.sources) || self.sources == other.sources) @@ -100,7 +99,6 @@ pub fn watched(s: &Session) -> Watched { reload: automatic_paths(s), sources: Arc::new(if s.check_footage_on_open { availability::sources(s) } else { Vec::new() }), project: Some(s.project.clone()), - cache_enabled: s.disk_cache.is_some(), reload_mode: s.prefs.import.auto_reload_footage.clone(), stored_paths: s.stored_paths.clone(), } @@ -139,10 +137,10 @@ pub struct Scan { } impl Scan { - /// Prepare availability and cache state before the session applies the observation. + /// Prepare the availability change before the session applies the observation. /// Automatic callers construct this result on the worker; explicit commands run inline. fn new(watched: Watched, observation: Observation, policy: ReloadPolicy) -> Self { - let update = watched.project.as_ref().and_then(|p| availability::prepare(p, &watched.sources, &observation.missing, watched.cache_enabled)); + let update = watched.project.as_ref().and_then(|p| availability::prepare(p, &watched.sources, &observation.missing)); Self { watched, observation, update, policy } } @@ -281,7 +279,17 @@ fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usi } let n = updates.len(); apply_reloads(s, updates, undoable)?; - forget(s, &paths); + forget_media(s, &paths); + // An explicit reload also reads files again that kept their size and time (the check on hit + // can't tell those apart): their entries in the disk cache go (#3184), when the cache is + // switched on if it is off now. An automatic one only follows a changed stamp, which the + // check catches. + if undoable { + match &s.disk_cache { + Some(dc) => dc.drop_files(paths.iter().map(String::as_str)), + None => s.pending_disk_drops.extend(paths), + } + } Ok((n, failed)) } @@ -320,14 +328,9 @@ fn apply_reloads(s: &mut Session, updates: Vec<(ItemId, Footage)>, undoable: boo Ok(()) } -/// Make the media layer and the layer cache let go of `paths`, so renders read them again. -fn forget(s: &mut Session, paths: &BTreeSet) { - forget_media(s, paths); - s.refresh_disk_salt(true); -} - -/// Invalidate decoded and rendered buffers without probing the disk for a new salt. -/// Availability updates already carry their worker-computed salt; content reload refreshes it. +/// Make the media layer and the layer cache let go of `paths`, so renders read them again. Disk +/// cache entries check the files they were made of when read (#3184); File ▸ Reload Footage +/// also drops the entries of its files ([`reload`]). pub(crate) fn forget_media(s: &mut Session, paths: &BTreeSet) { for p in paths { s.footage.forget(p); @@ -374,7 +377,7 @@ pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option)>) -> crate return Ok(json!({"files": 0, "reloaded": 0})); } // Changed frames of image sequences (their items keep their metadata). - forget(s, &changed); + forget_media(s, &changed); let files = changed.len(); s.toast(if reloaded > 0 { format!("Reloaded {reloaded} footage item(s) changed on disk") @@ -391,7 +394,6 @@ pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result { reload: reload_paths(s), sources: Arc::new(availability::sources(s)), project: Some(s.project.clone()), - cache_enabled: s.disk_cache.is_some(), reload_mode: s.prefs.import.auto_reload_footage.clone(), stored_paths: s.stored_paths.clone(), }; @@ -452,12 +454,7 @@ impl Watcher { pub fn update(&mut self, s: &Session) { let same_sources = availability::sources_match(s, &self.paths.sources); let same_data = self.paths.project.as_ref().is_some_and(|before| data_paths(before).eq(data_paths(&s.project))); - if same_sources - && same_data - && self.paths.stored_paths == s.stored_paths - && self.paths.reload_mode == s.prefs.import.auto_reload_footage - && self.paths.cache_enabled == s.disk_cache.is_some() - { + if same_sources && same_data && self.paths.stored_paths == s.stored_paths && self.paths.reload_mode == s.prefs.import.auto_reload_footage { let project = Some(s.project.clone()); self.paths.project = project.clone(); self.pending.lock().unwrap_or_else(PoisonError::into_inner).paths.project = project; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e50b15d04..599aba1b7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -97,7 +97,7 @@ pub use error::{EngineError, Result}; pub use event::Event; pub use fonts::{FontRow, font_features, font_menu, font_preview, font_styles, text_families}; pub use services::{FsServices, Importer, Services}; -pub use session::{DiskSaltJob, upgrade_effects}; +pub use session::upgrade_effects; pub struct Session { pub project: Arc, @@ -171,6 +171,9 @@ pub struct Session { pub crypto_probe: Arc>, /// The persistent disk cache (Settings ▸ Media & Disk Cache), when enabled. pub disk_cache: Option>, + /// Files File ▸ Reload Footage reloaded while the disk cache was off: their entries are + /// dropped when it is switched on ([`Session::configure_disk_cache`]), before it is used. + pub pending_disk_drops: std::collections::BTreeSet, /// Settings (Preferences). pub prefs: prefs::Prefs, /// Bumped whenever settings change (frontends re-apply theme, labels…). @@ -254,14 +257,6 @@ pub struct Session { /// Each footage file's size and modification time when last seen (Settings ▸ Import ▸ /// Reload Footage Changed on Disk, [`footage_reload`]). pub footage_stamps: std::collections::HashMap, - /// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]). - pub disk_salt: Option<(u64, u64)>, - /// Take the disk cache's footage salt on a worker thread ([`Session::refresh_disk_salt`]): - /// frontends with an event loop (the desktop app) turn this on and call - /// [`Session::poll_disk_salt`] each frame; headless sessions take it at once. - pub disk_salt_background: bool, - /// The footage salt being taken in the background. - pub disk_salt_job: Option, /// The footage files effects read the channels of, as last given to the footage source /// ([`Session::refresh_aux_footage`]); `None` = not given yet. pub aux_footage: Option>, @@ -356,6 +351,7 @@ impl Default for Session { layer_cache: Arc::new(LayerCache::default()), crypto_probe: Default::default(), disk_cache: None, + pending_disk_drops: Default::default(), prefs: prefs::Prefs::default(), prefs_revision: 0, config: None, @@ -391,9 +387,6 @@ impl Default for Session { check_footage_on_open: false, stored_paths: Default::default(), footage_stamps: Default::default(), - disk_salt: None, - disk_salt_background: false, - disk_salt_job: None, aux_footage: None, script_calls: ScriptCalls::default(), } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 31ed40c1d..18b0ff4cf 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -10,4 +10,4 @@ mod dispatch; mod edit; mod render; -pub use edit::{DiskSaltJob, upgrade_effects}; +pub use edit::upgrade_effects; diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 28813e2ec..67a8e4dcb 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -97,114 +97,10 @@ impl Session { if Arc::ptr_eq(&self.project, &self.saved_project) { self.saved_revision = self.revision; } - self.refresh_disk_salt(false); self.refresh_aux_footage(); self.events.push(Event::ProjectChanged { revision: self.revision }); } - /// Salt the disk cache's layer keys with the project's footage files - /// ([`effectcraft_render::disk_cache::footage_salt`]) and attach the disk cache to the layer - /// cache with it. Taking it reads the size and time of every file, every frame of an image - /// sequence included, so it is taken again only when the footage items changed, or with - /// `force` (a project opened, footage reloaded, the disk cache attached), not at every edit: - /// with a few large sequences that cost each keystroke in a text field over half a second - /// (#1926). - /// - /// With [`Session::disk_salt_background`] the files are read on a worker thread: an imported - /// or opened project with thousands of sequence frames on a network share took half a minute - /// to stamp, all of it on the UI thread. Until [`Session::poll_disk_salt`] picks the salt up, - /// renders use the memory cache only (keys under the old salt may name other files, or - /// another project's items) and this returns `None`. - pub fn refresh_disk_salt(&mut self, force: bool) -> Option { - let Some(dc) = self.disk_cache.clone() else { - self.disk_salt = None; - self.disk_salt_job = None; - return None; - }; - self.poll_disk_salt(); - let footage = Self::footage_fingerprint(&self.project); - if !force { - if let Some((seen, salt)) = self.disk_salt - && seen == footage - { - return Some(salt); - } - if self.disk_salt_job.as_ref().is_some_and(|j| j.footage == footage) { - return None; - } - } - if self.disk_salt_background && !cfg!(target_arch = "wasm32") { - let project = self.project.clone(); - let slot: Arc>> = Arc::default(); - let out = slot.clone(); - let spawned = std::thread::Builder::new().name("ec-disk-salt".into()).spawn(move || { - let salt = effectcraft_render::disk_cache::footage_salt(&project); - if let Ok(mut s) = out.lock() { - *s = Some(salt); - } - }); - // Without a thread the salt is taken here, as before. - if let Ok(thread) = spawned { - self.disk_salt = None; - self.disk_salt_job = Some(DiskSaltJob { footage, slot, thread }); - self.layer_cache.set_disk(None); - return None; - } - } - self.disk_salt_job = None; - let salt = effectcraft_render::disk_cache::footage_salt(&self.project); - self.disk_salt = Some((footage, salt)); - self.layer_cache.set_disk(Some((dc, salt))); - Some(salt) - } - - /// Pick up a footage salt taken in the background ([`Session::refresh_disk_salt`]) and attach - /// the disk cache with it. Returns whether one is still being taken (frontends call this each - /// frame while it is). - pub fn poll_disk_salt(&mut self) -> bool { - let Some(job) = &self.disk_salt_job else { return false }; - // (looked at first: a worker that has finished has left its salt in the slot) - let finished = job.thread.is_finished(); - let salt = job.slot.lock().ok().and_then(|mut s| s.take()); - let footage = job.footage; - match salt { - Some(salt) => { - self.disk_salt_job = None; - if let Some(dc) = self.disk_cache.clone() { - self.disk_salt = Some((footage, salt)); - self.layer_cache.set_disk(Some((dc, salt))); - } - false - } - None if finished => { - // The worker stopped without a salt: the disk cache stays off until the next - // edit takes it again. - log::warn!("disk cache: the footage files could not be stamped; the disk cache is off until the next edit"); - self.disk_salt_job = None; - false - } - None => true, - } - } - - /// In-memory identity of the footage settings represented by a disk salt. Workers use the - /// same fingerprint when preparing availability changes without disk access on the UI. - pub(crate) fn footage_fingerprint(project: &effectcraft_project::Project) -> u64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - for (id, item) in &project.items { - if let effectcraft_project::ItemKind::Footage(f) = &item.kind { - id.hash(&mut h); - format!("{f:?}").hash(&mut h); - } - if let Some(proxy) = &item.proxy { - id.hash(&mut h); - format!("{proxy:?}").hash(&mut h); - } - } - h.finish() - } - /// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes /// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay /// valid ([`effectcraft_project::Comp::same_pixels`]). @@ -322,11 +218,6 @@ impl Session { } self.layer_cache.clear(); self.project = Arc::new(p); - // Its files may have changed since they were last stamped. - self.disk_salt = None; - // A salt still being taken is for the old project's files: dropped, so that the next refresh - // stamps the new project anew (a matching fingerprint would otherwise keep the stale job). - self.disk_salt_job = None; // The set of footage effects read the channels of is sent again, even when it is empty. self.aux_footage = None; self.history = History::default(); @@ -389,11 +280,3 @@ pub fn upgrade_effects(p: &mut Project) { p.next_id = next; p.schema = effectcraft_project::SCHEMA_VERSION; } - -/// A footage salt being taken on a worker thread ([`Session::refresh_disk_salt`]). -pub struct DiskSaltJob { - /// The footage it is taken for ([`Session::footage_fingerprint`]). - footage: u64, - slot: Arc>>, - thread: std::thread::JoinHandle<()>, -} diff --git a/crates/engine/src/settings/session_settings.rs b/crates/engine/src/settings/session_settings.rs index 056616201..8ade9f8a3 100644 --- a/crates/engine/src/settings/session_settings.rs +++ b/crates/engine/src/settings/session_settings.rs @@ -137,10 +137,6 @@ impl Session { // over the store of a roto test running beside it. Serialise them with the roto tests. #[cfg(test)] crate::tests::roto::hold_roto_cache_test_lock(); - // Another setting changed (the disk cache stays as it is): its salt still holds, and - // stamping every footage file again would cost each settings change seconds with large - // sequences. - let kept = self.disk_cache.as_ref().is_some_and(|c| c.folder() == folder.as_path()); let dc = match &self.disk_cache { Some(c) if c.folder() == folder.as_path() => { c.set_max_bytes(max); @@ -162,11 +158,17 @@ impl Session { } }, }; + // Files reloaded while the cache was off: their entries go before anything is read from + // it (until the writer has deleted them they are misses, and no frame is linked). + if !self.pending_disk_drops.is_empty() { + dc.drop_files(std::mem::take(&mut self.pending_disk_drops)); + } self.disk_cache = Some(dc.clone()); // The reserve applies from the start (a new folder's watcher is idle). self.poll_disk_space(); - // Attaches the disk cache to the layer cache once the footage salt is known. - self.refresh_disk_salt(!kept); + // Used at once (no file is looked at first: each entry checks the source files it lists + // when it is read, #3184). + self.layer_cache.set_disk(Some(dc.clone())); // Roto Brush / Object Matte segmentations persist there too (held weakly: switching the // disk cache off drops it). let store: std::sync::Arc = dc.clone(); diff --git a/crates/engine/src/tests/crypto_pick.rs b/crates/engine/src/tests/crypto_pick.rs index e3a8cd50c..82fe21e71 100644 --- a/crates/engine/src/tests/crypto_pick.rs +++ b/crates/engine/src/tests/crypto_pick.rs @@ -214,6 +214,7 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap(); let (px, py) = buf.to_px([x, y]); diff --git a/crates/engine/src/tests/disk_cache.rs b/crates/engine/src/tests/disk_cache.rs index 4805d68fe..4c610dceb 100644 --- a/crates/engine/src/tests/disk_cache.rs +++ b/crates/engine/src/tests/disk_cache.rs @@ -76,11 +76,13 @@ fn disk_cache_settings_persistence_and_invalidation() { assert!(s2.layer_cache.disk().is_none()); } +/// The comp key comes from the project in memory: a footage file that changes on disk doesn't +/// change it (the entries check their own files, #3184); edits that change pixels do. #[test] -fn footage_changes_change_the_salt() { - let dir = folder("salt"); +fn comp_content_keys_come_from_memory() { + let dir = folder("keys"); let mut s = session(&dir); - let foot = std::env::temp_dir().join(format!("effectcraft-salt-{}.png", std::process::id())); + let foot = std::env::temp_dir().join(format!("effectcraft-keys-{}.png", std::process::id())); std::fs::write(&foot, b"one").unwrap(); s.edit("add", None, |p, _| { let f = effectcraft_project::Footage { @@ -108,12 +110,10 @@ fn footage_changes_change_the_salt() { Ok(()) }) .unwrap(); - let a = effectcraft_render::disk_cache::footage_salt(&s.project); - std::fs::write(&foot, b"two, longer").unwrap(); - let b = effectcraft_render::disk_cache::footage_salt(&s.project); - assert_ne!(a, b); let cid = project(&mut s); let k1 = effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + std::fs::write(&foot, b"two, longer").unwrap(); + assert_eq!(effectcraft_render::disk_cache::comp_content_key(&s.project, cid), k1, "no file is looked at"); s.execute("prop.set", json!({"layer": "#1", "path": "transform/opacity", "value": 50})).unwrap(); let k2 = effectcraft_render::disk_cache::comp_content_key(&s.project, cid); assert_ne!(k1, k2); @@ -137,66 +137,51 @@ fn footage_changes_change_the_salt() { assert_ne!(set_proxy(&mut s, false), k3); } -/// The desktop app stamps footage files for the disk cache's salt on a worker thread: importing an -/// After Effects project with thousands of sequence frames on a network share stamped them all -/// inside the import's edit, freezing the UI for half a minute. Until the salt lands the disk -/// cache sits out (keys under the old salt may name other files); a newer footage change -/// supersedes a salt still being taken; a settings change keeps the salt. +/// No salt and no scan (#3184, replacing the background footage salt of #3106): the disk cache +/// is attached to the layer cache as soon as it is switched on, and stays attached through +/// edits that add footage (a large image sequence included), settings changes and opening a +/// project, none of which looks at a single file for it. The first render may use it at once. #[test] -fn footage_salt_is_taken_off_the_ui_thread_and_the_disk_cache_waits_for_it() { - let dir = folder("bg-salt"); +fn the_disk_cache_is_attached_at_once_and_edits_do_no_file_work() { + let dir = folder("attach"); let mut s = session(&dir); - s.disk_salt_background = true; - let at = |n: &str| std::env::temp_dir().join(format!("effectcraft-bg-salt-{}-{n}.png", std::process::id())); - let add = |s: &mut Session, path: &std::path::Path| { - let f = effectcraft_project::Footage { - path: path.to_string_lossy().to_string(), - kind: effectcraft_project::FootageKind::Still, - width: 1, - height: 1, - has_video: true, - ..Default::default() - }; - s.edit("add", None, |p, _| { - p.add_item("f.png", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)); - Ok(()) - }) - .unwrap(); + let dc = s.disk_cache.clone().unwrap(); + let attached = |s: &Session| s.layer_cache.disk().is_some_and(|d| std::sync::Arc::ptr_eq(&d, &dc)); + assert!(attached(&s), "attached at once"); + // A sequence of 5000 frames that don't even exist. + let seq_dir = std::env::temp_dir().join(format!("effectcraft-no-scan-{}", std::process::id())); + let f = effectcraft_project::Footage { + path: seq_dir.join("f_00000.exr").to_string_lossy().to_string(), + kind: effectcraft_project::FootageKind::Sequence, + sequence: (0..5000).map(|i| seq_dir.join(format!("f_{i:05}.exr")).to_string_lossy().to_string()).collect(), + width: 8, + height: 8, + has_video: true, + ..Default::default() }; - let wait = |s: &mut Session| { - let t0 = std::time::Instant::now(); - while s.poll_disk_salt() { - assert!(t0.elapsed().as_secs() < 20, "the salt never landed"); - std::thread::sleep(std::time::Duration::from_millis(5)); - } - }; - assert!(s.layer_cache.disk().is_some(), "attached with its salt"); - std::fs::write(at("a"), b"one").unwrap(); - add(&mut s, &at("a")); - // The edit returned before the files were stamped, and the disk cache waits for the salt. - assert!(s.disk_salt.is_none() && s.layer_cache.disk().is_none()); - // Edits that leave the footage alone don't start over (and may pick up the salt meanwhile). - s.execute("comp.new", json!({"name": "C", "width": 8, "height": 8, "frameRate": 30, "duration": 1})).unwrap(); - let r = s.refresh_disk_salt(false); - assert!(r.is_none() || r == Some(effectcraft_render::disk_cache::footage_salt(&s.project)), "{r:?}"); - // New footage while it is taken: the salt for the newest footage lands. - std::fs::write(at("b"), b"two").unwrap(); - add(&mut s, &at("b")); - wait(&mut s); - let salt = effectcraft_render::disk_cache::footage_salt(&s.project); - assert_eq!(s.disk_salt.map(|(_, salt)| salt), Some(salt)); - assert_eq!(s.refresh_disk_salt(false), Some(salt)); - assert!(s.layer_cache.disk().is_some(), "attached again"); - // Another setting keeps the salt and the disk cache attached. + s.edit("add", None, |p, _| { + p.add_item("seq", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)); + Ok(()) + }) + .unwrap(); + assert!(attached(&s), "an import keeps it attached"); + let cid = project(&mut s); + s.execute("prop.set", json!({"layer": "#1", "path": "transform/opacity", "value": 50})).unwrap(); s.execute_checked("prefs.set", json!({"key": "disk.diskCacheMaxGb", "value": 30})).unwrap(); - assert!(s.layer_cache.disk().is_some() && s.disk_salt.map(|(_, salt)| salt) == Some(salt)); - // Turned off and on again: stamped anew, in the background. + assert!(attached(&s), "edits and other settings keep it attached"); + let same = (*s.project).clone(); + s.replace_project(same, None); + assert!(attached(&s), "opening a project keeps it attached"); + // The first render uses it: a solid reads no file, so nothing is checked either. + s.render(cid, s.time(), RenderOpts::default()); + dc.flush(); + assert!(dc.stats().entries > 0, "written by the first render"); + assert_eq!(dc.file_checks(), 0, "no file looked at for the disk cache"); + // Switched off and on: attached again at once. s.execute_checked("prefs.set", json!({"key": "disk.diskCacheEnabled", "value": false})).unwrap(); + assert!(s.layer_cache.disk().is_none()); s.execute_checked("prefs.set", json!({"key": "disk.diskCacheEnabled", "value": true})).unwrap(); - wait(&mut s); - assert_eq!(s.refresh_disk_salt(false), Some(salt)); - let _ = std::fs::remove_file(at("a")); - let _ = std::fs::remove_file(at("b")); + assert!(s.layer_cache.disk().is_some()); } /// Marker edits keep the disk key (the sound of a playing preview must not wait for frames to @@ -258,38 +243,227 @@ fn protected_markers_with_a_duration_change_the_content_key() { assert_ne!(key(&s), k1, "its duration"); } -/// A project opened while a footage salt is still being taken must not keep that salt when it -/// names the same footage: its files may have changed since the worker read them. +/// With expressions the comp key covers what they can read, not the whole project (#3184, +/// #1492): an expression reading only its own comp keeps the key through edits of another comp +/// and of unused footage; `comp("Other")` makes "Other" count (and which comps have that name), +/// not a third comp; a computed name counts every item. #[test] -fn opening_a_project_drops_the_footage_salt_being_taken() { - let dir = folder("bg-salt-open"); - let mut s = session(&dir); - s.disk_salt_background = true; - let path = std::env::temp_dir().join(format!("effectcraft-bg-salt-open-{}.png", std::process::id())); - std::fs::write(&path, b"one").unwrap(); - let f = effectcraft_project::Footage { - path: path.to_string_lossy().to_string(), - kind: effectcraft_project::FootageKind::Still, - width: 1, - height: 1, - has_video: true, - ..Default::default() +fn expressions_reach_only_what_they_name() { + let mut s = Session::default(); + let comp = |s: &mut Session, name: &str| { + let c = s.execute("comp.new", json!({"name": name, "width": 8, "height": 8, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let l = s.execute("layer.newSolid", json!({"comp": c, "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + (effectcraft_project::ItemId(c), (c, l)) }; - s.edit("add", None, |p, _| { - p.add_item("f.png", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)); + let (main, ml) = comp(&mut s, "Main"); + let (_, ol) = comp(&mut s, "Other"); + let (third, tl) = comp(&mut s, "Third"); + let unused = s.edit("add", None, |p, _| { + let f = effectcraft_project::Footage { path: "/nowhere/a.png".into(), has_video: true, ..Default::default() }; + Ok(p.add_item("a.png", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f))) + }); + let unused = unused.unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, main); + let expr = |s: &mut Session, (comp, layer): (u64, u64), text: &str| { + s.execute("prop.setExpression", json!({"comp": comp, "layer": layer, "path": "transform/opacity", "expression": text})).unwrap(); + }; + let opacity = |s: &mut Session, (comp, layer): (u64, u64), v: f64| { + s.execute("prop.set", json!({"comp": comp, "layer": layer, "path": "transform/opacity", "value": v})).unwrap(); + }; + let repath = |s: &mut Session, path: &str| { + let path = path.to_string(); + s.edit("repath", None, move |p, _| { + if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(unused).map(|i| &mut i.kind) { + f.path = path; + } + Ok(()) + }) + .unwrap(); + }; + // Its own comp only. + expr(&mut s, ml, "thisComp.layer(1).transform.rotation + wiggle(1, 1)[0]"); + expr(&mut s, ol, "wiggle(2, 10)"); + let k0 = key(&s); + opacity(&mut s, ol, 40.0); + expr(&mut s, tl, "time * 10"); + repath(&mut s, "/nowhere/b.png"); + s.execute("comp.addMarker", json!({"comp": third.0, "time": 0.5})).unwrap(); + assert_eq!(key(&s), k0, "edits elsewhere keep the key"); + // An edit in the comp itself changes it. + opacity(&mut s, ml, 30.0); + assert_ne!(key(&s), k0); + // `comp("Other")`: Other counts, Third doesn't. + expr(&mut s, ml, "comp(\"Other\").layer(1).transform.opacity"); + let k1 = key(&s); + expr(&mut s, ol, "wiggle(3, 10)"); + let k2 = key(&s); + assert_ne!(k2, k1, "the comp it names"); + opacity(&mut s, tl, 20.0); + repath(&mut s, "/nowhere/c.png"); + assert_eq!(key(&s), k2, "a comp it doesn't name"); + // Another comp taking the name changes what it may read. + s.edit("rename", None, |p, _| { + p.item_mut(third).unwrap().name = "Other".into(); Ok(()) }) .unwrap(); - // The file changes after the edit started the salt, before the same footage is opened again. - std::fs::write(&path, b"a longer content").unwrap(); - let same = (*s.project).clone(); - s.replace_project(same, None); - let t0 = std::time::Instant::now(); - while s.poll_disk_salt() { - assert!(t0.elapsed().as_secs() < 20, "the salt never landed"); - std::thread::sleep(std::time::Duration::from_millis(5)); + let k3 = key(&s); + assert_ne!(k3, k2, "a second comp named Other"); + // A computed name reaches every item. + expr(&mut s, ml, "comp(\"Oth\" + \"er\").layer(1).transform.opacity"); + let k4 = key(&s); + repath(&mut s, "/nowhere/d.png"); + assert_ne!(key(&s), k4, "unused footage counts with a computed name"); +} + +/// Markers count only for the comps whose expressions can read them (#1083): an expression in +/// another comp that reads markers leaves this comp's markers out of its key. +#[test] +fn markers_count_only_where_an_expression_can_read_them() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap(); + let cid = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + let other = s.execute("comp.new", json!({"name": "N", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let ol = s.execute("layer.newSolid", json!({"comp": other, "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"comp": other, "layer": ol, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + let k0 = key(&s); + s.execute("comp.addMarker", json!({"comp": cid.0, "time": 0.5, "comment": "beat"})).unwrap(); + assert_eq!(key(&s), k0, "no expression of M reads its markers"); + s.execute("prop.setExpression", json!({"comp": other, "layer": ol, "path": "transform/opacity", "expression": "comp(\"M\").marker.numKeys"})).unwrap(); + let other_key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, effectcraft_project::ItemId(other)); + let n0 = other_key(&s); + s.execute("comp.addMarker", json!({"comp": cid.0, "time": 1.0})).unwrap(); + assert_ne!(other_key(&s), n0, "N reads M's markers"); + assert_eq!(key(&s), k0, "M itself still doesn't"); +} + +/// File ▸ Reload Footage drops the disk entries made of the item's files (#3184): a file +/// replaced by one with the same size and modification time is read again (the check on hit +/// can't tell), and entries of other files stay. +#[test] +fn reload_footage_drops_the_entries_of_its_files() { + use crate::render::reads::{FileStamp, ReadFile, ReadFiles}; + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> std::result::Result { + Ok(effectcraft_project::Footage { + path: path.into(), + kind: effectcraft_project::FootageKind::Still, + width: 1, + height: 1, + has_video: true, + ..Default::default() + }) + } } - let now = effectcraft_render::disk_cache::footage_salt(&s.project); - assert_eq!(s.disk_salt.map(|(_, salt)| salt), Some(now), "the salt reflects the file as it is now"); - let _ = std::fs::remove_file(&path); + let dir = folder("reload"); + let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": &dir, "disk.diskCacheEnabled": true}})).unwrap(); + let src = std::path::Path::new(&dir).join("src"); + std::fs::create_dir_all(&src).unwrap(); + let mut add = |name: &str| { + let p = src.join(name); + std::fs::write(&p, b"aa").unwrap(); + let path = p.to_string_lossy().to_string(); + let f = effectcraft_project::Footage { + path: path.clone(), + kind: effectcraft_project::FootageKind::Still, + width: 1, + height: 1, + has_video: true, + ..Default::default() + }; + let id = s.edit("add", None, |p, _| Ok(p.add_item(name, effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)))).unwrap(); + (id, path) + }; + let (a, a_path) = add("a.png"); + let (_, b_path) = add("b.png"); + let dc = s.disk_cache.clone().unwrap(); + let read = |p: &str| -> ReadFiles { [ReadFile::new(p, FileStamp::native(p).unwrap())].into_iter().collect() }; + dc.put_frame_now(1, 1, 1, &[1; 4], &read(&a_path)); + dc.put_frame_now(2, 1, 1, &[2; 4], &read(&b_path)); + dc.put_layer_now(3, &effectcraft_effects::Buf { img: effectcraft_raster::Image::new(1, 1), offset: [0.0; 2], scale: 1.0 }, &read(&a_path)); + // Other bytes, same size, the old time back. + let t = std::fs::metadata(&a_path).unwrap().modified().unwrap(); + std::fs::write(&a_path, b"AA").unwrap(); + std::fs::File::options().append(true).open(&a_path).unwrap().set_modified(t).unwrap(); + assert!(dc.contains(crate::render::disk_cache::Kind::Frame, 1)); + s.execute_checked("file.reloadFootage", json!({"item": a.0})).unwrap(); + dc.flush(); + assert!(dc.get_frame(1, None).is_none(), "the reloaded file's frame renders again"); + assert!(dc.get_layer(3, None).is_none(), "and its layer buffer"); + assert!(dc.get_frame(2, None).is_some(), "another file's frame stays"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Reload Footage while the disk cache is off (#3184): when it is switched on later in the +/// session, the entries made of the reloaded files are dropped before anything is read from it +/// (a file replaced by one with the same size and time would pass the check on hit); entries of +/// other files stay. +#[test] +fn reload_footage_while_the_disk_cache_is_off_drops_its_entries_when_it_is_on() { + use crate::render::disk_cache::{DiskCache, Kind}; + use crate::render::reads::{FileStamp, ReadFile, ReadFiles}; + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> std::result::Result { + Ok(effectcraft_project::Footage { + path: path.into(), + kind: effectcraft_project::FootageKind::Still, + width: 1, + height: 1, + has_video: true, + ..Default::default() + }) + } + } + let dir = folder("reload-off"); + let src = std::path::Path::new(&dir).join("src"); + std::fs::create_dir_all(&src).unwrap(); + let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + let mut add = |name: &str| { + let p = src.join(name); + std::fs::write(&p, b"aa").unwrap(); + let path = p.to_string_lossy().to_string(); + let f = effectcraft_project::Footage { + path: path.clone(), + kind: effectcraft_project::FootageKind::Still, + width: 1, + height: 1, + has_video: true, + ..Default::default() + }; + let id = s.edit("add", None, |p, _| Ok(p.add_item(name, effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)))).unwrap(); + (id, path) + }; + let (a, a_path) = add("a.png"); + let (_, b_path) = add("b.png"); + // Entries from an earlier session in the cache folder. + let read = |p: &str| -> ReadFiles { [ReadFile::new(p, FileStamp::native(p).unwrap())].into_iter().collect() }; + { + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_frame_now(1, 1, 1, &[1; 4], &read(&a_path)); + dc.put_frame_now(2, 1, 1, &[2; 4], &read(&b_path)); + dc.put_layer_now(3, &effectcraft_effects::Buf { img: effectcraft_raster::Image::new(1, 1), offset: [0.0; 2], scale: 1.0 }, &read(&a_path)); + } + // Other bytes, same size, the old time back; reloaded while the cache is off. + let t = std::fs::metadata(&a_path).unwrap().modified().unwrap(); + std::fs::write(&a_path, b"AA").unwrap(); + std::fs::File::options().append(true).open(&a_path).unwrap().set_modified(t).unwrap(); + assert!(s.disk_cache.is_none()); + s.execute_checked("file.reloadFootage", json!({"item": a.0})).unwrap(); + // Switched on: the reloaded file's entries are misses from the start, and then gone. + s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": &dir, "disk.diskCacheEnabled": true}})).unwrap(); + let dc = s.disk_cache.clone().unwrap(); + assert!(dc.get_frame(1, None).is_none(), "the reloaded file's frame renders again"); + assert!(dc.get_layer(3, None).is_none(), "and its layer buffer"); + dc.flush(); + assert!(!dc.contains(Kind::Frame, 1) && !dc.contains(Kind::Layer, 3)); + assert!(dc.get_frame(2, None).is_some(), "another file's frame stays"); + assert!(s.pending_disk_drops.is_empty(), "applied once"); + drop(dc); + drop(s); + let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index db64a9066..634d84e02 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -40,7 +40,17 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { fn frame(s: &Session, id: u64) -> (Vec3, Vec3) { let c = cid(s); let comp = s.project.comp(c).unwrap(); - let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { + project: &s.project, + comp_id: c, + comp, + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + reads: None, + }; let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()); (e, f) } @@ -244,7 +254,7 @@ fn parented_camera_keeps_its_frame_under_an_offset_null() { fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) { let c = cid(s); let comp = s.project.comp(c).unwrap(); - let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[], reads: None }; layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()) } diff --git a/crates/engine/src/tests/sequence.rs b/crates/engine/src/tests/sequence.rs index c5faa232b..b084e6b36 100644 --- a/crates/engine/src/tests/sequence.rs +++ b/crates/engine/src/tests/sequence.rs @@ -269,30 +269,31 @@ fn dpx_and_cineon_runs_import_as_sequences() { assert_eq!(got, [("plate.[0001-0003].dpx".to_string(), 3), ("scan_[01-02].cin".to_string(), 2)], "{r}"); } -/// #1926: an edit that leaves the footage alone doesn't stamp every frame of its image sequences -/// again for the disk cache's salt (with large sequences each keystroke in a text field took over -/// half a second); Reload Footage does, and so does new footage. +/// #1926, #3184: an edit does no file work at all for the disk cache (it used to stamp every +/// frame of the project's image sequences for a salt; with large sequences each keystroke in a +/// text field took over half a second): the cache stays attached and no file is looked at, after +/// edits, after a frame of the sequence was rewritten, after Reload Footage and new footage. +/// Entries check the files they list when they are read instead. #[test] -fn edits_keep_the_disk_salt_and_reload_stamps_the_files_again() { +fn edits_do_no_disk_cache_file_work() { let dir = folder(&RUN); - let cache = std::env::temp_dir().join(format!("ec-seq-salt-{}", std::process::id())); + let cache = std::env::temp_dir().join(format!("ec-seq-nofiles-{}", std::process::id())); let _ = std::fs::remove_dir_all(&cache); let mut s = session(); s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": cache.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap(); + let dc = s.disk_cache.clone().expect("a disk cache"); + let attached = |s: &Session| s.layer_cache.disk().is_some_and(|d| std::sync::Arc::ptr_eq(&d, &dc)); s.execute("comp.new", json!({"name": "C", "width": 64, "height": 32, "frameRate": 30, "duration": 1})).unwrap(); let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": true, "addToComp": true})).unwrap(); let item = r["items"][0].clone(); - let salt = s.refresh_disk_salt(false).expect("a disk cache"); // A frame rewritten in place: same footage settings, another size on disk. std::fs::write(at(&dir, "shot_0004.png"), b"a new render of this frame").unwrap(); s.execute("layer.newText", json!({"text": "title"})).unwrap(); s.execute("layer.setText", json!({"text": "title 2"})).unwrap(); - assert_eq!(s.refresh_disk_salt(false), Some(salt), "edits don't stamp the files again"); s.execute("file.reloadFootage", json!({"items": [item]})).unwrap(); - let reloaded = s.refresh_disk_salt(false).unwrap(); - assert_ne!(reloaded, salt, "Reload Footage stamps them again"); std::fs::write(at(&dir, "extra.png"), b"").unwrap(); s.execute("file.import", json!({"paths": [at(&dir, "extra.png")]})).unwrap(); - assert_ne!(s.refresh_disk_salt(false), Some(reloaded), "new footage counts"); + assert!(attached(&s), "attached all along"); + assert_eq!(dc.file_checks(), 0, "no file looked at for the disk cache"); let _ = std::fs::remove_dir_all(&cache); } diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index fa2993f97..43400ea1d 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -297,6 +297,7 @@ impl<'a> Resolver<'a> { } self.comp(c)?.layer(LayerId(l)) } + /// The evaluation context for comp `c` at time `t` seconds, or `None` when the comp does not exist. fn ctx_at(&self, c: u64, t: f64) -> Option> { let comp = self.comp(c)?; let same_comp = c == self.ctx.comp_id.0; @@ -309,6 +310,7 @@ impl<'a> Resolver<'a> { footage: self.ctx.footage, property_overrides: if same_comp { self.ctx.property_overrides } else { &[] }, media_overrides: if same_comp { self.ctx.media_overrides } else { &[] }, + reads: self.ctx.reads, }) } fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> { diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index 9e78d250f..ae9e647f8 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -172,6 +172,7 @@ fn evaluate_out( footage: ctx.footage, property_overrides: ctx.property_overrides, media_overrides: ctx.media_overrides, + reads: ctx.reads, }; let comp = ctx.comp_id.0; let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid)); diff --git a/crates/expr/src/sample.rs b/crates/expr/src/sample.rs index 28f830bb2..38f617289 100644 --- a/crates/expr/src/sample.rs +++ b/crates/expr/src/sample.rs @@ -4,13 +4,16 @@ //! expression (its footage source; [`EvalCtx::footage`]): source → masks → effects for //! `postEffect = true`, the bare source otherwise, in layer space (before the transform). Buffers //! are cached per thread for the frame, so many expressions sampling the same layer at the same -//! time render it once. +//! time render it once. The source files a sampled render reads are kept with its buffer and +//! noted in the evaluating render's log ([`EvalCtx::reads`]) on every use, so a frame whose +//! expressions sample footage lists that footage too (#3184). use std::cell::RefCell; use std::collections::HashMap; use std::sync::Arc; use effectcraft_project::{ItemId, Layer}; +use effectcraft_render::reads::{ReadFiles, ReadLog}; use effectcraft_render::{EvalCtx, NoFootage, RenderOpts, Renderer}; use effectcraft_time::Tick; @@ -26,7 +29,8 @@ struct Sampled { type Key = (usize, u64, u64, u64, bool, u64); thread_local! { - static CACHE: RefCell>> = RefCell::new(HashMap::new()); + /// The sampled buffers and the source files each was made of. + static CACHE: RefCell, Arc)>> = RefCell::new(HashMap::new()); /// Nested sampleImage renders (a sampled layer whose expressions sample again). static DEPTH: RefCell = const { RefCell::new(0) }; } @@ -42,13 +46,21 @@ fn fingerprint(layer: &Layer) -> u64 { h.finish() } +/// Renders `layer` of `comp` at time `t` for an expression sample, before or after effects as `post` says. +/// Returns `None` once the nesting depth limit is reached. fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Option { let depth = DEPTH.with(|d| *d.borrow()); if depth >= MAX_DEPTH { return None; } let key: Key = (ctx.project as *const _ as usize, comp.0, layer.id.0, t.to_bits(), post, fingerprint(layer)); - if let Some(hit) = CACHE.with(|c| c.borrow().get(&key).cloned()) { + let note = |files: &ReadFiles| { + if let Some(log) = ctx.reads { + log.note_all(files); + } + }; + if let Some((hit, files)) = CACHE.with(|c| c.borrow().get(&key).cloned()) { + note(&files); return hit; } DEPTH.with(|d| *d.borrow_mut() += 1); @@ -60,20 +72,24 @@ fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Opti } let _pop = Pop; let footage: &dyn effectcraft_render::FootageSource = ctx.footage.unwrap_or(&NoFootage); + let log = ReadLog::new(); let mut r = Renderer::new(ctx.project, footage, RenderOpts { motion_blur: false, ..Default::default() }); r.expr = ctx.expr; + r.reads = Some(&log); let ectx = r.eval_ctx(comp, Tick::from_seconds_f64(t))?; let out = if post { r.content_buf(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale }) } else { r.layer_source(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img), offset: b.offset, scale: b.scale }) }; + let files = Arc::new(log.into_files()); + note(&files); CACHE.with(|c| { let mut c = c.borrow_mut(); if c.len() >= MAX_CACHE { c.clear(); } - c.insert(key, out.clone()); + c.insert(key, (out.clone(), files)); }); out } diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index a7d2489c7..050a5a550 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1332,6 +1332,7 @@ fn source_dimensions_follow_media_replacement() { footage: None, property_overrides: &[], media_overrides: &[media], + reads: None, }; assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0)); } @@ -1554,6 +1555,7 @@ fn text_expression_selector_values() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let doc = effectcraft_render::text::source_text(&ctx, l).unwrap(); let lay = effectcraft_text::layout_doc(&doc); @@ -1606,6 +1608,43 @@ fn sample_image_reads_rendered_pixels() { assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0); } +/// The footage `sampleImage` renders is a file the evaluating render read (#3184): it is noted in +/// the render's log, also when the sampled buffer comes from the per-thread cache. +#[test] +fn sample_image_notes_the_files_it_reads() { + use effectcraft_render::reads::{FileStamp, ReadFile, ReadLog}; + /// A grey picture of any footage, noting `/clip.png` for every read. + struct Clip; + impl effectcraft_render::FootageSource for Clip { + fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option> { + let mut img = effectcraft_render::Image::new(f.width, f.height); + img.data.fill([0.5, 0.5, 0.5, 1.0]); + Some(std::sync::Arc::new(img)) + } + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + f(); + reads.note(ReadFile::new("/clip.png", FileStamp::Native { len: 3, modified: Some(1) })); + } + } + let mut p = Project::default(); + let comp = Comp::new(20, 20, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let footage = + effectcraft_project::Footage { path: "/clip.png".into(), width: 8, height: 8, has_video: true, frame_rate: FrameRate::FPS_30, ..Default::default() }; + let item = p.add_item("Clip", Label::Aqua, None, ItemKind::Footage(footage)); + let layer = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item }, (8, 8), None); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let c = p.comp(cid).unwrap(); + for pass in ["rendered", "cached"] { + let log = ReadLog::new(); + let ctx = EvalCtx { footage: Some(&Clip), reads: Some(&log), ..EvalCtx::new(&p, cid, c, Tick::ZERO) }; + let px = crate::sample::sample_image(ctx, cid, &layer, [4.0, 4.0], [0.5, 0.5], false, 0.0).unwrap(); + assert!((px[0] - 0.5).abs() < 1e-3 && px[3] > 0.99, "{pass}: {px:?}"); + let files = log.into_files(); + assert_eq!(files.files().iter().map(|f| &*f.path).collect::>(), ["/clip.png"], "{pass}"); + assert!(files.is_complete(), "{pass}"); + } +} + /// A huge sample point saturates the pixel coordinates; it must not overflow the loop bounds. #[test] fn sample_image_huge_point_is_transparent() { diff --git a/crates/gpu/src/tests/adv3d.rs b/crates/gpu/src/tests/adv3d.rs index 00b1fee14..4914c4912 100644 --- a/crates/gpu/src/tests/adv3d.rs +++ b/crates/gpu/src/tests/adv3d.rs @@ -123,6 +123,7 @@ pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height)) @@ -315,6 +316,7 @@ pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); (ctx, run) diff --git a/crates/host/src/tests.rs b/crates/host/src/tests.rs index f628ce4e4..55240698f 100644 --- a/crates/host/src/tests.rs +++ b/crates/host/src/tests.rs @@ -343,6 +343,7 @@ fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_eng footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; ctx.value(layer, layer.props.prop(path).unwrap()) } @@ -606,6 +607,7 @@ fn stereo_rig_expressions_follow_the_master_camera() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3() }; @@ -624,3 +626,35 @@ fn stereo_rig_expressions_follow_the_master_camera() { assert!(close(v3(&s, "transform/position"), [300.0 - d, 180.0, -800.0]), "{:?}", v3(&s, "transform/position")); assert!(close(v3(&s, "transform/poi"), [300.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi")); } + +/// A text layer showing a 3D model's name (`thisComp.layer("Model").source.name`): renaming the +/// model gives the comp a new disk key and the text shows the new name (expressions on). +#[test] +fn renaming_a_model_a_text_expression_reads_changes_the_key_and_the_text() { + use effectcraft_engine::Session; + let mut s = super::session(); + let cid = effectcraft_project::ItemId( + s.execute("comp.new", json!({"name": "M", "width": 160, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(), + ); + let model = s + .edit("add model", None, |p, _| { + let f = effectcraft_project::Footage { kind: effectcraft_project::FootageKind::Model, ..Default::default() }; + let model = p.add_item("chair.glb", Default::default(), None, effectcraft_project::ItemKind::Footage(f)); + let comp = p.comp(cid).cloned().unwrap(); + let mut l = effectcraft_project::build::layer(p, &comp, "Model", effectcraft_project::LayerSource::Model { item: model }, (16, 16), None); + // (not drawn: no model file; an expression still reads its source) + l.switches.video = false; + p.comp_mut(cid).unwrap().layers.push(l); + Ok(model) + }) + .unwrap(); + let text = s.execute("layer.newText", json!({"comp": cid.0, "text": "x"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"comp": cid.0, "layer": text, "path": "text/sourceText", "expression": "thisComp.layer(\"Model\").source.name"})) + .unwrap(); + let key = effectcraft_engine::render::disk_cache::comp_content_key(&s.project, cid); + let shown = |s: &mut Session| s.execute("prop.get", json!({"comp": cid.0, "layer": text, "path": "text/sourceText"})).unwrap()["evaluated"].to_string(); + assert!(shown(&mut s).contains("chair.glb"), "{}", shown(&mut s)); + s.execute("project.rename", json!({"item": model.0, "name": "table.glb"})).unwrap(); + assert_ne!(effectcraft_engine::render::disk_cache::comp_content_key(&s.project, cid), key, "the comp's frames on disk hold the old name"); + assert!(shown(&mut s).contains("table.glb"), "the text shows the new name: {}", shown(&mut s)); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index a4070297b..4947d928a 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -36,3 +36,7 @@ rayon = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } + +[dev-dependencies] +# (tests render comps that read layers through effects; already a dependency of effectcraft-render) +effectcraft-effects = { workspace = true } diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index c3e7cfc3d..d0acf590f 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -17,6 +17,7 @@ use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, split_channel}; use effectcraft_raster::{AuxChannels, Image}; +use effectcraft_render::reads::{ReadFile, ReadFiles}; use exr::block::UncompressedBlock; use exr::block::chunk::TileCoordinates; use exr::image::read::image::ReadImage; @@ -477,8 +478,8 @@ fn layered_picture_of(aux: &AuxChannels, op: AlphaOp) -> Option { /// decoded anyway), else of the picture's channels alone (also when the cache has no channels /// for the file: a read that failed, or a broken part the picture doesn't use). `Err(Some(why))` /// when the file is too large to decode, `Err(None)` when it isn't a readable multi-layer EXR. -pub(crate) fn layered_picture(path: &str, bytes: Arc<[u8]>, op: AlphaOp, shared: bool) -> Result> { - if shared && let Some(d) = cache().get(path, Use::Picture, || Some(bytes.clone())) { +pub(crate) fn layered_picture(path: &str, bytes: Arc<[u8]>, file: &ReadFile, op: AlphaOp, shared: bool) -> Result> { + if shared && let Some(d) = cache().get(path, Use::Picture, || read_as(file, bytes.clone())) { return layered_picture_of(&d.aux, op).ok_or(None); } let (d, rgb, a) = picture_only(&bytes).map_err(|r| match r { @@ -499,8 +500,8 @@ pub(crate) fn layered_picture(path: &str, bytes: Arc<[u8]>, op: AlphaOp, shared: /// decoder shows for a file it reads, [`layered_image`]'s rule for one it refuses. `None` when /// the planes can't stand in for the `image` crate's picture (decode with it), or the file is /// not a readable EXR. -pub(crate) fn cached_picture(path: &str, bytes: Arc<[u8]>, op: AlphaOp) -> Option { - let d = cache().get(path, Use::Picture, || Some(bytes))?; +pub(crate) fn cached_picture(path: &str, bytes: Arc<[u8]>, file: &ReadFile, op: AlphaOp) -> Option { + let d = cache().get(path, Use::Picture, || read_as(file, bytes))?; let aux = &d.aux; match d.rgb { Rgb::None => layered_picture_of(aux, op), @@ -536,8 +537,16 @@ const CACHE_FILES: usize = 64; /// rendered at once. The oldest stops being kept when another comes. const HELD_FILES: usize = 4; -/// A file's decode: done once, by the first caller; the others wait for it. -type Slot = Arc>>>; +/// A file's decode: done once, by the first caller; the others wait for it. With the files read +/// for it (see [`crate::reads`]), noted again on every hit. +type Slot = Arc>, ReadFiles)>>; + +/// Bytes the caller already read from `file`, given to the cache as read from it (so the entry +/// keeps the file's identity). +fn read_as(file: &ReadFile, bytes: Arc<[u8]>) -> Option> { + crate::reads::note(file.clone()); + Some(bytes) +} struct Entry { path: String, @@ -554,8 +563,8 @@ impl Entry { fn in_use(&self) -> bool { match self.slot.get() { None => true, - Some(None) => false, - Some(Some(d)) => Arc::strong_count(d) > 1 || Arc::strong_count(&d.aux) > 1, + Some((None, _)) => false, + Some((Some(d), _)) => Arc::strong_count(d) > 1 || Arc::strong_count(&d.aux) > 1, } } } @@ -648,30 +657,18 @@ impl ChannelCache { // is safe because the decode never waits on rayon's global pool (the exr crate // decompresses on a pool of its own and this thread only receives the blocks), so the // decoding thread can't be waiting for this one. - let v = slot - .get_or_init(|| { - decoded_here = true; - self.decodes.fetch_add(1, Ordering::Relaxed); - #[cfg(test)] - count_decode(true); - let _abandon = Abandon { cache: self, slot: &slot }; - let bytes = read()?; - // The planes it will allocate count against the budget while it decodes. - if let Ok(meta) = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes[..]), false) - && let Some(need) = planes_bytes(&meta, &|_| true) - { - self.account(&slot, need.min(MAX_DECODE_BYTES)); - } - match decode(&bytes, &|_| true) { - Ok(d) => Some(Arc::new(d)), - Err(Refused::TooLarge(why)) => { - log::warn!("media: {path}: {why}"); - None - } - Err(Refused::Unreadable) => None, - } - }) - .clone(); + let (v, files) = slot.get_or_init(|| { + decoded_here = true; + self.decodes.fetch_add(1, Ordering::Relaxed); + #[cfg(test)] + count_decode(true); + let _abandon = Abandon { cache: self, slot: &slot }; + let (bytes, files) = crate::reads::captured(read); + (bytes.and_then(|bytes| self.decode_file(path, &slot, &bytes)), files) + }); + // The file(s) the decode was made of, read now or for an earlier caller. + crate::reads::note_all(files); + let v = v.clone(); if decoded_here { self.account(&slot, v.as_ref().map_or(0, |d| d.bytes())); } @@ -692,6 +689,24 @@ impl ChannelCache { v } + /// Decode the file at `path` whose bytes are `bytes` for `slot` (see [`ChannelCache::get`]). + fn decode_file(&self, path: &str, slot: &Slot, bytes: &[u8]) -> Option> { + // The planes it will allocate count against the budget while it decodes. + if let Ok(meta) = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(bytes), false) + && let Some(need) = planes_bytes(&meta, &|_| true) + { + self.account(slot, need.min(MAX_DECODE_BYTES)); + } + match decode(bytes, &|_| true) { + Ok(d) => Some(Arc::new(d)), + Err(Refused::TooLarge(why)) => { + log::warn!("media: {path}: {why}"); + None + } + Err(Refused::Unreadable) => None, + } + } + /// Drop least recently used files not in use until the rest fit the budget, keeping `newest`. fn evict(entries: &mut Vec, budget: usize, newest: &Slot) { while entries.iter().map(|e| e.bytes).sum::() > budget || entries.len() > CACHE_FILES { @@ -996,7 +1011,7 @@ mod tests { let path = "/474-shared-fallback.exr"; assert!(cache().get(path, Use::Channels, || None).is_none(), "a read that failed"); let op = AlphaOp::of(&Footage::default(), FloatCurve::Linear); - let img = layered_picture(path, bytes, op, true).unwrap(); + let img = layered_picture(path, bytes, &ReadFile::new(path, effectcraft_render::reads::FileStamp::Virtual), op, true).unwrap(); assert_eq!(img.get(1, 1), [0.5, 0.5, 0.5, 1.0]); } @@ -1174,7 +1189,8 @@ mod tests { for curve in [FloatCurve::Srgb, FloatCurve::Linear, FloatCurve::Gamma24] { let op = AlphaOp::of(&Footage { alpha, premul_color: [0.2, 0.4, 0.6], ..Default::default() }, curve); let want = crate::convert::dynamic_to_image(&img, op); - let got = cached_picture(&path, bytes.as_slice().into(), op).unwrap(); + let got = cached_picture(&path, bytes.as_slice().into(), &ReadFile::new(path.as_str(), effectcraft_render::reads::FileStamp::Virtual), op) + .unwrap(); assert_eq!((got.width, got.height), (want.width, want.height)); assert_eq!(bits(&got), bits(&want), "file {k} {alpha:?} {curve:?}"); } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 31478f111..0b509c45f 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -25,6 +25,7 @@ mod layered; pub use layered::vector_doc; mod pool; mod probe; +mod reads; mod stream; mod wav_markers; mod webp_anim; @@ -85,7 +86,15 @@ pub fn is_importable(path: &std::path::Path) -> bool { /// A whole file's bytes, refusing anything that isn't a regular file: a project can name /// `/dev/zero` or a FIFO as footage, and reading those never ends (#2134). pub(crate) fn read_regular_file(path: &std::path::Path) -> std::io::Result> { + read_regular_file_stamped(path).map(|(bytes, _)| bytes) +} + +/// [`read_regular_file`] with the file's identity, from the metadata of the open file (no +/// second look at the path). +pub(crate) fn read_regular_file_stamped(path: &std::path::Path) -> std::io::Result<(Vec, effectcraft_render::reads::FileStamp)> { use std::io::Read; + #[cfg(test)] + count_open(path); let mut file = std::fs::File::open(path)?; let meta = file.metadata()?; if !meta.is_file() { @@ -95,7 +104,22 @@ pub(crate) fn read_regular_file(path: &std::path::Path) -> std::io::Result> = std::sync::Mutex::new(Vec::new()); + +/// Count an open of a file (tests: which files a render touched on disk). +#[cfg(test)] +pub(crate) fn count_open(path: &std::path::Path) { + OPENS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_path_buf()); +} + +/// How many times files under `dir` were opened so far (tests). +#[cfg(test)] +pub(crate) fn opens_under(dir: &std::path::Path) -> usize { + OPENS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).iter().filter(|p| p.starts_with(dir)).count() } pub(crate) fn ext_of(path: &std::path::Path) -> String { diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9e10b4eb3..a60048b66 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -6,6 +6,7 @@ use std::sync::{Arc, Condvar, Mutex, OnceLock}; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId}; use effectcraft_raster::{Image, Px}; +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}; @@ -14,6 +15,7 @@ use image::metadata::Orientation; use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned}; use crate::downmix::Order; use crate::gif_anim::{self, Anim}; +use crate::reads; use crate::{MediaError, Result}; /// Default frame-cache budget: 1 GiB of decoded frames. @@ -53,6 +55,8 @@ struct Key { struct Entry { img: Arc, + /// The files the frame was decoded from (see [`crate::reads`]). + files: Arc, stamp: u64, bytes: usize, } @@ -106,18 +110,20 @@ struct Lru { } impl Lru { - fn get(&mut self, k: &Key) -> Option> { + /// 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)> { let e = self.map.get_mut(k)?; self.order.remove(&e.stamp); self.clock += 1; e.stamp = self.clock; self.order.insert(e.stamp, k.clone()); - Some(e.img.clone()) + Some((e.img.clone(), e.files.clone())) } - fn insert(&mut self, k: Key, img: Arc, budget: usize) { + /// 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; self.clock += 1; - if let Some(old) = self.map.insert(k.clone(), Entry { img, stamp: self.clock, bytes }) { + if let Some(old) = self.map.insert(k.clone(), Entry { img, files, stamp: self.clock, bytes }) { self.order.remove(&old.stamp); self.bytes -= old.bytes; } @@ -180,8 +186,9 @@ struct CacheState { } /// A movie/audio source by path ([`Opened`]), opened by the first caller while the others wait -/// for it (`None`: failed to open; not retried until `forget`). -type SourceSlot = Arc>>; +/// for it (`None`: failed to open; not retried until `forget`), with the file's identity as +/// opened. +type SourceSlot = Arc, ReadFile)>>; /// An opened movie/audio source, the turn its picture is shown with and the order its decoded /// audio channels arrive in. @@ -211,7 +218,7 @@ fn channel_order(src: &SharedSource, header: Option<&[u8]>, file: Option<&dyn fi } /// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load). -type GifSlot = Arc, String>>>; +type GifSlot = Arc, String>, ReadFile)>>; struct Inner { /// Opened movie/audio sources by path. @@ -220,8 +227,9 @@ struct Inner { opened: AtomicU64, /// In-memory files registered with `add_bytes` (web builds, tests). files: Mutex>>, - /// Parsed 3D models by path (`None`: failed to load; not retried until `forget`). - models: Mutex>>>, + /// Parsed 3D models by path (`None`: failed to load; not retried until `forget`), with the + /// files read for them (the model's and the resources it names). + models: Mutex>, Arc)>>, /// Decoded animated GIFs and WebPs by path, loaded by the first caller while the others wait (an /// `Err` is not retried until `forget`). gifs: Mutex, GifSlot>>, @@ -439,11 +447,13 @@ impl MediaPool { } /// The conformed-audio file of `path` at `rate` (named by a hash of the path, size and - /// modification time, so an edited file conforms again, and of `CONFORM_VERSION`). + /// modification time, so an edited file conforms again, and of `CONFORM_VERSION`). Notes + /// `path` as read with that identity (see [`crate::reads`]): its samples come from it. pub fn conformed_path(&self, path: &str, rate: u32) -> Option { use std::hash::{Hash, Hasher}; let folder = lock(&self.inner.conform).clone()?; let meta = std::fs::metadata(path).ok()?; + reads::note(ReadFile::new(path, FileStamp::of_metadata(&meta))); let mut h = std::collections::hash_map::DefaultHasher::new(); (CONFORM_VERSION, path, meta.len(), meta.modified().ok()).hash(&mut h); Some(folder.join(format!("{:016x}_{rate}.ecaf", h.finish()))) @@ -612,16 +622,20 @@ impl Inner { /// A 3D model file and its sibling resources (buffers, textures, MTL files), parsed once. fn model(&self, path: &str) -> Option> { - if let Some(m) = lock(&self.models).get(path) { - return m.clone(); + let found = lock(&self.models).get(path).cloned(); + if let Some((m, files)) = found { + reads::note_all(&files); + return m; } - let loaded = self.read(path).map_err(|e| e.to_string()).and_then(|bytes| { - // Files the model names are read only when local or on the model's own network share. - let resolve = |uri: &str| -> Option> { - let p = craft_docpath::resolve_absolute(uri, Some(std::path::Path::new(path))).ok()?; - self.read(&p.to_string_lossy()).ok().map(|b| b.to_vec()) - }; - effectcraft_model::load(path, &bytes, &resolve).map_err(|e| e.to_string()) + let (loaded, files) = reads::captured(|| { + self.read(path).map_err(|e| e.to_string()).and_then(|bytes| { + // Files the model names are read only when local or on the model's own network share. + let resolve = |uri: &str| -> Option> { + let p = craft_docpath::resolve_absolute(uri, Some(std::path::Path::new(path))).ok()?; + self.read(&p.to_string_lossy()).ok().map(|b| b.to_vec()) + }; + effectcraft_model::load(path, &bytes, &resolve).map_err(|e| e.to_string()) + }) }); let m = match loaded { Ok(m) => Some(Arc::new(m)), @@ -630,14 +644,29 @@ impl Inner { None } }; - lock(&self.models).entry(path.to_string()).or_insert(m).clone() + let (m, files) = lock(&self.models).entry(path.to_string()).or_insert((m, Arc::new(files))).clone(); + reads::note_all(&files); + m } + /// The bytes of the file at `path`, noted as read (see [`crate::reads`]). fn read(&self, path: &str) -> Result> { - if let Some(b) = lock(&self.files).get(path) { - return Ok(b.clone()); + let (bytes, file) = self.read_file(path); + reads::note(file); + bytes + } + + /// The bytes of the file at `path` and its identity as read ([`FileStamp::Missing`] when it + /// can't be read), not noted. + fn read_file(&self, path: &str) -> (Result>, ReadFile) { + let in_memory = lock(&self.files).get(path).cloned(); + if let Some(b) = in_memory { + return (Ok(b), ReadFile::new(path, FileStamp::Virtual)); + } + match crate::read_regular_file_stamped(std::path::Path::new(path)) { + Ok((bytes, stamp)) => (Ok(bytes.into()), ReadFile::new(path, stamp)), + Err(e) => (Err(MediaError::Io(format!("{path}: {e}"))), ReadFile::new(path, FileStamp::Missing)), } - crate::read_regular_file(std::path::Path::new(path)).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) } /// The opened movie/audio source for `path` ([`Opened`]). The first caller opens it, outside @@ -645,35 +674,42 @@ impl Inner { /// files are decoded whole); callers asking for the same file meanwhile wait for that instead /// of opening it again: a preview's frame workers, audio feeder, conform and waveform threads /// each decoded a long FLAC in full at once (#408). + /// Each call notes the file as read (see [`crate::reads`]), with its identity as opened. fn opened(&self, path: &Arc) -> Option { let slot = lock(&self.sources).entry(path.clone()).or_default().clone(); - slot.get_or_init(|| { - self.opened.fetch_add(1, Ordering::Relaxed); - // Files on disk are read in place (the index, then samples on demand) rather than - // whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes. - let in_memory = lock(&self.files).contains_key(&**path); - let file = std::path::Path::new(&**path); - let in_place = if in_memory { None } else { crate::stream::open_source(file) }; - let in_place = in_place.map(|src| { - let (header, reader) = (crate::stream::movie_header(file), crate::stream::file_reader(file)); - src.map(|src| Opened::new(src, header.as_deref(), reader.as_deref())) + let (opened, file) = slot.get_or_init(|| self.open(path)); + reads::note(file.clone()); + opened.clone() + } + + /// Open the movie/audio file at `path` (see [`Inner::opened`]), with its identity as opened. + fn open(&self, path: &Arc) -> (Option, ReadFile) { + self.opened.fetch_add(1, Ordering::Relaxed); + // Files on disk are read in place (the index, then samples on demand) rather than + // whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes. + let in_memory = lock(&self.files).contains_key(&**path); + let file = std::path::Path::new(&**path); + let in_place = if in_memory { None } else { crate::stream::open_source(file) }; + let in_place = in_place.map(|(src, stamp)| { + let (header, reader) = (crate::stream::movie_header(file), crate::stream::file_reader(file)); + (src.map(|src| Opened::new(src, header.as_deref(), reader.as_deref())), ReadFile::new(path.clone(), stamp)) + }); + let (opened, read) = in_place.unwrap_or_else(|| { + let (bytes, read) = self.read_file(path); + let opened = bytes.and_then(|bytes| { + let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let src = filmcraft_codecs::open_bytes(&name, bytes.clone()).map_err(MediaError::from)?; + Ok(Opened::new(src, Some(&bytes), Some(&filmcraft_media::reader::MemReader(bytes.clone())))) }); - let opened = in_place.unwrap_or_else(|| { - self.read(path).and_then(|bytes| { - let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let src = filmcraft_codecs::open_bytes(&name, bytes.clone()).map_err(MediaError::from)?; - Ok(Opened::new(src, Some(&bytes), Some(&filmcraft_media::reader::MemReader(bytes.clone())))) - }) - }); - match opened { - Ok(s) => Some(s), - Err(e) => { - self.note_problem(path, "", &e); - None - } + (opened, read) + }); + match opened { + Ok(s) => (Some(s), read), + Err(e) => { + self.note_problem(path, "", &e); + (None, read) } - }) - .clone() + } } /// Remember why the movie at `path` cannot be shown (`codec` is the footage's, when known) @@ -694,13 +730,15 @@ impl Inner { /// map's lock; callers asking for the same file meanwhile wait for that. fn gif(&self, path: &Arc) -> Result> { let slot = lock(&self.gifs).entry(path.clone()).or_default().clone(); - let loaded = slot.get_or_init(|| { - let loaded = self.read(path).and_then(|bytes| Anim::load(&bytes)); - loaded.map(Arc::new).map_err(|e| { + let (loaded, file) = slot.get_or_init(|| { + let (bytes, file) = self.read_file(path); + let loaded = bytes.and_then(|bytes| Anim::load(&bytes)).map(Arc::new).map_err(|e| { log::warn!("media: cannot decode {path}: {e}"); e.to_string() - }) + }); + (loaded, file) }); + reads::note(file.clone()); loaded.clone().map_err(|e| MediaError::Decode(format!("{path}: {e}"))) } @@ -783,19 +821,15 @@ impl Inner { let mut c = lock(&self.cache); // sequential playback of a movie: read the next frame ahead if playback && movie { - let prev = c.last.insert(loc.key.path.clone(), loc.key.frame); - if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) { - let next_t = t + footage.frame_rate.frame_duration(); - let mut next = Self::locate(footage, next_t, float); - next.key.size = loc.key.size; - 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_next(&mut c, &loc, footage, t, size, float); } + // (Each exit notes the files the frame came from, see `crate::reads`; a cached frame + // notes those it was decoded from, without touching them.) loop { - if let Some(img) = c.lru.get(&loc.key) { + if let Some((img, files)) = c.lru.get(&loc.key) { self.hits.fetch_add(1, Ordering::Relaxed); + drop(c); + reads::note_all(&files); return Ok(img); } if !c.inflight.contains(&loc.key) { @@ -808,7 +842,9 @@ impl Inner { if rayon::current_thread_index().is_some() { self.misses.fetch_add(1, Ordering::Relaxed); drop(c); - return Ok(Arc::new(self.decode(&loc, footage)?)); + let (img, files) = reads::captured(|| self.decode(&loc, footage)); + reads::note_all(&files); + return Ok(Arc::new(img?)); } c = self.done.wait(c).unwrap_or_else(std::sync::PoisonError::into_inner); } @@ -816,11 +852,26 @@ impl Inner { c.inflight.insert(loc.key.clone()); drop(c); let _guard = Inflight { inner: self, key: loc.key.clone() }; - let img = Arc::new(self.decode(&loc, footage)?); - lock(&self.cache).lru.insert(loc.key, img.clone(), self.budget()); + 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()); Ok(img) } + /// Sequential playback of a movie (`loc`, at `t`): read the frame after it ahead. + fn prefetch_next(self: &Arc, c: &mut CacheState, loc: &Loc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) { + let prev = c.last.insert(loc.key.path.clone(), loc.key.frame); + if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) { + let next_t = t + footage.frame_rate.frame_duration(); + let mut next = Self::locate(footage, next_t, float); + next.key.size = loc.key.size; + if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) { + self.prefetch(footage, next_t, size, float); + } + } + } + #[cfg(not(target_arch = "wasm32"))] fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) { let (me, f) = (self.clone(), footage.clone()); @@ -856,13 +907,17 @@ impl Inner { } } + /// Decodes the still image at `loc` and applies the footage alpha and float handling. + /// A placeholder path decodes to colour bars. fn decode_still(&self, loc: &Loc, footage: &Footage) -> Result { let op = AlphaOp::of(footage, loc.float); let path = &loc.key.path; if &**path == PLACEHOLDER { return Ok(colour_bars(footage.width, footage.height)); } - let bytes = self.read(path)?; + let (bytes, file) = self.read_file(path); + reads::note(file.clone()); + let bytes = bytes?; if let Some(img) = crate::dpx::decode(path, &bytes)? { return Ok(dynamic_to_image(&img, op)); } @@ -880,7 +935,7 @@ impl Inner { let shared = lock(&self.aux_footage).contains(&footage.path); if shared && path.to_ascii_lowercase().ends_with(".exr") - && let Some(img) = crate::exr_channels::cached_picture(path, bytes.clone(), op) + && let Some(img) = crate::exr_channels::cached_picture(path, bytes.clone(), &file, op) { return Ok(img); } @@ -889,7 +944,7 @@ impl Inner { match crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)) { Ok(img) => Ok(dynamic_to_image(&img, op)), // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. - Err(e) => crate::exr_channels::layered_picture(path, bytes, op, shared) + Err(e) => crate::exr_channels::layered_picture(path, bytes, &file, op, shared) .map_err(|why| MediaError::Decode(format!("{path}: {}", why.unwrap_or_else(|| e.to_string())))), } } @@ -957,6 +1012,13 @@ impl FootageSource for MediaPool { fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) { filmcraft_media::cancel::with_cancel(cancel, f) } + /// Every read of a file notes it (see [`crate::reads`]): a frame's image file or sequence + /// frame, a movie, a model and the files it names, an EXR's channels, a movie's audio. A + /// frame (or other entry) served from memory notes the files it was made of, as they were + /// when read, without reading them again. A missing sequence frame (colour bars) reads none. + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + reads::recording(reads, f) + } fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { self.frame_sized(item, footage, t, None, FloatCurve::Srgb) } @@ -1207,13 +1269,13 @@ mod tests { let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0) }; let budget = 3 * (4096 + 64); for i in 0..10 { - c.insert(k(i), img(), budget); + c.insert(k(i), img(), Arc::default(), budget); } assert_eq!(c.map.len(), 3); assert!(c.get(&k(9)).is_some() && c.get(&k(6)).is_none()); // touching 7 makes 8 the oldest c.get(&k(7)); - c.insert(k(10), img(), budget); + c.insert(k(10), img(), Arc::default(), budget); assert!(c.get(&k(7)).is_some() && c.get(&k(8)).is_none()); assert_eq!(c.spare.len(), MAX_SPARE); assert_eq!(c.take_spare(256).len(), 256); diff --git a/crates/media/src/reads.rs b/crates/media/src/reads.rs new file mode 100644 index 000000000..43ea209b8 --- /dev/null +++ b/crates/media/src/reads.rs @@ -0,0 +1,120 @@ +//! Which files the [`MediaPool`](crate::MediaPool) reads for a render +//! ([`FootageSource::recording`](effectcraft_render::FootageSource::recording)). +//! +//! Every read of a file notes it, with the identity it had when read, in the innermost scope open +//! on this thread. The pool's caches (decoded frames, opened movies, GIFs, models, EXR channels) +//! keep what their entries were made of and note it again on each hit, so a frame served from +//! memory reports its file without touching the disk. +//! +//! The scopes are per thread: a read noted while a render's scope is open on the thread belongs +//! to that render. A thread waiting inside a decode can run another job meanwhile (rayon); that +//! job's reads go to its own scope when it opens one, and otherwise to the scope below it, which +//! then lists a file more than it read (never fewer). + +use std::cell::RefCell; + +use effectcraft_render::reads::{ReadFile, ReadFiles, ReadLog}; + +/// Scopes nested deeper than this are not opened (a thread that keeps taking jobs while it waits +/// cannot pile them up without bound): their reads go to the scope below, and they come back +/// incomplete. +const MAX_DEPTH: usize = 64; + +thread_local! { + /// The open scopes on this thread, innermost last, each with the files noted in it. + static SCOPES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Note `file` in the innermost open scope on this thread (nothing when none is open). +pub(crate) fn note(file: ReadFile) { + let _ = SCOPES.try_with(|s| { + if let Ok(mut s) = s.try_borrow_mut() + && let Some(top) = s.last_mut() + { + top.add(file); + } + }); +} + +/// Note every file of `files` in the innermost open scope on this thread. +pub(crate) fn note_all(files: &ReadFiles) { + if files.is_empty() && files.is_complete() { + return; + } + let _ = SCOPES.try_with(|s| { + if let Ok(mut s) = s.try_borrow_mut() + && let Some(top) = s.last_mut() + { + top.extend(files); + } + }); +} + +/// Closes the scope it opened, also when what runs inside panics: its files go to `out`. +struct Scope<'a> { + out: Out<'a>, + opened: bool, +} + +enum Out<'a> { + /// A render's log ([`recording`]). + Log(&'a ReadLog), + /// The caller's ([`captured`]). + Files(&'a mut ReadFiles), +} + +impl Scope<'_> { + /// Starts collecting the files read on this thread into `out`. + /// At the nesting limit the scope stays closed and reports an incomplete list. + fn open(out: Out<'_>) -> Scope<'_> { + let opened = SCOPES + .try_with(|s| match s.try_borrow_mut() { + Ok(mut s) if s.len() < MAX_DEPTH => { + s.push(ReadFiles::default()); + true + } + _ => false, + }) + .unwrap_or(false); + Scope { out, opened } + } +} + +impl Drop for Scope<'_> { + /// Ends the scope and hands the collected files to its output, marked incomplete when the scope could not be opened. + fn drop(&mut self) { + let files = if self.opened { SCOPES.try_with(|s| s.try_borrow_mut().ok().and_then(|mut s| s.pop())).ok().flatten() } else { None }; + // A scope that could not open saw nothing: what it made can't name its files. + let files = files.unwrap_or_else(|| { + let mut f = ReadFiles::default(); + f.mark_incomplete(); + f + }); + match &mut self.out { + Out::Log(log) => log.note_all(&files), + Out::Files(out) => out.extend(&files), + } + } +} + +/// Run `f` with the files it reads on this thread noted in `log` +/// ([`FootageSource::recording`](effectcraft_render::FootageSource::recording)). +pub(crate) fn recording(log: &ReadLog, f: &mut dyn FnMut()) { + let _scope = Scope::open(Out::Log(log)); + f(); +} + +/// Run `f` and return what it returns with the files it read on this thread (to keep with a +/// cache entry made of them). They are not noted in the enclosing scope: the caller notes them, +/// as it does on each later hit of the entry. +pub(crate) fn captured(f: impl FnOnce() -> T) -> (T, ReadFiles) { + let mut files = ReadFiles::default(); + let out = { + let _scope = Scope::open(Out::Files(&mut files)); + f() + }; + (out, files) +} + +#[cfg(test)] +mod tests; diff --git a/crates/media/src/reads/tests.rs b/crates/media/src/reads/tests.rs new file mode 100644 index 000000000..5dae8415a --- /dev/null +++ b/crates/media/src/reads/tests.rs @@ -0,0 +1,390 @@ +//! The files a render reads, as the media pool reports them (#3184): exactly the files the frame +//! was made of, also when its pixels come from memory. + +use std::path::{Path, PathBuf}; + +use effectcraft_color::Label; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, Project, Solid, Value}; +use effectcraft_render::reads::{FileStamp, ReadFiles, ReadLog}; +use effectcraft_render::{LayerCache, RenderOpts, Renderer}; +use effectcraft_time::FrameRate; + +use crate::MediaPool; + +const RATE: FrameRate = FrameRate::FPS_30; +const SIZE: (u32, u32) = (16, 8); + +/// A fresh folder for test `name` (on the build drive, not the system temp folder). +fn folder(name: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../../target/test-reads-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A PNG at `path`, a flat grey of `level`. +fn png(path: &Path, level: u8) -> String { + image::RgbaImage::from_pixel(SIZE.0, SIZE.1, image::Rgba([level, level, level, 255])).save(path).unwrap(); + path.to_string_lossy().to_string() +} + +/// An image sequence of `n` PNG frames in `dir`. +fn sequence(dir: &Path, n: usize) -> Footage { + let files: Vec = (0..n).map(|i| png(&dir.join(format!("seq_{i:04}.png")), 20 + i as u8 * 10)).collect(); + let mut f = Footage { kind: FootageKind::Sequence, sequence: files, frame_rate: RATE, ..crate::probe(dir.join("seq_0000.png")).unwrap() }; + f.sync_sequence_duration(); + f +} + +/// A still footage item of `path`. +fn still(path: &Path, level: u8) -> Footage { + png(path, level); + crate::probe(path).unwrap() +} + +fn comp() -> Comp { + Comp::new(SIZE.0, SIZE.1, RATE, RATE.tick_of(10)) +} + +fn footage_layer(p: &mut Project, comp: &Comp, name: &str, f: Footage) -> Layer { + let item = p.add_item(name, Label::Aqua, None, ItemKind::Footage(f)); + build::layer(p, comp, name, LayerSource::Footage { item }, SIZE, Some(comp.duration)) +} + +fn add_comp(p: &mut Project, name: &str, mut comp: Comp, layers: Vec) -> ItemId { + comp.layers = layers.into(); + p.add_item(name, Label::Sandstone, None, ItemKind::Comp(comp.into())) +} + +/// Render frame `frame` of `comp` with `pool` and `cache`: the picture's top-left pixel and the +/// files the render reports. +fn render(p: &Project, pool: &MediaPool, cache: &LayerCache, comp: ItemId, frame: i64) -> ([f32; 4], ReadFiles) { + let log = ReadLog::new(); + let mut r = Renderer::new(p, pool, RenderOpts::default()); + r.cache = Some(cache); + r.reads = Some(&log); + let img = r.comp_frame(comp, RATE.tick_of(frame)); + (img.get(0, 0), log.into_files()) +} + +fn paths(files: &ReadFiles) -> Vec { + files.files().iter().map(|f| f.path.to_string()).collect() +} + +/// Every listed file has the identity it has on disk now. +fn assert_stamps_match_disk(files: &ReadFiles) { + for f in files.files() { + assert_eq!(Some(f.stamp), FileStamp::native(&f.path), "{}", f.path); + } +} + +/// (a) One frame of a comp with an image-sequence layer reads that frame's file and nothing else +/// of the sequence; a large sequence in the project that no layer uses adds nothing, not even a +/// look at its files. +#[test] +fn a_sequence_frame_reports_only_its_own_file() { + let dir = folder("sequence"); + let unused_dir = dir.join("unused"); + let mut p = Project::default(); + let c = comp(); + let seq = sequence(&dir, 5); + let (frame_2, frame_4) = (seq.sequence[2].clone(), seq.sequence[4].clone()); + let layer = footage_layer(&mut p, &c, "Seq", seq); + // 5000 frames nobody shows (they don't even exist). + let unused = Footage { + kind: FootageKind::Sequence, + sequence: (0..5000).map(|i| unused_dir.join(format!("big_{i:05}.exr")).to_string_lossy().to_string()).collect(), + frame_rate: RATE, + width: SIZE.0, + height: SIZE.1, + has_video: true, + ..Default::default() + }; + p.add_item("Unused", Label::Aqua, None, ItemKind::Footage(unused)); + let cid = add_comp(&mut p, "C", c, vec![layer]); + let (pool, cache) = (MediaPool::new(), LayerCache::default()); + let opened = crate::opens_under(&dir); + let (px, files) = render(&p, &pool, &cache, cid, 2); + assert!((px[0] - 40.0 / 255.0).abs() < 0.01, "frame 2 shows: {px:?}"); + assert_eq!(paths(&files), [frame_2]); + assert!(files.is_complete()); + assert_stamps_match_disk(&files); + assert_eq!(crate::opens_under(&dir) - opened, 1, "one file opened: the frame's"); + assert_eq!(crate::opens_under(&unused_dir), 0, "the unused sequence was not looked at"); + // Another frame reads its own file, also through the viewer's wrapper of the pool. + let viewer = effectcraft_render::CancellableFootage { inner: &pool, cancel: Default::default() }; + let log = ReadLog::new(); + let mut r = Renderer::new(&p, &viewer, RenderOpts::default()); + r.cache = Some(&cache); + r.reads = Some(&log); + r.comp_frame(cid, RATE.tick_of(4)); + let files = log.into_files(); + assert_eq!(paths(&files), [frame_4]); + assert!(files.is_complete()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// (b) A precomp reports the files of what it draws, and an effect that reads another layer +/// reports that layer's files (the layer itself is hidden: its pixels come only through the +/// effect). +#[test] +fn precomps_and_layers_read_by_effects_report_their_files() { + let dir = folder("nested"); + let mut p = Project::default(); + let c = comp(); + let seq = sequence(&dir, 4); + let nested_frame = seq.sequence[3].clone(); + let inner_layer = footage_layer(&mut p, &c, "Seq", seq); + let inner = add_comp(&mut p, "Inner", c.clone(), vec![inner_layer]); + let pre = build::layer(&mut p, &c, "Inner", LayerSource::Comp { item: inner }, SIZE, Some(c.duration)); + let matte_path = dir.join("matte.png"); + let mut matte = footage_layer(&mut p, &c, "Matte", still(&matte_path, 255)); + matte.switches.video = false; + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: SIZE.0, height: SIZE.1, pixel_aspect: 1.0 })); + let mut solid = build::layer(&mut p, &c, "S", LayerSource::Solid { item: sid }, SIZE, Some(c.duration)); + let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap(); + let mut next = p.next_id; + let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [SIZE.0 as f64, SIZE.1 as f64]); + p.next_id = next; + fx.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(matte.id.0)); + solid.props.sub_mut("effects").unwrap().children.push(fx.into()); + let cid = add_comp(&mut p, "Outer", c, vec![solid, matte, pre]); + let (pool, cache) = (MediaPool::new(), LayerCache::default()); + let (_, files) = render(&p, &pool, &cache, cid, 3); + let mut want = vec![matte_path.to_string_lossy().to_string(), nested_frame]; + want.sort(); + assert_eq!(paths(&files), want); + assert!(files.is_complete()); + assert_stamps_match_disk(&files); + let _ = std::fs::remove_dir_all(&dir); +} + +/// (c) A frame rendered again comes from memory (the sequence frame from the media pool, the +/// still layer from the layer cache) and reports the same files, with the identities they had +/// when read, without opening anything: here the files are even gone by then. +#[test] +fn a_frame_served_from_memory_reports_its_files_without_touching_them() { + let dir = folder("memory"); + let mut p = Project::default(); + let c = comp(); + // The sequence at half opacity over the still: both show. + let mut seq = footage_layer(&mut p, &c, "Seq", sequence(&dir, 3)); + seq.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0); + let plate = footage_layer(&mut p, &c, "Plate", still(&dir.join("plate.png"), 128)); + let cid = add_comp(&mut p, "C", c, vec![seq, plate]); + let (pool, cache) = (MediaPool::new(), LayerCache::default()); + let (px, first) = render(&p, &pool, &cache, cid, 1); + assert_eq!(first.len(), 2, "{:?}", paths(&first)); + assert_stamps_match_disk(&first); + std::fs::remove_dir_all(&dir).unwrap(); + let opened = crate::opens_under(&dir); + let hits = cache.stats().hits; + let (again_px, again) = render(&p, &pool, &cache, cid, 1); + assert_eq!(again_px, px, "the same picture"); + assert_eq!(again, first, "the same files, as they were when read"); + assert!(cache.stats().hits > hits, "the still layer came from the layer cache"); + // The media pool alone (a new layer cache) reports them too. + let (_, from_pool) = render(&p, &pool, &LayerCache::default(), cid, 1); + assert_eq!(from_pool, first); + assert_eq!(crate::opens_under(&dir), opened, "nothing was opened on disk"); +} + +// ---------------------------------------------------------------- layer entries on disk (#3184) + +use std::sync::Arc; + +use effectcraft_render::disk_cache::{DiskCache, Kind}; + +/// A "session": a new media pool and layer cache over the disk cache in `cache` (opened anew, +/// as after a restart), writing every layer buffer however quick to render. +fn session(cache: &Path) -> (MediaPool, LayerCache, Arc) { + let dc = DiskCache::open(cache, 1 << 30).unwrap(); + dc.set_min_layer_ms(0); + let lc = LayerCache::default(); + lc.set_disk(Some(dc.clone())); + (MediaPool::new(), lc, dc) +} + +/// Give `path` a later modification time, so a rewrite within the clock's resolution counts. +fn touch_later(path: &Path) { + let t = std::fs::metadata(path).unwrap().modified().unwrap() + std::time::Duration::from_secs(7); + std::fs::File::options().append(true).open(path).unwrap().set_modified(t).unwrap(); +} + +/// Two still plates (the upper one at half opacity, so both show), and a 5000-frame sequence +/// in the project that no layer uses (its files don't even exist). +fn plates(dir: &Path) -> (Project, ItemId, PathBuf) { + let mut p = Project::default(); + let c = comp(); + let a = dir.join("a.png"); + let mut top = footage_layer(&mut p, &c, "A", still(&a, 60)); + top.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0); + let bottom = footage_layer(&mut p, &c, "B", still(&dir.join("b.png"), 200)); + let unused = dir.join("unused"); + let seq = Footage { + kind: FootageKind::Sequence, + sequence: (0..5000).map(|i| unused.join(format!("big_{i:05}.exr")).to_string_lossy().to_string()).collect(), + frame_rate: RATE, + width: SIZE.0, + height: SIZE.1, + has_video: true, + ..Default::default() + }; + p.add_item("Unused", Label::Aqua, None, ItemKind::Footage(seq)); + let cid = add_comp(&mut p, "C", c, vec![top, bottom]); + (p, cid, a) +} + +/// Correct and no scan: after a restart, only the layer buffer whose source file changed is +/// rendered again (only that file is opened); the other comes from disk with its file list, so +/// the frame's list is complete again (its frame entry can be stored). The disk cache checks +/// exactly the files its entries list, never the unused sequence. The re-rendered buffer +/// replaces its entry: the next restart opens nothing. +#[test] +fn a_changed_file_renders_only_the_layer_buffers_that_read_it_again() { + let dir = folder("layer-disk"); + let (src, cache) = (dir.join("src"), dir.join("cache")); + std::fs::create_dir_all(&src).unwrap(); + let (p, cid, a) = plates(&src); + let (pool, lc, dc) = session(&cache); + let (px, files) = render(&p, &pool, &lc, cid, 0); + assert!(files.is_complete() && files.len() == 2, "{:?}", paths(&files)); + dc.flush(); + let entries = dc.keys(Kind::Layer).len(); + assert!(entries >= 2, "both plates' buffers are on disk: {entries}"); + drop((pool, lc, dc)); + + // Restart without a change: everything from disk, nothing opened, a complete list. + let (pool, lc, dc) = session(&cache); + let opened = crate::opens_under(&src); + let (again, files_again) = render(&p, &pool, &lc, cid, 0); + assert_eq!(again, px); + assert_eq!(files_again, files, "the files of the entries, as they were read"); + assert!(files_again.is_complete(), "a frame made of buffers from disk can be stored"); + assert_eq!(crate::opens_under(&src), opened, "nothing opened"); + let checks = dc.file_checks(); + assert!(checks >= 2 && checks as usize <= entries, "one check per entry used: {checks} of {entries}"); + drop((pool, lc, dc)); + + // a.png gets new pixels: only the top plate renders again. + png(&a, 120); + touch_later(&a); + let (pool, lc, dc) = session(&cache); + let opened = crate::opens_under(&src); + let (changed, files_changed) = render(&p, &pool, &lc, cid, 0); + assert_ne!(changed, px, "the new pixels show"); + assert_eq!(crate::opens_under(&src) - opened, 1, "only the changed file is read"); + assert!(files_changed.is_complete() && files_changed.len() == 2); + assert_stamps_match_disk(&files_changed); + assert!(dc.file_checks() as usize <= entries, "only listed files are checked"); + assert_eq!(crate::opens_under(&src.join("unused")), 0); + dc.flush(); + drop((pool, lc, dc)); + + // Its entry was replaced: the next restart opens nothing and shows the new pixels. + let (pool, lc, _dc) = session(&cache); + let opened = crate::opens_under(&src); + let (last, _) = render(&p, &pool, &lc, cid, 0); + assert_eq!(last, changed); + assert_eq!(crate::opens_under(&src), opened, "both from disk"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Two projects whose footage items have the same ids but name different files never share a +/// layer entry: the layer key names the item's path and settings. +#[test] +fn projects_with_the_same_item_ids_never_share_a_layer_entry() { + let dir = folder("layer-disk-projects"); + let (src, cache) = (dir.join("src"), dir.join("cache")); + std::fs::create_dir_all(&src).unwrap(); + let mut p = Project::default(); + let c = comp(); + let layer = footage_layer(&mut p, &c, "Plate", still(&src.join("one.png"), 40)); + let LayerSource::Footage { item } = layer.source else { panic!("a footage layer") }; + let cid = add_comp(&mut p, "C", c, vec![layer]); + let mut other = p.clone(); + other.item_mut(item).unwrap().kind = ItemKind::Footage(still(&src.join("two.png"), 220)); + let (pool, lc, dc) = session(&cache); + let (one, _) = render(&p, &pool, &lc, cid, 0); + dc.flush(); + drop((pool, lc, dc)); + let (pool, lc, dc) = session(&cache); + let hits = dc.stats().hits; + let (two, files) = render(&other, &pool, &lc, cid, 0); + assert!((two[0] - 220.0 / 255.0).abs() < 0.01, "the other project's own file shows: {two:?}"); + assert_ne!(two, one); + assert_eq!(dc.stats().hits, hits, "no entry of the first project is used"); + assert_eq!(paths(&files), [src.join("two.png").to_string_lossy().to_string()]); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Safe: a damaged layer entry, a source file that is missing, one replaced by something +/// unreadable (a folder), and one back with other pixels never serve a stale buffer after a +/// restart; the damaged entry is rendered again, replaced and then used. +#[test] +fn missing_unreadable_files_and_damaged_layer_entries_never_serve_stale_buffers() { + let dir = folder("layer-disk-safe"); + let (src, cache) = (dir.join("src"), dir.join("cache")); + std::fs::create_dir_all(&src).unwrap(); + let mut p = Project::default(); + let c = comp(); + let path = src.join("plate.png"); + let layer = footage_layer(&mut p, &c, "Plate", still(&path, 90)); + let cid = add_comp(&mut p, "C", c, vec![layer]); + let (pool, lc, dc) = session(&cache); + let (shown, _) = render(&p, &pool, &lc, cid, 0); + assert!(shown[3] > 0.99, "the plate shows: {shown:?}"); + dc.flush(); + drop((pool, lc, dc)); + + // A damaged entry: a miss, deleted, rendered again and stored anew. + let layers = cache.join("v1").join(Kind::Layer.dir()); + for shard in std::fs::read_dir(&layers).unwrap().flatten() { + for f in std::fs::read_dir(shard.path()).unwrap().flatten() { + let mut d = std::fs::read(f.path()).unwrap(); + let n = d.len() / 2; + d[n] ^= 0x5a; + std::fs::write(f.path(), d).unwrap(); + } + } + let (pool, lc, dc) = session(&cache); + let opened = crate::opens_under(&src); + let (again, files) = render(&p, &pool, &lc, cid, 0); + assert_eq!(again, shown); + assert_eq!(crate::opens_under(&src) - opened, 1, "read again, not served from the damaged entry"); + assert!(files.is_complete()); + dc.flush(); + drop((pool, lc, dc)); + let (pool, lc, dc) = session(&cache); + let hits = dc.stats().hits; + assert_eq!(render(&p, &pool, &lc, cid, 0).0, shown); + assert!(dc.stats().hits > hits, "the rewritten entry is used"); + drop((pool, lc, dc)); + + // The file is gone: not the old plate. + std::fs::remove_file(&path).unwrap(); + let (pool, lc, dc) = session(&cache); + let hits = dc.stats().hits; + let (gone, files) = render(&p, &pool, &lc, cid, 0); + assert!(gone[3] < 0.01, "a missing file shows nothing, never the cached plate: {gone:?}"); + assert_eq!(dc.stats().hits, hits); + assert!(files.files().iter().all(|f| f.stamp == FileStamp::Missing), "{files:?}"); + dc.flush(); + drop((pool, lc, dc)); + + // Something unreadable at its path (a folder): still not the old plate. + std::fs::create_dir(&path).unwrap(); + let (pool, lc, _dc) = session(&cache); + let (unreadable, _) = render(&p, &pool, &lc, cid, 0); + assert!(unreadable[3] < 0.01, "{unreadable:?}"); + std::fs::remove_dir(&path).unwrap(); + + // Back with other pixels: those show, not the entry made of the old file. + png(&path, 240); + touch_later(&path); + let (pool, lc, _dc) = session(&cache); + let (back, _) = render(&p, &pool, &lc, cid, 0); + assert!((back[0] - 240.0 / 255.0).abs() < 0.01, "{back:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/media/src/stream.rs b/crates/media/src/stream.rs index 80db21fc4..0155b4acd 100644 --- a/crates/media/src/stream.rs +++ b/crates/media/src/stream.rs @@ -9,6 +9,7 @@ use std::path::Path; +use effectcraft_render::reads::FileStamp; use filmcraft_media::SharedSource; use crate::Result; @@ -19,17 +20,22 @@ use crate::Result; pub(crate) struct FileReader { file: std::fs::File, len: u64, + /// The file's identity when it was opened. + stamp: FileStamp, } #[cfg(any(unix, windows))] impl FileReader { + /// Opens `path` for reading and records its length and modification time. pub(crate) fn open(path: &Path) -> std::io::Result { + #[cfg(test)] + crate::count_open(path); let file = std::fs::File::open(path)?; let m = file.metadata()?; if !m.is_file() { return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a file", path.display()))); } - Ok(Self { file, len: m.len() }) + Ok(Self { file, len: m.len(), stamp: FileStamp::of_metadata(&m) }) } } @@ -72,16 +78,19 @@ fn name_of(path: &Path) -> String { /// Open a movie/audio file for the media pool, reading only what its container needs; files the /// reader openers don't handle are read whole and opened from memory, exactly as before. `None` /// when the file can't be opened in place (the caller reads it whole, which reports the error). +/// With the file's identity as opened. #[cfg(any(unix, windows))] -pub(crate) fn open_source(path: &Path) -> Option> { +pub(crate) fn open_source(path: &Path) -> Option<(Result, FileStamp)> { let reader = FileReader::open(path).ok()?; + let stamp = reader.stamp; let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader); let opened = filmcraft_media::reader::open_reader(&name_of(path), reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers()); - Some(opened.map_err(Into::into)) + Some((opened.map_err(Into::into), stamp)) } +/// Without a native file system there is no in-place source: always `None`. #[cfg(not(any(unix, windows)))] -pub(crate) fn open_source(_path: &Path) -> Option> { +pub(crate) fn open_source(_path: &Path) -> Option<(Result, FileStamp)> { None } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 079b81765..c9322ac37 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -35,6 +35,7 @@ mod footage; mod item; mod layer; mod layers; +mod reach; mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; @@ -46,6 +47,7 @@ pub use layer::{ reads_other_frames, }; pub use layers::Layers; +pub use reach::{ExprReach, PixelReach, expression_reach}; pub use settings::{ APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView, TimeDisplayStyle, is_newer_version, saved_by, saved_view, diff --git a/crates/project/src/reach.rs b/crates/project/src/reach.rs new file mode 100644 index 000000000..31a2ed567 --- /dev/null +++ b/crates/project/src/reach.rs @@ -0,0 +1,405 @@ +//! What a composition's pixels can depend on, from the project in memory: the items it draws +//! and, when expressions are among them, what those expressions can read (#3184). The disk +//! cache keys a comp's frames by exactly these items, so an edit elsewhere in the project keeps +//! them. +//! +//! An expression reaches its own comp's layers (`thisComp`, `thisLayer`), the sources of the +//! layers it can read (`layer.source`, a precomp's layers through it), and other items by name: +//! `comp("Name")` and `footage("Name")`. A name given as a plain string literal is followed; a +//! computed name (`comp("Shot " + index)`), the object model's constructors and internals +//! (`new Comp(7)`, `__h`), dynamic code (`eval`, `Function`) and anything the scanner can't read +//! for sure (a regular expression literal, a template string, an escape in a name) count as +//! reaching every item of the project. The scan errs on the side of reaching more: an item +//! wrongly left out would serve a stale frame, one wrongly let in only renders again. +//! +//! Known limit (`ponytail`): an expression that builds the names of the object model's private +//! fields at run time into a variable and writes them with a computed key it reads from that +//! variable could forge another comp's id. That is deliberate tampering with the internals, not +//! an expression one writes; the scan refuses every direct form of it (`__` names, the +//! constructors, `Object`, `JSON`, `call`/`apply`/`bind`, computed writes and computed keys). +//! Upgrade: have the expression host record which items an evaluation reached. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::{Comp, ItemId, ItemKind, LayerId, Node, Project, PropGroup, Value}; + +/// What an expression's text can reach outside its own comp and the layers it can read. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExprReach { + /// Only items named by string literals: comps by `comp("…")`, footage by `footage("…")`. + Names { comps: Vec, footage: Vec }, + /// Any item of the project. + Anything, +} + +/// Names that hand an expression another item by id, run code built at run time, or give it the +/// tools to forge the object model's private fields. +const REACHES_ANYTHING: &[&str] = &[ + "Comp", + "Footage", + "Layer", + "Prop", + "Markers", + "eval", + "Function", + "globalThis", + "this", + "self", + "window", + "arguments", + "constructor", + "prototype", + "Object", + "Reflect", + "Proxy", + "JSON", + "call", + "apply", + "bind", + "class", + "with", + "import", + "require", + "fromCharCode", + "fromCodePoint", + "getOwnPropertyDescriptor", + "getOwnPropertyDescriptors", + "getPrototypeOf", + "setPrototypeOf", + "defineProperty", +]; + +/// Keywords after which a `/` starts a regular expression. +const BEFORE_REGEX: &[&str] = &["return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw", "case", "do", "else", "yield", "await"]; + +/// Keywords whose `( … )` a regular expression may follow (`if (a) /x/.test(s)`). +const CONTROL: &[&str] = &["if", "while", "for", "with"]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tok<'a> { + Ident(&'a str), + /// A string literal's text between its quotes; `plain` when it has no escape. + Str { + text: &'a str, + plain: bool, + }, + Num, + Punct(u8), +} + +/// The tokens of `text`, or `None` when it can't be read for sure. +fn tokens(text: &str) -> Option>> { + let b = text.as_bytes(); + let mut out: Vec> = Vec::new(); + // Per open `(`: whether a control keyword is before it; whether the last `)` closed one + // (only looked at right after a `)`). + let mut parens: Vec = Vec::new(); + let mut control_close = false; + let mut i = 0; + while let Some(&c) = b.get(i) { + match c { + b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c => i += 1, + b'/' if b.get(i + 1) == Some(&b'/') => { + while b.get(i).is_some_and(|c| *c != b'\n') { + i += 1; + } + } + b'/' if b.get(i + 1) == Some(&b'*') => { + let end = text.get(i + 2..)?.find("*/")?; + i += 2 + end + 2; + } + b'/' => { + if regex_may_start(out.last(), control_close) { + return None; + } + out.push(Tok::Punct(c)); + i += 1; + } + b'"' | b'\'' => { + let start = i + 1; + let mut j = start; + let mut plain = true; + loop { + match *b.get(j)? { + q if q == c => break, + b'\n' | b'\r' => return None, + b'\\' => { + // Only escapes that can't spell a name: `\n`, `\t`, a quote... + if !matches!(b.get(j + 1)?, b'n' | b'r' | b't' | b'b' | b'f' | b'v' | b'\'' | b'"' | b'\\') { + return None; + } + plain = false; + j += 2; + } + _ => j += 1, + } + } + let s = text.get(start..j)?; + if s.contains("__") { + return None; + } + out.push(Tok::Str { text: s, plain }); + i = j + 1; + } + b'0'..=b'9' => { + while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'.') { + i += 1; + } + out.push(Tok::Num); + } + b'.' if b.get(i + 1).is_some_and(u8::is_ascii_digit) => { + i += 1; + while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'.') { + i += 1; + } + out.push(Tok::Num); + } + c if c.is_ascii_alphabetic() || c == b'_' || c == b'$' => { + let start = i; + while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'$') { + i += 1; + } + out.push(Tok::Ident(text.get(start..i)?)); + } + // A template string (code inside `${}`), an escaped identifier, a name in another + // alphabet: not followed. + b'`' | b'\\' => return None, + c if !c.is_ascii() => return None, + c => { + match c { + b'(' => parens.push(matches!(out.last(), Some(Tok::Ident(k)) if CONTROL.contains(k))), + b')' => control_close = parens.pop().unwrap_or(false), + _ => {} + } + out.push(Tok::Punct(c)); + i += 1; + } + } + } + Some(out) +} + +/// Whether a `/` after `prev` may start a regular expression literal (which the scanner doesn't +/// read: its text could hide a quote). +fn regex_may_start(prev: Option<&Tok<'_>>, control_close: bool) -> bool { + match prev { + None => true, + Some(Tok::Ident(k)) => BEFORE_REGEX.contains(k), + Some(Tok::Num | Tok::Str { .. }) => false, + Some(Tok::Punct(b')')) => control_close, + Some(Tok::Punct(b']')) => false, + Some(Tok::Punct(_)) => true, + } +} + +/// What the expression `text` can reach by name, or [`ExprReach::Anything`]. +pub fn expression_reach(text: &str) -> ExprReach { + expression_names(text).map_or(ExprReach::Anything, |(comps, footage)| ExprReach::Names { comps, footage }) +} + +/// The comp names and footage names an expression references by literal name. +/// Returns `None` when it can reach anything by a computed name or cannot be tokenised. +fn expression_names(text: &str) -> Option<(Vec, Vec)> { + let toks = tokens(text)?; + let (mut comps, mut footage) = (Vec::new(), Vec::new()); + // Per open `[`: a computed key (`{[k]: v}`), a computed member (`o[k]`). + let mut brackets: Vec<(bool, bool)> = Vec::new(); + for (i, t) in toks.iter().enumerate() { + let prev = i.checked_sub(1).and_then(|p| toks.get(p)); + let next = |n: usize| toks.get(i + n); + match *t { + Tok::Ident(name) if name.starts_with("__") || REACHES_ANYTHING.contains(&name) => return None, + Tok::Ident(name @ ("comp" | "footage")) => { + // Only a call with a plain literal, not a property of something else. + let (Some(Tok::Punct(b'(')), Some(Tok::Str { text, plain: true }), Some(Tok::Punct(b')'))) = (next(1), next(2), next(3)) else { return None }; + if matches!(prev, Some(Tok::Punct(b'.'))) { + return None; + } + if name == "comp" { comps.push((*text).to_string()) } else { footage.push((*text).to_string()) } + } + Tok::Punct(b'[') => { + let key = matches!(prev, Some(Tok::Punct(b'{' | b',')) | Some(Tok::Ident("get" | "set" | "static" | "async")) | Some(Tok::Punct(b'*'))); + let member = matches!(prev, Some(Tok::Ident(_) | Tok::Str { .. } | Tok::Num | Tok::Punct(b')' | b']' | b'.'))); + brackets.push((key, member)); + } + Tok::Str { .. } if brackets.last().is_some_and(|(_, member)| *member) => return None, + Tok::Punct(b']') => { + let (key, _) = brackets.pop().unwrap_or((true, true)); + // A computed key (`{[k]: v}`, `{[k]() {}}`) or a write through brackets (`o[k] = v`, + // `o[k] ||= v`, `for (o[k] in …)`, `[o[k]] = v`) could name a private field. + if key && matches!(next(1), Some(Tok::Punct(b':' | b'('))) { + return None; + } + if assigns(&toks, i + 1) || matches!(next(1), Some(Tok::Ident("in" | "of"))) { + return None; + } + } + // A destructuring write (`({a: o[k]} = v)`). + Tok::Punct(b'}') if assigns(&toks, i + 1) => return None, + _ => {} + } + } + Some((comps, footage)) +} + +/// Whether the tokens from `at` are an assignment operator (`=`, `+=`, `>>>=`, `??=`...), not a +/// comparison (`==`, `<=`) or an arrow (`=>`). +fn assigns(toks: &[Tok<'_>], at: usize) -> bool { + let mut op = Vec::new(); + for t in toks.iter().skip(at).take(5) { + match t { + Tok::Punct(c) if b"+-*/%&|^<>?=".contains(c) => op.push(*c), + _ => break, + } + } + let Some(eq) = op.iter().position(|c| *c == b'=') else { return false }; + let (before, after) = op.split_at(eq); + if after.get(1).is_some_and(|c| *c == b'=' || *c == b'>') { + return false; + } + matches!(before, b"" | b"+" | b"-" | b"*" | b"/" | b"%" | b"&" | b"|" | b"^" | b"**" | b"<<" | b">>" | b">>>" | b"&&" | b"||" | b"??") +} + +/// The items a comp's pixels can depend on ([`Project::pixel_reach`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PixelReach { + /// The items whose state counts; `None`: every item of the project. + pub items: Option>, + /// Expressions are among them: they can read every layer of the comps they reach (hidden + /// and silent ones too) and the markers, which therefore count as well (#1083). + pub expressions: bool, + /// The names expressions look items up by (`true`: a comp's, `false`: footage's), each with + /// the ids of the items of that kind that have it now, in id order (the first one is what + /// the lookup finds; another item taking or giving up the name changes it). + pub names: Vec<(bool, String, Vec)>, +} + +/// Each property of a group and its subgroups (iterative: no depth limit to hit). +fn each_prop<'a>(g: &'a PropGroup, f: &mut dyn FnMut(&'a crate::Property) -> Option<()>) -> Option<()> { + let mut stack = vec![g]; + while let Some(g) = stack.pop() { + for n in &g.children { + match n { + Node::Prop(p) => f(p)?, + Node::Group(sub) => stack.push(sub), + } + } + } + Some(()) +} + +impl Project { + /// The items comp `comp` draws ([`Self::silent_layers`] aside, with `heard`) and the + /// footage its precomp layers swap in (Essential Graphics Media Replacement); with an + /// expression in any of its comps, everything those expressions can reach + /// ([`expression_reach`]). Memory only: no file is looked at. + pub fn pixel_reach(&self, comp: ItemId, heard: &crate::Heard) -> PixelReach { + let Some(drawn) = self.reach_walk(comp, &|id, c| self.silent_layers(id, c, heard), &mut |_| Some(vec![])) else { return PixelReach::every() }; + let has_expression = |g: &PropGroup| each_prop(g, &mut |p| p.expr.is_none().then_some(())).is_none(); + if !drawn.iter().filter_map(|id| self.comp(*id)).any(|c| c.layers.iter().any(|l| has_expression(&l.props))) { + return PixelReach { items: Some(drawn), expressions: false, names: vec![] }; + } + // Expressions: every layer of a comp they reach counts, and what they name. + let mut names: BTreeMap<(bool, String), Vec> = BTreeMap::new(); + let mut scan = |p: &crate::Property| match p.expr.as_ref().map(|e| expression_reach(&e.text)) { + None => Some(vec![]), + Some(ExprReach::Anything) => None, + Some(ExprReach::Names { comps, footage }) => { + let mut found = vec![]; + for (is_comp, name) in comps.into_iter().map(|n| (true, n)).chain(footage.into_iter().map(|n| (false, n))) { + let ids = self.named(is_comp, &name); + found.extend(ids.iter().copied()); + names.insert((is_comp, name), ids); + } + Some(found) + } + }; + let Some(mut items) = self.reach_walk(comp, &|_, _| BTreeSet::new(), &mut scan) else { return PixelReach::every() }; + // A source's data is read by its name (`layer.source.sourceData`). + let footage_names: BTreeSet<&str> = + items.iter().filter_map(|id| self.item(*id)).filter(|i| matches!(i.kind, ItemKind::Footage(_))).map(|i| i.name.as_str()).collect(); + let same_name: Vec = + self.items.values().filter(|i| matches!(i.kind, ItemKind::Footage(_)) && footage_names.contains(i.name.as_str())).map(|i| i.id).collect(); + items.extend(same_name); + PixelReach { items: Some(items), expressions: true, names: names.into_iter().map(|((c, n), ids)| (c, n, ids)).collect() } + } + + /// The items of one kind (`is_comp`: comps, else footage) named `name`, in id order. + fn named(&self, is_comp: bool, name: &str) -> Vec { + self.items + .values() + .filter(|i| i.name == name && if is_comp { matches!(i.kind, ItemKind::Comp(_)) } else { matches!(i.kind, ItemKind::Footage(_)) }) + .map(|i| i.id) + .collect() + } + + /// The items reached from `comp` through its layers (but those `quiet` names) and the items + /// `scan` returns for each of their properties; `None` when something reaches every item. + /// (A stack and a visited set: each item is visited once, cycles included.) + fn reach_walk( + &self, + comp: ItemId, + quiet: &dyn Fn(ItemId, &Comp) -> BTreeSet, + scan: &mut dyn FnMut(&crate::Property) -> Option>, + ) -> Option> { + let mut seen = BTreeSet::new(); + let mut stack = vec![comp]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + continue; + } + let Some(c) = self.comp(id) else { continue }; + let quiet = quiet(id, c); + for l in c.layers.iter().filter(|l| !quiet.contains(&l.id)) { + // Every source item: a 3D model's too (it is drawn, and `layer.source` reads its + // name and size). + if let Some(item) = l.source.item() { + stack.push(item); + } + stack.extend(self.media_replacements(l)?); + let mut reached = vec![]; + each_prop(&l.props, &mut |p| { + reached.extend(scan(p)?); + Some(()) + })?; + stack.extend(reached); + } + } + Some(seen) + } + + /// The footage a precomp layer swaps in through its Essential Properties (Media Replacement): + /// the items its overridden properties name. `None` when they can't be listed (an + /// expression or keyframes choose the item over time). + fn media_replacements(&self, layer: &crate::Layer) -> Option> { + let over = crate::essential::overridden(layer); + let Some(g) = crate::essential::group(layer).filter(|_| !over.is_empty()) else { return Some(vec![]) }; + let mut out = vec![]; + each_prop(g, &mut |p| { + if !over.contains(&p.uid) { + return Some(()); + } + if p.expr.is_some() || !p.keys.is_empty() { + return None; + } + if let Value::Scalar(id) = p.value { + let item = ItemId(id.max(0.0) as u64); + if matches!(self.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) { + out.push(item); + } + } + Some(()) + })?; + Some(out) + } +} + +impl PixelReach { + /// Every item of the project, with expressions. + fn every() -> PixelReach { + PixelReach { items: None, expressions: true, names: vec![] } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/project/src/reach/tests.rs b/crates/project/src/reach/tests.rs new file mode 100644 index 000000000..53db8172a --- /dev/null +++ b/crates/project/src/reach/tests.rs @@ -0,0 +1,198 @@ +use super::*; +use crate::{Expression, LayerSource, Solid, build}; +use effectcraft_time::{FrameRate, Tick}; + +fn names(comps: &[&str], footage: &[&str]) -> ExprReach { + ExprReach::Names { comps: comps.iter().map(|s| s.to_string()).collect(), footage: footage.iter().map(|s| s.to_string()).collect() } +} + +/// Expressions that read their own comp, or other items by literal names, are followed; every +/// way to reach items by a computed name or an id, or text the scanner can't read for sure, +/// reaches everything. +#[test] +fn expression_reach_follows_literal_names_and_refuses_the_rest() { + let follows = [ + ("wiggle(2, 20)", names(&[], &[])), + ("thisComp.layer(\"Comp 1 Layer\").transform.opacity * 2 / 3", names(&[], &[])), + ("value + [10, 0]; value[0] / 2", names(&[], &[])), + ("// comp(name) in a comment\n/* footage(x) */ value", names(&[], &[])), + ("\"a string that says comp(x) and Layer\"", names(&[], &[])), + ("comp(\"Other\").layer(1).transform.position", names(&["Other"], &[])), + ("comp( 'A' ).width + comp(\"B\").height", names(&["A", "B"], &[])), + ("footage(\"data.json\").sourceData.x", names(&[], &["data.json"])), + ("x = a[0] <= b ? a[1] : 0; y = a[0] == 1", names(&[], &[])), + ("thisLayer.source.layer(1).name", names(&[], &[])), + ("if (a) x = 1; else x = 2; (a + b) / 2", names(&[], &[])), + ("\"Ünïcödé name\"", names(&[], &[])), + ("const f = (v) => v * 2; f(1)", names(&[], &[])), + ]; + for (text, want) in follows { + assert_eq!(expression_reach(text), want, "{text}"); + } + let anything = [ + "comp(\"Shot \" + index).layer(1)", + "comp(name)", + "var n = \"Other\"; comp(n)", + "comp(\"O\\\"ther\")", + "x.comp(\"Other\")", + "var comp = 1", + "footage(thisComp.name)", + "new Comp(7).layer(1)", + "Layer(7, 3).name", + "__h(__COMP, 7)", + "o.__c = 7", + "eval('1')", + "Function('return 1')()", + "this.x", + "({}).constructor", + "Object.getPrototypeOf(thisComp)", + "JSON.parse(s)", + "thisComp.layer.call(o, 1)", + "o['_' + '_c']", + "o[k] = 7", + "o[k] ||= 7", + "o[k] += 7", + "[o[k]] = [7]", + "({a: o[k]} = {a: 7})", + "for (o[k] in x) {}", + "({[k]: 7})", + "({a: 1, [k]: 7})", + "o = {'__c': 7}", + "'\\x5f\\x5fc'", + "'\\_\\_c'", + "`${x}`", + "c\\u006fmp('A')", + "comp\u{0430}('A')", + "/\"/.test(s); comp(n); \"", + "return /x/", + "if (a) /\"/.test(s)", + "x = '", + "/* comp(n)", + "class X {}", + "String.fromCharCode(95)", + ]; + for text in anything { + assert_eq!(expression_reach(text), ExprReach::Anything, "{text}"); + } +} + +/// `a / b` divides after a name, a number, a string and a closing bracket or parenthesis. +#[test] +fn division_is_not_a_regular_expression() { + for text in ["a / b", "2 / 3", "'a' / 2", "x[0] / 2", "(a + b) / 2", "f(x) / 2 / g(y)"] { + assert_eq!(expression_reach(text), names(&[], &[]), "{text}"); + } +} + +struct Fixture { + p: Project, + main: ItemId, + other: ItemId, + third: ItemId, + unused: ItemId, + solid: ItemId, +} + +/// Comps "Main" (a solid), "Other" (a solid), "Third" (empty), an unused footage item. +fn fixture() -> Fixture { + let mut p = Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let solid = + p.add_item("Solid", effectcraft_color::Label::None, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 64, height: 64, pixel_aspect: 1.0 })); + let main = p.add_item("Main", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into())); + let other = p.add_item("Other", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into())); + let third = p.add_item("Third", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into())); + let unused = p.add_item("unused.png", effectcraft_color::Label::None, None, ItemKind::Footage(crate::Footage { has_video: true, ..Default::default() })); + for c in [main, other] { + let l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: solid }, (64, 64), None); + p.comp_mut(c).unwrap().layers.push(l); + } + Fixture { p, main, other, third, unused, solid } +} + +fn set_expr(p: &mut Project, comp: ItemId, text: &str) { + let l = p.comp_mut(comp).unwrap().layers.first_mut().unwrap(); + l.props.prop_mut("transform/opacity").unwrap().expr = Some(Expression { text: text.into(), enabled: true }); +} + +fn reach(p: &Project, comp: ItemId) -> PixelReach { + p.pixel_reach(comp, &p.heard_layers()) +} + +#[test] +fn pixel_reach_is_what_the_comp_draws_and_what_its_expressions_name() { + let mut f = fixture(); + let ids = |r: &PixelReach| r.items.clone().map(|s| s.into_iter().collect::>()); + assert_eq!(reach(&f.p, f.main), PixelReach { items: Some([f.solid, f.main].into()), expressions: false, names: vec![] }); + // An expression reading only its own comp: what it draws, with expressions. + set_expr(&mut f.p, f.main, "thisComp.layer(1).transform.opacity"); + let r = reach(&f.p, f.main); + assert!(r.expressions); + assert_eq!(ids(&r), Some(vec![f.solid, f.main])); + // An expression in another comp doesn't reach this one. + set_expr(&mut f.p, f.other, "wiggle(1, 1)"); + assert_eq!(ids(&reach(&f.p, f.main)), Some(vec![f.solid, f.main])); + // A literal name: that comp and what it draws, with the name and the comps that have it. + set_expr(&mut f.p, f.main, "comp(\"Other\").layer(1).transform.opacity"); + let r = reach(&f.p, f.main); + assert_eq!(ids(&r), Some(vec![f.solid, f.main, f.other])); + assert_eq!(r.names, vec![(true, "Other".to_string(), vec![f.other])]); + // Another comp taking the name is part of it, whichever the lookup finds. + f.p.item_mut(f.third).unwrap().name = "Other".into(); + let r = reach(&f.p, f.main); + assert_eq!(r.names, vec![(true, "Other".to_string(), vec![f.other, f.third])]); + assert!(r.items.unwrap().contains(&f.third)); + // A name no item has yet still counts (with no item). + set_expr(&mut f.p, f.main, "footage(\"later.json\").sourceData"); + assert_eq!(reach(&f.p, f.main).names, vec![(false, "later.json".to_string(), vec![])]); + // A computed name reaches everything. + set_expr(&mut f.p, f.main, "comp(\"Ot\" + \"her\").width"); + assert_eq!(reach(&f.p, f.main), PixelReach { items: None, expressions: true, names: vec![] }); + // Footage reached as a layer's source brings the footage of its name (its data is read by + // name). + let comp = f.p.comp(f.main).unwrap().clone(); + let l = build::layer(&mut f.p, &comp, "U", LayerSource::Footage { item: f.unused }, (64, 64), None); + f.p.comp_mut(f.main).unwrap().layers.push(l); + let twin = f.p.add_item("unused.png", effectcraft_color::Label::None, None, ItemKind::Footage(crate::Footage::default())); + set_expr(&mut f.p, f.main, "value"); + let r = reach(&f.p, f.main).items.unwrap(); + assert!(r.contains(&f.unused) && r.contains(&twin), "{r:?}"); +} + +/// A comp nested in the reach is scanned too: its expression's names count for the outer comp. +#[test] +fn nested_comps_bring_their_expressions_reach() { + let mut f = fixture(); + let comp = f.p.comp(f.main).unwrap().clone(); + let pre = build::layer(&mut f.p, &comp, "Pre", LayerSource::Comp { item: f.other }, (64, 64), None); + f.p.comp_mut(f.main).unwrap().layers.push(pre); + set_expr(&mut f.p, f.other, "comp(\"Third\").width"); + let r = reach(&f.p, f.main); + assert!(r.expressions && r.items.unwrap().contains(&f.third)); + // A cycle through names ends. + let l = build::layer(&mut f.p, &comp, "S", LayerSource::Solid { item: f.solid }, (64, 64), None); + f.p.comp_mut(f.third).unwrap().layers.push(l); + set_expr(&mut f.p, f.third, "comp(\"Main\").width"); + assert!(reach(&f.p, f.main).items.unwrap().contains(&f.main)); +} + +/// A 3D model layer's item is reached like any other source: it is drawn, and an expression +/// reads its name and size through `layer.source` (a renamed model left the comp's disk key and +/// its old frames in place). +#[test] +fn model_sources_are_reached() { + let mut f = fixture(); + let model = f.p.add_item( + "chair.glb", + effectcraft_color::Label::None, + None, + ItemKind::Footage(crate::Footage { kind: crate::FootageKind::Model, ..Default::default() }), + ); + let comp = f.p.comp(f.main).unwrap().clone(); + let l = build::layer(&mut f.p, &comp, "Model", LayerSource::Model { item: model }, (64, 64), None); + f.p.comp_mut(f.main).unwrap().layers.push(l); + assert!(reach(&f.p, f.main).items.unwrap().contains(&model), "drawn"); + set_expr(&mut f.p, f.main, "thisComp.layer(\"Model\").source.name.length"); + let r = reach(&f.p, f.main); + assert!(r.expressions && r.items.unwrap().contains(&model), "read by an expression"); +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index efbd1fc19..cd67e846a 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -26,3 +26,6 @@ rayon = { workspace = true } web-time = { workspace = true } serde = { workspace = true } log = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index f545a62ee..0f271c59d 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -224,7 +224,7 @@ impl<'a> Renderer<'a> { LayerSource::Footage { item } => { let item = ctx.media_item(layer, *item); let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None }; - let a = self.footage.aux(item, f, ctx.source_time(layer))?; + let a = self.read(|| self.footage.aux(item, f, ctx.source_time(layer)))?; // Aux pixels per layer pixel (the layer is the footage at its nominal size). The // planes are only copied when that changes the scale. let scale = a.width as f64 / f.width as f64; @@ -349,20 +349,19 @@ impl<'a> Renderer<'a> { 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))); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { + let key = self.mask_gate_key(ctx, layer, key); + if let Some((b, _)) = self.cached(key) { return Some(b); } - let mut foot = self.source(ctx, layer)?; - let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); - let foot = Arc::new(foot); + let (foot, files) = self.recorded(key, |r| { + // (what the masks' expressions read, at shutter times too, goes to the buffer's files) + let ctx = &r.with_reads(ctx); + let mut foot = r.source(ctx, layer)?; + let _ = masks::apply(ctx, layer, &mut foot, r.mask_blur(ctx, layer)); + Some(foot) + }); // The buffer the cache keeps (another worker's, if it kept one first). - let foot = match (self.cache_to_keep(), key) { - (Some(c), Some(k)) => c.insert(k, foot), - _ => foot, - }; - Some(foot) + Some(self.keep(key, Arc::new(foot?), &files).0) } /// Draw one 2D layer into `canvas` on the CPU (any kind: adjustment, wireframe, styled…). diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index cf1a84e82..8a3d7b8ae 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -22,27 +22,36 @@ use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; use effectcraft_effects::Buf; +use effectcraft_keyframe::Value; use effectcraft_project::{FootageKind, ItemKind, Layer, LayerSource, Node, PropGroup}; +use crate::disk_cache::{EntryFiles, SharedVirtualFileId, VirtualFileId, layer_disk_key}; use crate::eval::EvalCtx; +use crate::reads::ReadFiles; mod dependencies; /// Thread-safe, memory-budgeted cache of processed layer buffers, optionally backed by the /// persistent [`DiskCache`](crate::disk_cache::DiskCache): a memory miss reads the disk, and -/// buffers that were slow to render are written there (keys salted per project, see -/// [`LayerCache::set_disk`]). +/// buffers that were slow to render are written there with the source files they were made of +/// (keys: [`crate::disk_cache::layer_disk_key`]; an entry is used only while its files are +/// unchanged, #3184). pub struct LayerCache { inner: Mutex, - disk: Mutex, u64)>>, + disk: Mutex>>, /// Another persistent store when there is no disk cache (the browser's, [`PrefetchStore`]). - store: Mutex, u64)>>, + store: Mutex>>, + /// Size and content hash of files served from memory (the browser file table), to store + /// and check the source files of persistent entries. + virtual_id: Mutex>, /// While set, inserts are dropped (see [`LayerCache::set_gate`]). gate: Mutex>>, } struct Entry { buf: Arc, + /// The source files the buffer was made of (see [`crate::reads`]). + files: Arc, bytes: usize, last_use: u64, } @@ -54,7 +63,9 @@ struct Inner { clock: u64, hits: u64, misses: u64, - /// When each recent miss happened (to time the render that follows it). + /// When each recent miss happened, to time the render that follows it (the drop generation + /// a render's reads started at travels with its files instead, [`ReadFiles::since`]: two + /// renders of a key may overlap). missed_at: HashMap, } @@ -80,6 +91,7 @@ impl LayerCache { inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0, missed_at: HashMap::new() }), disk: Mutex::new(None), store: Mutex::new(None), + virtual_id: Mutex::new(None), gate: Mutex::new(None), } } @@ -98,77 +110,75 @@ impl LayerCache { self.gate.lock().ok().and_then(|g| g.as_ref().map(|g| g.load(std::sync::atomic::Ordering::Relaxed))).unwrap_or(false) } - /// Attach (or detach) the disk cache. `salt` separates projects and footage versions (see - /// `footage_salt`); keys on disk are `(salt, key)`. - pub fn set_disk(&self, disk: Option<(Arc, u64)>) { + /// Attach (or detach) the disk cache. It is used at once: its entries check their own + /// source files when read, so nothing has to be looked at first. + pub fn set_disk(&self, disk: Option>) { if let Ok(mut d) = self.disk.lock() { *d = disk; } } - /// Change the disk salt (after footage changed) keeping the attached disk cache. - pub fn set_disk_salt(&self, salt: u64) { - if let Ok(mut d) = self.disk.lock() - && let Some((_, s)) = d.as_mut() - { - *s = salt; - } - } - + /// The attached disk cache, if any. pub fn disk(&self) -> Option> { - self.disk.lock().ok()?.as_ref().map(|d| d.0.clone()) + self.disk.lock().ok()?.clone() } /// Attach (or detach) a [`LayerStore`] used when no disk cache is attached (the browser's - /// Origin Private File System through a [`PrefetchStore`]); `salt` as in - /// [`LayerCache::set_disk`]. - pub fn set_store(&self, store: Option<(Arc, u64)>) { + /// Origin Private File System through a [`PrefetchStore`]). + pub fn set_store(&self, store: Option>) { if let Ok(mut s) = self.store.lock() { *s = store; } } - /// Change the store's salt (a new project or footage), keeping the store. - pub fn set_store_salt(&self, salt: u64) { - if let Ok(mut d) = self.store.lock() - && let Some((_, s)) = d.as_mut() - { - *s = salt; + /// Size and content hash of files served from memory (the browser file table): what a + /// persistent entry keeps for them and checks them against. + pub fn set_virtual_id(&self, id: Option) { + if let Ok(mut v) = self.virtual_id.lock() { + *v = id; } } - fn disk_key(&self, key: u64) -> Option<(Arc, u128)> { - let salted = |salt: u64| ((salt as u128) << 64) | key as u128; - if let Some((dc, salt)) = self.disk.lock().ok()?.as_ref() { - return Some((dc.clone() as Arc, salted(*salt))); - } - let s = self.store.lock().ok()?; - let (st, salt) = s.as_ref()?; - Some((st.clone(), salted(*salt))) + /// The attached virtual-file identity lookup, if any. + fn virtual_id(&self) -> Option { + self.virtual_id.lock().ok()?.clone() } - pub fn get(&self, key: u64) -> Option> { - let mut g = self.inner.lock().ok()?; - g.clock += 1; - let now = g.clock; - match g.map.get_mut(&key) { - Some(e) => { - e.last_use = now; - let b = e.buf.clone(); - g.hits += 1; - Some(b) - } + /// The persistent store behind the memory: the disk cache, else the other store. + fn persistent(&self) -> Option> { + if let Some(dc) = self.disk.lock().ok()?.as_ref() { + return Some(dc.clone() as Arc); + } + self.store.lock().ok()?.clone() + } + + /// The buffer kept under `key` and the source files it was made of (see [`crate::reads`]). + /// A buffer from the disk cache or another store comes with the files its entry lists, after + /// they were found unchanged. A buffer made of a file dropped since its reads began (File ▸ + /// Reload Footage) is never served: it is forgotten, and the lookup is a miss. + pub fn get(&self, key: u64) -> Option<(Arc, Arc)> { + match self.kept(key, true) { + Some(found) => Some(found), None => { + let mut g = self.inner.lock().ok()?; g.misses += 1; - if g.missed_at.len() > 4096 { - g.missed_at.clear(); - } - g.missed_at.insert(key, web_time::Instant::now()); drop(g); - // Disk cache: a hit comes back into memory. - let (dc, dk) = self.disk_key(key)?; - let buf = dc.get_layer(dk)?; - Some(self.insert_mem(key, buf)) + let store = self.persistent(); + // (before the entry is read: what it is made of may be dropped meanwhile) + let since = store.as_ref().map_or(0, |s| s.drop_generation()); + if let Ok(mut g) = self.inner.lock() { + if g.missed_at.len() > 4096 { + g.missed_at.clear(); + } + g.missed_at.insert(key, web_time::Instant::now()); + } + // Disk cache: a hit comes back into memory, read at generation `since`. + let store = store?; + let id = self.virtual_id(); + let (buf, mut files) = store.get_layer(layer_disk_key(key), id.as_deref())?; + files.note_since(since); + let files = Arc::new(files); + Some(self.insert_mem(key, buf, files)) } } } @@ -178,39 +188,113 @@ impl LayerCache { /// together each rendered every layer; sharing the first buffer, the GPU compositor uploads /// and packs it once instead of once per worker, which ran it out of memory). Renders don't /// wait for each other: on the frame workers' shared thread pool a wait can end up under - /// the very work it waits for. - pub fn insert(&self, key: u64, buf: Arc) -> Arc { + /// the very work it waits for. `files`: the source files `buf` was made of (see + /// [`crate::reads`]); a buffer whose files can't all be named stays in memory. The files + /// returned are the ones the returned buffer was made of. + /// + /// The drop generation `buf`'s reads started at comes with `files` ([`ReadFiles::since`], + /// from [`LayerCache::drop_generation`] when the render began): a buffer made of a file + /// dropped since (File ▸ Reload Footage while it rendered) is kept neither on disk nor in + /// memory. A list that doesn't say counts as made before every drop. A buffer made of values + /// its key doesn't capture ([`ReadFiles::is_unkeyed`]) stays in memory. + pub fn insert(&self, key: u64, buf: Arc, files: Arc) -> (Arc, Arc) { if self.gated() { - return buf; + return (buf, files); } - if let Some(kept) = self.inner.lock().ok().and_then(|g| g.map.get(&key).map(|e| e.buf.clone())) { + if let Some(kept) = self.kept(key, false) { return kept; } + let store = self.persistent(); + let since = files.since().unwrap_or(0); + // (before the miss's time is taken: it may time another render of the key) + if store.as_ref().is_some_and(|s| s.dropped_since(&files, since)) { + return (buf, files); + } let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key)); - if let Some((dc, dk)) = self.disk_key(key) { - let slow = started.is_some_and(|t| t.elapsed().as_secs_f64() * 1e3 >= dc.min_layer_ms() as f64); - if slow { - dc.put_layer(dk, &buf); + if let Some(store) = store { + let id = self.virtual_id(); + // (an incomplete list, or a file from memory without its identity: not stored) + if let Some(t) = started + && !files.is_unkeyed() + && t.elapsed().as_secs_f64() * 1e3 >= store.min_layer_ms() as f64 + && let Some(entry) = EntryFiles::of(&files, id.as_deref()) + { + store.put_layer(layer_disk_key(key), &buf, &entry, since); } } - self.insert_mem(key, buf) + self.insert_mem(key, buf, files) } - /// See [`LayerCache::insert`]: the buffer kept under `key` (`buf`, unless one was kept first - /// or `buf` is too large to keep). - fn insert_mem(&self, key: u64, buf: Arc) -> Arc { + /// The buffer kept in memory under `key` with its files, if it is not made of a file dropped + /// since its reads began (one that is, is forgotten). `hit`: count it as a hit and as just + /// used. (A render that started before a drop may publish its buffer after the drop's + /// purge, Reload Footage clearing the cache while the buffer was being written: the check + /// here, not the order of the two, keeps it from being served.) + fn kept(&self, key: u64, hit: bool) -> Option<(Arc, Arc)> { + let found = { + let mut g = self.inner.lock().ok()?; + g.clock += 1; + let now = g.clock; + let e = g.map.get_mut(&key)?; + if hit { + e.last_use = now; + } + (e.buf.clone(), e.files.clone()) + }; + if self.made_before_a_drop(&found.1) { + if let Ok(mut g) = self.inner.lock() + && g.map.get(&key).is_some_and(|e| Arc::ptr_eq(&e.files, &found.1)) + && let Some(e) = g.map.remove(&key) + { + g.bytes = g.bytes.saturating_sub(e.bytes); + } + return None; + } + if hit && let Ok(mut g) = self.inner.lock() { + g.hits += 1; + } + Some(found) + } + + /// Whether a buffer made of `files` holds a file as it was before a drop that came after its + /// reads began ([`ReadFiles::since`]; a list that doesn't say counts as made before every + /// drop). + fn made_before_a_drop(&self, files: &ReadFiles) -> bool { + self.persistent().is_some_and(|s| s.dropped_since(files, files.since().unwrap_or(0))) + } + + /// The persistent store's drop generation now ([`LayerStore::drop_generation`]), taken by a + /// render before it starts reading (`None` without a store). + pub fn drop_generation(&self) -> Option { + self.persistent().map(|s| s.drop_generation()) + } + + /// See [`LayerCache::insert`]: the buffer kept under `key` and its files (`buf` and `files`, + /// unless one was kept first or `buf` is too large to keep). + fn insert_mem(&self, key: u64, buf: Arc, files: Arc) -> (Arc, Arc) { let bytes = buf.img.data.len() * std::mem::size_of::(); - let Ok(mut g) = self.inner.lock() else { return buf }; + let Ok(mut g) = self.inner.lock() else { return (buf, files) }; if bytes > g.budget / 4 { - return buf; + return (buf, files); } g.clock += 1; let now = g.clock; if let Some(e) = g.map.get_mut(&key) { - e.last_use = now; - return e.buf.clone(); + // (one made before a drop gives way: see [`LayerCache::kept`]) + if !self.made_before_a_drop(&e.files) { + e.last_use = now; + return (e.buf.clone(), e.files.clone()); + } + if let Some(old) = g.map.remove(&key) { + g.bytes = g.bytes.saturating_sub(old.bytes); + } } - g.map.insert(key, Entry { buf: buf.clone(), bytes, last_use: now }); + // (checked again just before it is published: a drop may have come since `insert` + // checked; one that comes later is caught when the buffer is looked up) + if self.made_before_a_drop(&files) { + return (buf, files); + } + g.map.insert(key, Entry { buf: buf.clone(), files: files.clone(), bytes, last_use: now }); g.bytes += bytes; if g.bytes > g.budget { // Evict least-recently used entries down to 3/4 of the budget. @@ -226,7 +310,7 @@ impl LayerCache { } } } - buf + (buf, files) } /// The memory budget in bytes. @@ -264,12 +348,28 @@ impl LayerCache { } } -/// Persistent storage of layer buffers behind a [`LayerCache`] (keys salted per project). +/// Persistent storage of layer buffers behind a [`LayerCache`] (keys: +/// [`crate::disk_cache::layer_disk_key`]). Every entry keeps the source files its buffer was made +/// of and is used only while they are unchanged (#3184). pub trait LayerStore: Send + Sync { - /// A stored buffer (synchronous: mid-render). - fn get_layer(&self, key: u128) -> Option>; - /// Keep a buffer that was slow to render. - fn put_layer(&self, key: u128, buf: &Buf); + /// A stored buffer whose source files are unchanged, with those files (synchronous: + /// 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 + /// [`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); + /// 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 { + 0 + } + /// Whether one of `files` was dropped after generation `since`: what was made of them is + /// from before the drop. `false` for a store that never drops. + fn dropped_since(&self, _files: &ReadFiles, _since: u64) -> bool { + false + } /// Buffers faster to render than this (ms) are not stored. fn min_layer_ms(&self) -> u64 { 0 @@ -281,9 +381,10 @@ pub const PREFETCH_MIN_LAYER_MS: u64 = 20; /// A [`LayerStore`] over storage that can't be read in the middle of a render (the browser's /// Origin Private File System, read asynchronously). Lookups are served from buffers fetched -/// before the frame ([`PrefetchStore::provide`]); the keys a render looked up and didn't find -/// are recorded ([`PrefetchStore::take_misses`]) so the transport can fetch them before the next -/// frame; buffers to store queue up as sealed entries ([`PrefetchStore::take_writes`]) for the +/// before the frame ([`PrefetchStore::provide`]), once the files their entries list are found +/// unchanged; the keys a render looked up and didn't find are recorded +/// ([`PrefetchStore::take_misses`]) so the transport can fetch them before the next frame; +/// buffers to store queue up as sealed entries ([`PrefetchStore::take_writes`]) for the /// transport to write after the frame. Fetched buffers are kept least recently used first, up /// to a byte budget. pub struct PrefetchStore { @@ -292,9 +393,21 @@ pub struct PrefetchStore { min_ms: u64, } +/// A fetched entry: the buffer, the source files it was made of, its last use. +struct Fetched { + buf: Arc, + files: Arc, + used: u64, +} + +/// The memory a buffer's pixels take, in bytes. +fn buf_bytes(b: &Buf) -> usize { + b.img.data.len().saturating_mul(std::mem::size_of::()) +} + #[derive(Default)] struct Prefetched { - ready: HashMap, u64)>, + ready: HashMap, bytes: usize, clock: u64, misses: Vec, @@ -304,6 +417,22 @@ struct Prefetched { known: std::collections::HashSet, } +impl Prefetched { + /// Records a missed key for prefetching, up to a fixed bound. + fn note_miss(&mut self, key: u128) { + if !self.misses.contains(&key) && self.misses.len() < 1024 { + self.misses.push(key); + } + } + + /// Drops the prefetched entry for `key` and frees its bytes. + fn remove(&mut self, key: u128) { + if let Some(f) = self.ready.remove(&key) { + self.bytes = self.bytes.saturating_sub(buf_bytes(&f.buf)); + } + } +} + impl Default for PrefetchStore { fn default() -> Self { PrefetchStore::new(256 << 20) @@ -327,23 +456,21 @@ impl PrefetchStore { } /// A fetched entry (sealed bytes, [`crate::disk_cache::layer_entry`]) for `key`; `false` - /// when damaged. + /// when damaged or of another format. Its files are checked when the buffer is used. pub fn provide(&self, key: u128, entry: &[u8]) -> bool { - let Some(buf) = crate::disk_cache::read_layer_entry(entry) else { return false }; - let bytes = buf.img.data.len() * std::mem::size_of::(); + let Some((buf, files)) = crate::disk_cache::read_layer_entry(entry) else { return false }; + let buf = Arc::new(buf); + let bytes = buf_bytes(&buf); let Ok(mut s) = self.st.lock() else { return false }; s.clock += 1; - let now = s.clock; - if let Some((old, _)) = s.ready.insert(key, (Arc::new(buf), now)) { - s.bytes -= old.img.data.len() * std::mem::size_of::(); - } - s.bytes += bytes; + let used = s.clock; + s.remove(key); + s.ready.insert(key, Fetched { buf, files: Arc::new(files), used }); + s.bytes = s.bytes.saturating_add(bytes); s.known.insert(key); while s.bytes > self.budget { - let Some((&k, _)) = s.ready.iter().min_by_key(|(_, (_, t))| *t) else { break }; - if let Some((b, _)) = s.ready.remove(&k) { - s.bytes -= b.img.data.len() * std::mem::size_of::(); - } + let Some(oldest) = s.ready.iter().min_by_key(|(_, f)| f.used).map(|(k, _)| *k) else { break }; + s.remove(oldest); } true } @@ -372,32 +499,46 @@ impl PrefetchStore { } impl LayerStore for PrefetchStore { - fn get_layer(&self, key: u128) -> Option> { + /// The prefetched layer for `key` with its read files, or `None` when it is not ready. + fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Arc, ReadFiles)> { + let found = { + let mut s = self.st.lock().ok()?; + s.clock += 1; + let now = s.clock; + match s.ready.get_mut(&key) { + Some(f) => { + f.used = now; + Some((f.buf.clone(), f.files.clone())) + } + None => { + s.note_miss(key); + None + } + } + }; + let (buf, files) = found?; + // (looked at without the lock: one call of the hook, or one `metadata`, per file) + let unchanged = files.unchanged(virtual_id); let mut s = self.st.lock().ok()?; - s.clock += 1; - let now = s.clock; - match s.ready.get_mut(&key) { - Some((b, t)) => { - *t = now; - let b = b.clone(); - if !s.hits.contains(&key) { - s.hits.push(key); - } - Some(b) - } - None => { - if !s.misses.contains(&key) && s.misses.len() < 1024 { - s.misses.push(key); - } - None - } + if !unchanged { + // Made of files that changed since: forgotten, so the buffer rendered again is + // written over its entry. + s.remove(key); + s.known.remove(&key); + s.note_miss(key); + return None; } + if !s.hits.contains(&key) { + s.hits.push(key); + } + Some((buf, files.to_read_files())) } - fn put_layer(&self, key: u128, buf: &Buf) { + /// 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) { let Ok(mut s) = self.st.lock() else { return }; if s.known.insert(key) { - s.writes.push((key, crate::disk_cache::layer_entry(buf))); + s.writes.push((key, crate::disk_cache::layer_entry(buf, files))); } } @@ -470,6 +611,12 @@ fn time_dependent(layer: &Layer, effects: bool) -> bool { false } +/// A text layer with an Expression Selector: its amount is evaluated per character, and the +/// layer's key holds only the first character's (see [`crate::reads::ReadFiles::is_unkeyed`]). +pub(crate) fn per_character_expressions(layer: &Layer) -> bool { + matches!(layer.source, LayerSource::Text) && layer.props.sub("text").is_some_and(|t| group_has(t, "expressionSelector")) +} + fn group_has(g: &PropGroup, match_id: &str) -> bool { g.children.iter().any(|c| match c { Node::Group(sub) => sub.match_id == match_id || group_has(sub, match_id), @@ -741,6 +888,21 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo Some(h.finish()) } +/// Does the layer read another layer through an enabled effect's layer parameter (Gradient Wipe, +/// Set Matte…)? Those layers are drawn by the reading layer's renderer, so the switches it +/// passes down belong in the key (`Renderer::nested_look`). +pub(crate) fn reads_layers(ctx: &EvalCtx, layer: &Layer) -> bool { + /// Whether any enabled layer-valued property of `layer` points at another layer. + fn reads(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, depth: usize) -> bool { + depth < 32 + && g.children.iter().any(|n| match n { + Node::Prop(p) => matches!(p.value, Value::Layer(_)) && ctx.value(layer, p).as_layer().is_some_and(|id| id != layer.id.0), + Node::Group(sub) => sub.enabled && reads(ctx, layer, sub, depth + 1), + }) + } + layer.switches.effects && layer.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled && reads(ctx, layer, g, 0))) +} + /// Does the layer run an effect that reads the comp camera or lights (Card Dance, CC Particle /// World…)? fn reads_comp_scene(layer: &Layer) -> bool { @@ -786,8 +948,211 @@ mod shared_insert_tests { let cache = LayerCache::default(); let buf = || Arc::new(Buf { img: Image::new(4, 4), offset: [0.0; 2], scale: 1.0 }); let (a, b) = (buf(), buf()); - assert!(Arc::ptr_eq(&cache.insert(3, a.clone()), &a)); - assert!(Arc::ptr_eq(&cache.insert(3, b), &a), "the later render gets the first buffer"); - assert!(Arc::ptr_eq(&cache.get(3).unwrap(), &a)); + assert!(Arc::ptr_eq(&cache.insert(3, a.clone(), Default::default()).0, &a)); + assert!(Arc::ptr_eq(&cache.insert(3, b, Default::default()).0, &a), "the later render gets the first buffer"); + assert!(Arc::ptr_eq(&cache.get(3).unwrap().0, &a)); + } + + /// The browser's store (#3184): a fetched entry is used only while the files it lists are + /// unchanged (here from memory, through the hook), and comes with them; one made of files + /// that changed is dropped and counts as a miss, so the buffer rendered again is queued over + /// it. A buffer whose files can't all be named is not queued at all. + #[test] + fn the_prefetch_store_checks_the_files_of_its_entries() { + use super::{LayerStore, PrefetchStore}; + use crate::disk_cache::{EntryFiles, layer_disk_key, layer_entry}; + use crate::reads::{FileStamp, ReadFile, ReadFiles}; + let buf = || Buf { img: Image::new(2, 2), offset: [0.0; 2], scale: 1.0 }; + let files: ReadFiles = [ReadFile::new("/v.png", FileStamp::Virtual)].into_iter().collect(); + let (one, two) = (|_: &str| Some((3, 1)), |_: &str| Some((3, 2))); + let entry = layer_entry(&buf(), &EntryFiles::of(&files, Some(&one)).unwrap()); + let store = PrefetchStore::new(1 << 20).with_min_ms(0); + assert!(store.provide(5, &entry)); + assert!(!store.provide(6, b"damaged")); + let (_, got) = store.get_layer(5, Some(&one)).unwrap(); + assert_eq!(got, files); + assert_eq!(store.take_hits(), [5]); + // The file changed: a miss, forgotten, and the new buffer is queued over it. + 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); + 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(); + let store = Arc::new(PrefetchStore::new(1 << 20).with_min_ms(0)); + cache.set_store(Some(store.clone())); + cache.set_virtual_id(Some(Arc::new(one))); + assert!(store.provide(layer_disk_key(9), &entry)); + let (_, from_store) = cache.get(9).unwrap(); + assert!(*from_store == files && from_store.is_complete()); + let mut incomplete = files.clone(); + incomplete.mark_incomplete(); + assert!(cache.get(10).is_none()); + cache.insert(10, Arc::new(buf()), Arc::new(incomplete)); + assert!(cache.get(11).is_none()); + cache.insert(11, Arc::new(buf()), Arc::new(files.clone())); + let written: Vec = store.take_writes().into_iter().map(|w| w.0).collect(); + assert_eq!(written, [layer_disk_key(11)], "only the buffer whose files are all named"); + } + + /// A layer buffer whose render started before Reload Footage dropped its file (#3184) is + /// not written to the disk cache when it is kept afterwards; one whose render started after + /// the drop is. + #[test] + fn a_buffer_rendered_across_a_drop_is_not_written() { + use crate::disk_cache::{DiskCache, Kind, layer_disk_key}; + use crate::reads::{FileStamp, ReadFile, ReadLog}; + let dir = tempfile::tempdir().unwrap(); + let dc = DiskCache::open(dir.path(), 1 << 30).unwrap(); + dc.set_min_layer_ms(0); + let cache = LayerCache::default(); + cache.set_disk(Some(dc.clone())); + let buf = || Arc::new(Buf { img: Image::new(2, 2), offset: [0.0; 2], scale: 1.0 }); + // A render's files, read after it took the drop generation (`Renderer::recorded`). + let render = || { + let log = ReadLog::starting_at(cache.drop_generation().unwrap()); + move || { + log.note(ReadFile::new("/a.png", FileStamp::Native { len: 3, modified: Some(1) })); + Arc::new(log.into_files()) + } + }; + // The miss starts the render; the file is reloaded before the buffer is kept. + assert!(cache.get(1).is_none()); + let a = render(); + dc.drop_files(["/a.png"]); + dc.flush(); + // Another render of the same key starts after the drop, while the first one still runs + // (its miss must not lend the first render its generation). + assert!(cache.get(1).is_none()); + let b = render(); + let (stale, _) = cache.insert(1, buf(), a()); + dc.flush(); + assert!(!dc.contains(Kind::Layer, layer_disk_key(1)), "made of the file as it was before the drop"); + // Not kept in memory either: the second render keeps its own buffer, and writes it. + let fresh = buf(); + let (kept, _) = cache.insert(1, fresh.clone(), b()); + assert!(Arc::ptr_eq(&kept, &fresh) && !Arc::ptr_eq(&kept, &stale)); + // A render of another key after the drop. + assert!(cache.get(2).is_none()); + cache.insert(2, buf(), render()()); + dc.flush(); + assert!(dc.contains(Kind::Layer, layer_disk_key(1)), "the render after the drop"); + assert!(dc.contains(Kind::Layer, layer_disk_key(2))); + // A list that doesn't say when its reads began counts as made before the drop. + assert!(cache.get(3).is_none()); + cache.insert(3, buf(), Arc::new([ReadFile::new("/a.png", FileStamp::Native { len: 3, modified: Some(1) })].into_iter().collect())); + dc.flush(); + assert!(!dc.contains(Kind::Layer, layer_disk_key(3))); + drop((cache, dc)); + } + + /// A disk cache whose layer writes stop at a barrier until the test lets them go on. + struct PausedWrites { + dc: Arc, + arrived: std::sync::Barrier, + go_on: std::sync::Barrier, + } + + impl super::LayerStore for PausedWrites { + 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) { + self.arrived.wait(); + self.go_on.wait(); + super::LayerStore::put_layer(&*self.dc, key, buf, files, since); + } + fn drop_generation(&self) -> u64 { + self.dc.drop_generation() + } + fn dropped_since(&self, files: &crate::reads::ReadFiles, since: u64) -> bool { + self.dc.dropped_since(files, since) + } + } + + /// Reload Footage while a render from before it is writing its buffer: the cache is cleared + /// and the file dropped after the buffer passed its check, before it is kept. The buffer + /// then reaches memory after the purge, but it is never served, and a render after the drop + /// keeps its own buffer (#3191 review). + #[test] + fn a_buffer_kept_after_the_purge_of_a_reload_is_not_served() { + use crate::reads::{FileStamp, ReadFile, ReadLog}; + let dir = tempfile::tempdir().unwrap(); + let dc = crate::disk_cache::DiskCache::open(dir.path(), 1 << 30).unwrap(); + let store = Arc::new(PausedWrites { dc: dc.clone(), arrived: std::sync::Barrier::new(2), go_on: std::sync::Barrier::new(2) }); + let cache = Arc::new(LayerCache::default()); + cache.set_store(Some(store.clone())); + let buf = || Arc::new(Buf { img: Image::new(2, 2), offset: [0.0; 2], scale: 1.0 }); + let files = |cache: &LayerCache| { + let log = ReadLog::starting_at(cache.drop_generation().unwrap()); + log.note(ReadFile::new("/a.png", FileStamp::Native { len: 3, modified: Some(1) })); + Arc::new(log.into_files()) + }; + // The old render: a miss, its reads, then its insert, which stops in the disk write. + assert!(cache.get(1).is_none()); + let old_files = files(&cache); + let old = buf(); + let writer = { + let (cache, old) = (cache.clone(), old.clone()); + std::thread::spawn(move || cache.insert(1, old, old_files)) + }; + store.arrived.wait(); + // Reload Footage meanwhile: the layer cache is purged and the file dropped. + cache.clear(); + dc.drop_files(["/a.png"]); + dc.flush(); + store.go_on.wait(); + let _ = writer.join().unwrap(); + dc.flush(); + assert!(!dc.contains(crate::disk_cache::Kind::Layer, crate::disk_cache::layer_disk_key(1)), "not on disk"); + assert!(cache.get(1).is_none(), "the old pixels are not served from memory"); + // A render after the drop keeps its own buffer. + let fresh = buf(); + let new_files = files(&cache); + let mut kept = None; + std::thread::scope(|s| { + s.spawn(|| { + store.arrived.wait(); + store.go_on.wait(); + }); + kept = Some(cache.insert(1, fresh.clone(), new_files).0); + }); + let kept = kept.unwrap(); + assert!(Arc::ptr_eq(&kept, &fresh) && !Arc::ptr_eq(&kept, &old)); + assert!(Arc::ptr_eq(&cache.get(1).unwrap().0, &fresh)); + } + + /// A render that gets another render's buffer gets that buffer's files too (#3184): when a + /// file changed between the two reads, its frame lists the file with both identities, so the + /// list is incomplete and the frame is not kept on disk with the new identity and the old + /// pixels. + #[test] + fn the_buffer_kept_first_brings_its_files() { + use crate::reads::{FileStamp, ReadFile, ReadFiles, ReadLog}; + use crate::{NoFootage, RenderOpts, Renderer}; + let cache = LayerCache::default(); + let buf = || Arc::new(Buf { img: Image::new(4, 4), offset: [0.0; 2], scale: 1.0 }); + let files = |len| Arc::new([ReadFile::new("/a.png", FileStamp::Native { len, modified: Some(1) })].into_iter().collect::()); + let (first, second) = (files(1), files(2)); + let a = buf(); + cache.insert(3, a.clone(), first.clone()); + let project = effectcraft_project::Project::default(); + let log = ReadLog::new(); + let mut r = Renderer::new(&project, &NoFootage, RenderOpts::default()); + r.cache = Some(&cache); + r.reads = Some(&log); + // (what `Renderer::recorded` noted for the second render) + log.note_all(&second); + let (kept, kept_files) = r.keep(Some(3), buf(), &second); + assert!(Arc::ptr_eq(&kept, &a)); + assert_eq!(kept_files, first); + assert!(!log.files().is_complete()); + // The same files: nothing changes. + let same = ReadLog::new(); + r.reads = Some(&same); + same.note_all(&first); + let _ = r.keep(Some(3), buf(), &files(1)); + assert!(same.files().is_complete()); } } diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index db1465c07..dc4216098 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -156,7 +156,7 @@ impl Dependencies<'_> { layer.layer_time(ctx.time).0.hash(self.h); } match layer.source { - LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => { + LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Model { item } => { let item = match layer.source { LayerSource::Footage { .. } => ctx.media_item(layer, item), _ => item, diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 427198ad2..f509a5896 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -2,15 +2,25 @@ //! and layer cache entries kept on disk between sessions, like After Effects' disk cache. //! //! * Entries are files keyed by a 128-bit content hash (`/v1//<2 hex>/<32 hex>.ecc`): -//! frames by the hash of the composition's content (the comp and everything it uses, footage -//! files' size and modification time included) plus frame, scale and view; layer buffers by -//! the layer cache's content key salted with the project's footage fingerprint. A changed -//! project therefore never reads stale pixels: its keys simply change. +//! frames by the hash of the composition's content as it is in memory (the comp and what it can +//! draw, footage items with their settings and paths, and what its expressions can read; no +//! file is looked at, [`comp_content_key`]) plus frame, scale and view; layer buffers by the +//! layer cache's content key (which names the footage items a buffer uses with their settings +//! and paths) and the renderer version ([`layer_disk_key`]). A changed project therefore never +//! reads stale pixels: its keys simply change, and an edit elsewhere keeps them. +//! * Frame and layer entries keep the source files they were made of (footage, and the font +//! files text is drawn with), each with the size and modification time it was read with +//! ([`EntryFiles`], #3184). An entry is used only while those files are unchanged: a handful +//! of `metadata` calls when it is read, never a scan of the project's files. A frame or +//! buffer whose files can't all be named is not stored (text in a family that isn't installed +//! or with fallback glyphs, footage refused by automation read roots, …). File ▸ Reload +//! Footage deletes the entries made of the reloaded files ([`DiskCache::drop_files`]): a file +//! replaced by one of the same size and time is read again. //! * Payloads are LZ4-compressed (`lz4_flex`, MIT): frames as 8-bit premultiplied RGBA (or, for //! 32 bpc viewer frames, half-float RGBA with the bytes shuffled into planes; the entry -//! header's format byte tells them apart, 8-bit entries keep format 0), layer -//! buffers as `f32` RGBA with the bytes shuffled into planes (exact, so cached and fresh -//! renders are bit-identical), Roto Brush / Object Matte segmentations as +//! header's format byte tells them apart: 2 and 3, both with their source files first), layer +//! buffers (format 4, their source files first) as `f32` RGBA with the bytes shuffled into +//! planes (exact, so cached and fresh renders are bit-identical), Roto Brush / Object Matte segmentations as //! `FrameSeg::to_bytes` (keyed by their content-keyed chain key and scale, so they are //! reused whenever the same frames, settings and prompt come back, also after a restart). Every file ends in a checksum; damaged files read as misses and //! are deleted. @@ -32,6 +42,11 @@ use std::sync::{Arc, Condvar, Mutex}; use effectcraft_effects::Buf; use effectcraft_raster::Image; +use crate::reads::ReadFiles; + +mod entry_files; +pub use entry_files::{EntryFile, EntryFiles, EntryStamp}; + /// File format version (part of the folder layout). const VERSION: &str = "v1"; const MAGIC: &[u8; 4] = b"ECDC"; @@ -87,9 +102,16 @@ pub struct FrameF16 { pub rgba: Vec, } -/// The entry header's format byte of frame entries. +/// The entry header's format byte: segmentations (and 8-bit frames and layer buffers before +/// #3184). const FORMAT_RGBA8: u8 = 0; -const FORMAT_F16: u8 = 1; +/// Frame entries: their source files ([`EntryFiles`]), then 8-bit or half-float pixels. The +/// formats before them (0: 8-bit, 1: half float, without files) read as misses and are deleted. +const FORMAT_RGBA8_FILES: u8 = 2; +const FORMAT_F16_FILES: u8 = 3; +/// Layer entries: their source files ([`EntryFiles`]), then the buffer. The format before it +/// (0, without files) reads as a miss and is deleted. +const FORMAT_LAYER_FILES: u8 = 4; /// Counters for tests, the Info panel and `cache.diskStats`. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -220,7 +242,7 @@ pub struct LayerPrefetch { const PREFETCH_RECENT: usize = 512; impl LayerPrefetch { - /// A frame's layer-cache misses (salted keys). + /// A frame's layer-cache misses (disk keys, [`layer_disk_key`]). pub fn note_misses(&mut self, keys: impl IntoIterator) { for k in keys { self.recent.retain(|x| *x != k); @@ -243,6 +265,8 @@ impl LayerPrefetch { enum Job { /// Kind, key, header format byte, payload. Write(Kind, u128, u8, Vec), + /// Delete the frame and layer entries made of these files ([`DiskCache::drop_files`]). + Drop(Vec>), } struct Queue { @@ -255,14 +279,55 @@ struct Queue { busy: bool, /// Keys of frames queued or being written. pending_frames: std::collections::HashSet, + /// The drops so far (under the queue's lock: a write is queued either before a drop, which + /// then sweeps it, or after it, when this refuses it if it was made from before). + drops: Drops, +} + +/// Files dropped from the cache files beyond this are not remembered one by one: everything made +/// before the drop that forgot them is refused instead. +const MAX_DROPPED_PATHS: usize = 1 << 16; + +/// The [`DiskCache::drop_files`] calls so far: their number (the drop generation) and the last one +/// of each file. An entry made of a file read before that file's last drop (its render started at +/// an earlier generation) holds the pixels from before and is refused. +#[derive(Default)] +struct Drops { + generation: u64, + last: HashMap, u64>, + /// Everything made before this generation is refused (the per-file record was forgotten). + floor: u64, +} + +impl Drops { + /// Notes that the given paths were dropped, bumping the generation and tracking them for later checks. + fn record(&mut self, paths: &[Arc]) { + self.generation = self.generation.saturating_add(1); + if self.last.len().saturating_add(paths.len()) > MAX_DROPPED_PATHS { + self.last.clear(); + self.floor = self.generation; + return; + } + for p in paths { + self.last.insert(p.clone(), self.generation); + } + } + + /// Whether something made of `paths`, whose reads started at generation `since`, read one of + /// them before it was dropped. + fn stale<'a>(&self, mut paths: impl Iterator>, since: u64) -> bool { + since < self.floor || paths.any(|p| self.last.get(p).is_some_and(|g| *g > since)) + } } impl Queue { + /// Takes the next queued job, frames first, and releases the bytes it held. #[cfg(not(target_arch = "wasm32"))] fn pop(&mut self) -> Option { if let Some(j) = self.frames.pop_front() { - let Job::Write(_, _, _, p) = &j; - self.frame_bytes = self.frame_bytes.saturating_sub(p.len()); + if let Job::Write(_, _, _, p) = &j { + self.frame_bytes = self.frame_bytes.saturating_sub(p.len()); + } return Some(j); } self.jobs.pop_front() @@ -280,16 +345,25 @@ pub struct DiskCache { disk_cap: AtomicU64, /// Free space is limiting the cache (warned once per episode). space_limited: AtomicBool, + /// The free space the cache leaves on its disk ([`disk_reserve`]); 0 = unknown. + reserve: AtomicU64, hits: AtomicU64, misses: AtomicU64, writes: AtomicU64, evictions: AtomicU64, dropped: AtomicU64, failed: AtomicU64, + /// Source files looked at to check frame and layer entries ([`DiskCache::file_checks`]). + file_checks: AtomicU64, + /// Frame and layer entries compressed for a queued write ([`DiskCache::encodes`]). + encodes: AtomicU64, tmp_counter: AtomicU64, queue: Arc<(Mutex, Condvar)>, /// Layer buffers that took less than this to render are not written (ms). min_layer_ms: AtomicU64, + /// Files whose entries are being deleted ([`DiskCache::drop_files`]), each with the number + /// of drops of it pending: until then an entry made of one is a miss. + dropping: Mutex, u32>>, } /// FNV-1a 128-bit (content keys). @@ -326,6 +400,23 @@ fn checksum(data: &[u8]) -> u64 { h } +/// The least free space the disk cache leaves on its disk. +pub const MIN_DISK_RESERVE: u64 = 2 << 30; +/// The most free space the disk cache leaves on its disk: 5% of a large disk is far more than +/// the system needs to keep working (on a 4 TB disk it held a 20 GB cache at 0 with 190 GB free). +pub const MAX_DISK_RESERVE: u64 = 10 << 30; +/// Below this much room left by free disk space, frame and layer writes are refused before they +/// are encoded ([`DiskCache::accepts_writes`]): a few frames would fill it, and the rest would be +/// compressed only to be refused. +const MIN_WRITE_ROOM: u64 = 64 << 20; + +/// The free space the disk cache leaves on a disk of `total` bytes: 5% of it, at least 2 GB and +/// at most 10 GB. It keeps the cache from filling the disk; the cache's size is the user's +/// Maximum Disk Cache Size (After Effects has that maximum and no reserve). +pub fn disk_reserve(total: u64) -> u64 { + (total / 20).clamp(MIN_DISK_RESERVE, MAX_DISK_RESERVE) +} + /// The platform's default disk cache folder. pub fn default_folder() -> PathBuf { let home = std::env::var_os("HOME").map(PathBuf::from); @@ -356,12 +447,15 @@ impl DiskCache { max_bytes: AtomicU64::new(max_bytes), disk_cap: AtomicU64::new(u64::MAX), space_limited: AtomicBool::new(false), + reserve: AtomicU64::new(0), hits: AtomicU64::new(0), misses: AtomicU64::new(0), writes: AtomicU64::new(0), evictions: AtomicU64::new(0), dropped: AtomicU64::new(0), failed: AtomicU64::new(0), + file_checks: AtomicU64::new(0), + encodes: AtomicU64::new(0), tmp_counter: AtomicU64::new(0), queue: Arc::new(( Mutex::new(Queue { @@ -370,10 +464,12 @@ impl DiskCache { jobs: Default::default(), busy: cfg!(not(target_arch = "wasm32")), pending_frames: Default::default(), + drops: Drops::default(), }), Condvar::new(), )), min_layer_ms: AtomicU64::new(30), + dropping: Mutex::new(HashMap::new()), }); dc.rescan(); // Shrinking a large cache deletes many files: the writer thread does it. @@ -446,19 +542,22 @@ impl DiskCache { self.max_bytes().min(self.disk_cap.load(Ordering::Relaxed)) } - /// The cache's disk: `(total, available)` bytes, `None` = unknown. The cache leaves at least - /// 2 GB or 5% of the disk free, whichever is more (evicting when it must). + /// The cache's disk: `(total, available)` bytes, `None` = unknown. The cache leaves + /// [`disk_reserve`] free (5% of the disk, at least 2 GB and at most 10 GB), evicting when it + /// must. pub fn set_disk_space(&self, space: Option<(u64, u64)>) { + let mut reserve = 0; let cap = match space { // (a disk reported as 0 bytes, such as an automount map, is unknown) Some((total, available)) if total > 0 => { - let reserve = (2u64 << 30).max(total / 20); + reserve = disk_reserve(total); let used = self.index.lock().map(|g| g.total()).unwrap_or_default(); // The cache's own files count as free: below the reserve it gives space back. used.saturating_add(available).saturating_sub(reserve) } _ => u64::MAX, }; + self.reserve.store(reserve, Ordering::Relaxed); self.disk_cap.store(cap, Ordering::Relaxed); let limited = cap < self.max_bytes(); if self.space_limited.swap(limited, Ordering::Relaxed) != limited && limited { @@ -467,6 +566,33 @@ impl DiskCache { self.evict(); } + /// Whether frame and layer writes are worth encoding now: `false` while free disk space + /// holds the limit at 0 or under 64 MB (a limit the setting asks for is always used). Two + /// atomic reads, so writes check it before they spend time converting and compressing + /// pixels that [`DiskCache::write_format`] would refuse anyway. (Whether a given entry fits + /// is known only once it is compressed: `write_format` decides.) + pub fn accepts_writes(&self) -> bool { + let (max, limit) = (self.max_bytes(), self.limit_bytes()); + limit > 0 && (limit >= MIN_WRITE_ROOM || limit == max) + } + + /// The free space the cache leaves on its disk ([`disk_reserve`]), once the disk's size is + /// known ([`DiskCache::set_disk_space`]). + pub fn reserve_bytes(&self) -> Option { + Some(self.reserve.load(Ordering::Relaxed)).filter(|r| *r > 0) + } + + /// Frame and layer entries compressed for writing so far (tests: a write refused up front + /// costs no encode). + pub fn encodes(&self) -> u64 { + self.encodes.load(Ordering::Relaxed) + } + + /// Count one entry compressed for a queued write. + fn count_encode(&self) { + self.encodes.fetch_add(1, Ordering::Relaxed); + } + /// Layer buffers faster to render than this are not worth a disk write. pub fn set_min_layer_ms(&self, ms: u64) { self.min_layer_ms.store(ms, Ordering::Relaxed); @@ -541,36 +667,94 @@ impl DiskCache { /// An entry's payload and its header's format byte. fn read_entry(&self, kind: Kind, key: u128) -> Option<(u8, Vec)> { + let entry = self.load(kind, key); + match &entry { + Some(_) => self.hit(kind, key), + None => _ = self.misses.fetch_add(1, Ordering::Relaxed), + } + entry + } + + /// An entry's payload and format byte, without counting it as a hit or miss. A damaged file + /// is deleted; a missing one forgotten. + fn load(&self, kind: Kind, key: u128) -> Option<(u8, Vec)> { if !self.contains(kind, key) { - self.misses.fetch_add(1, Ordering::Relaxed); return None; } let path = self.path(kind, key); let data = std::fs::read(&path).ok(); - let ok = data.as_deref().and_then(|d| verify(d, kind).map(|p| (d[5], p))); - match ok { - Some((format, payload)) => { - let payload = (format, payload.to_vec()); - self.hits.fetch_add(1, Ordering::Relaxed); - if let Ok(mut g) = self.index.lock() { - g.touch(kind, key); - } - // Persist the recency for the next session (best effort). - if let Ok(f) = std::fs::File::options().append(true).open(&path) { - let _ = f.set_modified(std::time::SystemTime::now()); - } - Some(payload) - } - None => { - self.misses.fetch_add(1, Ordering::Relaxed); - // Gone (another process evicted it) or damaged: forget it. - if data.is_some() { - let _ = std::fs::remove_file(&path); - } - self.forget(kind, key); - None + let ok = data.as_deref().and_then(|d| Some((*d.get(5)?, verify(d, kind)?.to_vec()))); + if ok.is_none() { + // Gone (another process evicted it) or damaged: forget it. + if data.is_some() { + let _ = std::fs::remove_file(&path); } + self.forget(kind, key); } + ok + } + + /// Count a hit: the entry becomes the most recently used, also for the next session. + fn hit(&self, kind: Kind, key: u128) { + self.hits.fetch_add(1, Ordering::Relaxed); + if let Ok(mut g) = self.index.lock() { + g.touch(kind, key); + } + // Persist the recency for the next session (best effort). + if let Ok(f) = std::fs::File::options().append(true).open(self.path(kind, key)) { + let _ = f.set_modified(std::time::SystemTime::now()); + } + } + + /// An entry of `kind` and `format` whose source files are unchanged, decoded by `decode`, + /// with those files. An entry whose files changed, of the format before #3184, or damaged, + /// is a miss and is deleted (it renders again and is stored anew). A frame entry of the + /// other current frame format (8-bit and half-float frames have keys of their own) is a miss + /// and stays. + fn get_checked( + &self, + kind: Kind, + key: u128, + format: u8, + virtual_id: Option>, + decode: impl FnOnce(&[u8]) -> Option, + ) -> Option<(T, ReadFiles)> { + let miss = || { + self.misses.fetch_add(1, Ordering::Relaxed); + None + }; + // (before the file is read: the writer may finish a drop's sweep between the read and the + // check below, and a drop may start meanwhile) + let drops = self.drops_now(); + let Some((found, payload)) = self.load(kind, key) else { return miss() }; + if found != format { + let other_frame_format = kind == Kind::Frame && (found == FORMAT_RGBA8_FILES || found == FORMAT_F16_FILES); + if !other_frame_format { + self.discard(kind, key); + } + return miss(); + } + let Some((files, body)) = EntryFiles::decode(&payload) else { + self.discard(kind, key); + return miss(); + }; + self.file_checks.fetch_add(files.len() as u64, Ordering::Relaxed); + if self.made_of_dropped(&files, &drops) || !files.unchanged(virtual_id) { + self.discard(kind, key); + return miss(); + } + let Some(value) = decode(body) else { + self.discard(kind, key); + return miss(); + }; + self.hit(kind, key); + Some((value, files.to_read_files())) + } + + /// Source files looked at so far to check frame and layer entries before using them (one per + /// file a read entry lists; tests). + pub fn file_checks(&self) -> u64 { + self.file_checks.load(Ordering::Relaxed) } /// A valid file whose payload does not decode: delete it, as a damaged one. @@ -587,13 +771,19 @@ 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) + self.enqueue_format(kind, key, FORMAT_RGBA8, payload, None) } - /// [`DiskCache::enqueue`] with the entry's header format byte. - fn enqueue_format(&self, kind: Kind, key: u128, format: u8, payload: Vec) -> bool { + /// [`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 { + 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); true } @@ -601,6 +791,9 @@ impl DiskCache { { let (m, cv) = &*self.queue; let Ok(mut q) = m.lock() else { return false }; + 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, @@ -622,10 +815,6 @@ impl DiskCache { } /// Write an entry now (atomic rename), then evict down to the limit. - fn write_now(&self, kind: Kind, key: u128, payload: &[u8]) { - self.write_format(kind, key, FORMAT_RGBA8, payload); - } - fn write_format(&self, kind: Kind, key: u128, format: u8, payload: &[u8]) { if self.contains(kind, key) { return; @@ -680,70 +869,229 @@ impl DiskCache { } } + /// Delete the frame and layer entries made of any of `paths` (File ▸ Reload Footage: a file + /// replaced by one with the same size and modification time must be read again, which the + /// check on hit can't tell). The index knows keys, not files, so the writer thread reads the + /// files section at the start of each frame and layer entry (no pixels) and deletes the + /// entries that list one of `paths`, after the writes queued before (those are swept too); + /// until it is done, an entry made of one of them is a miss. Entries are deleted for good, + /// so they don't come back after a restart. Nothing else in the cache is touched. + /// + /// A render that was under way meanwhile may still offer an entry made of one of `paths` as + /// it was before: it is refused, see [`DiskCache::drop_generation`]. + pub fn drop_files(&self, paths: impl IntoIterator>>) { + let paths: Vec> = paths.into_iter().map(Into::into).collect(); + if paths.is_empty() { + return; + } + { + let mut d = self.dropping.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for p in &paths { + let n = d.entry(p.clone()).or_default(); + *n = n.saturating_add(1); + } + } + #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] + let (m, cv) = &*self.queue; + match m.lock() { + Ok(mut q) => { + q.drops.record(&paths); + #[cfg(not(target_arch = "wasm32"))] + { + // (never refused for a full queue) + q.jobs.push_back(Job::Drop(paths)); + cv.notify_all(); + return; + } + } + // (no writer thread to take the job any more) + Err(poisoned) => poisoned.into_inner().drops.record(&paths), + } + self.sweep(&paths); + } + + /// The number of [`DiskCache::drop_files`] calls so far. A render takes it before it starts + /// reading and hands it to `put_frame` / `put_layer` with what it made: an entry listing a + /// file dropped after that is refused (the render may have read the file as it was before). + pub fn drop_generation(&self) -> u64 { + self.queue.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).drops.generation + } + + /// Whether one of `files` was dropped after generation `since` ([`DiskCache::drop_generation`]), + /// or after the earlier generation the list's own reads started at ([`ReadFiles::since`]: a + /// buffer made by an earlier render, from memory): what was made of them then is from before + /// the drop (shown, never stored). + pub fn dropped_since(&self, files: &ReadFiles, since: u64) -> bool { + let since = files.since().map_or(since, |s| s.min(since)); + self.queue.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).drops.stale(files.files().iter().map(|f| &f.path), since) + } + + /// Whether a [`DiskCache::drop_files`] is pending. + pub fn is_dropping(&self) -> bool { + !self.dropping.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_empty() + } + + /// The drop generation and the files being dropped now, taken before an entry is read + /// (see [`DiskCache::made_of_dropped`]). + fn drops_now(&self) -> (u64, Vec>) { + let generation = self.drop_generation(); + let d = self.dropping.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + (generation, d.keys().cloned().collect()) + } + + /// An entry read after [`DiskCache::drops_now`] gave `drops` that is made of a file whose + /// drop was pending then (its sweep may have ended since, after the entry was read) or that + /// was dropped since. + fn made_of_dropped(&self, files: &EntryFiles, drops: &(u64, Vec>)) -> bool { + let (since, pending) = drops; + let paths = || files.files().iter().map(|f| &f.path); + paths().any(|p| pending.contains(p)) || self.queue.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).drops.stale(paths(), *since) + } + + /// The writer's part of [`DiskCache::drop_files`]: delete the entries made of `paths`, then + /// end their drop. + fn sweep(&self, paths: &[Arc]) { + let set: std::collections::HashSet<&str> = paths.iter().map(|p| &**p).collect(); + for kind in [Kind::Frame, Kind::Layer] { + for key in self.keys(kind) { + if self.entry_files(kind, key).is_some_and(|f| f.files().iter().any(|f| set.contains(&*f.path))) { + self.discard(kind, key); + } + } + } + let mut d = self.dropping.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for p in paths { + if let Some(n) = d.get_mut(p) { + *n = n.saturating_sub(1); + if *n == 0 { + d.remove(p); + } + } + } + } + + /// The source files a frame or layer entry lists, read from the start of its file (the + /// whole file only when the list is longer than that). `None` when it has none to read (an + /// earlier format, gone, damaged): such an entry is never used anyway. + fn entry_files(&self, kind: Kind, key: u128) -> Option { + use std::io::Read; + const HEAD: u64 = 64 << 10; + let parse = |d: &[u8]| { + let format = *d.get(5)?; + let ours = d.get(..4)? == MAGIC && *d.get(4)? == kind.tag(); + if !ours || !matches!(format, FORMAT_RGBA8_FILES | FORMAT_F16_FILES | FORMAT_LAYER_FILES) { + return None; + } + EntryFiles::decode(d.get(8..)?).map(|(files, _)| files) + }; + let path = self.path(kind, key); + let mut head = Vec::new(); + std::fs::File::open(&path).ok()?.take(HEAD).read_to_end(&mut head).ok()?; + parse(&head).or_else(|| if head.len() as u64 == HEAD { parse(&std::fs::read(&path).ok()?) } else { None }) + } + // ------------------------------------------------------------ typed entries - /// An 8-bit frame (`None` for a half-float entry of that key, which [`DiskCache::get_frame_f16`] - /// reads). - pub fn get_frame(&self, key: u128) -> Option { - let (format, p) = self.read_entry(Kind::Frame, key)?; - if format != FORMAT_RGBA8 { - return None; - } - decode_frame(&p) + /// An 8-bit frame whose source files are unchanged (see [`DiskCache::get_checked`]), with + /// those files; `None` for a half-float entry of that key, which + /// [`DiskCache::get_frame_f16`] reads. `virtual_id` names files served from memory. + pub fn get_frame(&self, key: u128, virtual_id: Option>) -> Option<(Frame8, ReadFiles)> { + self.get_checked(Kind::Frame, key, FORMAT_RGBA8_FILES, virtual_id, decode_frame) } - /// A half-float frame (32 bpc viewer frames). A damaged one is deleted. - pub fn get_frame_f16(&self, key: u128) -> Option { - let (format, p) = self.read_entry(Kind::Frame, key)?; - if format != FORMAT_F16 { - return None; - } - let f = decode_frame_f16(&p); - if f.is_none() { - self.discard(Kind::Frame, key); - } - f + /// A half-float frame (32 bpc viewer frames), as [`DiskCache::get_frame`]. + pub fn get_frame_f16(&self, key: u128, virtual_id: Option>) -> Option<(FrameF16, ReadFiles)> { + self.get_checked(Kind::Frame, key, FORMAT_F16_FILES, virtual_id, decode_frame_f16) } - /// Queue a half-float frame for writing (`width * height * 4` binary16 values). `true` when - /// it is on disk or queued; `false` when refused (wrong size, a full queue). - pub fn put_frame_f16(&self, key: u128, width: u32, height: u32, rgba: &[u16]) -> bool { + /// Queue a half-float frame for writing (`width * height * 4` binary16 values) with the + /// source files it was made of. `true` when it is on disk or queued; `false` when refused + /// (wrong size, a full queue, no room on the disk, files that can't be checked later: see + /// [`EntryFiles::of`]; a file dropped after `since`, the [`DiskCache::drop_generation`] the + /// render started at). + #[allow(clippy::too_many_arguments)] + pub fn put_frame_f16( + &self, + key: u128, + width: u32, + height: u32, + rgba: &[u16], + files: &ReadFiles, + virtual_id: Option>, + since: u64, + ) -> bool { if rgba.len() as u64 != width as u64 * height as u64 * 4 { return false; } - self.contains(Kind::Frame, key) || self.enqueue_format(Kind::Frame, key, FORMAT_F16, encode_frame_f16(width, height, rgba)) - } - - /// Write a half-float frame synchronously (tests). - pub fn put_frame_f16_now(&self, key: u128, width: u32, height: u32, rgba: &[u16]) { - self.write_format(Kind::Frame, key, FORMAT_F16, &encode_frame_f16(width, height, rgba)); - } - - /// Queue a frame for writing (8-bit premultiplied RGBA, `width * height * 4` bytes). `true` - /// when it is on disk or queued; `false` when refused (wrong size, a full queue). - pub fn put_frame(&self, key: u128, width: u32, height: u32, rgba: &[u8]) -> bool { - if rgba.len() != width as usize * height as usize * 4 { + let Some(files) = EntryFiles::of(files, virtual_id) else { return false }; + if self.contains(Kind::Frame, key) { + return true; + } + // (no room on the disk: refused before the pixels are compressed) + if !self.accepts_writes() { return false; } - self.contains(Kind::Frame, key) || self.enqueue(Kind::Frame, key, encode_frame(width, height, rgba)) + 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))) } - pub fn get_layer(&self, key: u128) -> Option { - let p = self.read(Kind::Layer, key)?; - decode_layer(&p) + /// Write a half-float frame synchronously (tests); a list that can't be stored is not. + pub fn put_frame_f16_now(&self, key: u128, width: u32, height: u32, rgba: &[u16], files: &ReadFiles) { + if let Some(files) = EntryFiles::of(files, None) { + self.write_format(Kind::Frame, key, FORMAT_F16_FILES, &with_files(&files, encode_frame_f16(width, height, rgba))); + } } - /// Queue a layer buffer for writing. - pub fn put_layer(&self, key: u128, buf: &Buf) { - if self.contains(Kind::Layer, key) { + /// Queue a frame for writing (8-bit premultiplied RGBA, `width * height * 4` bytes) with the + /// source files it was made of. `true` when it is on disk or queued; `false` when refused + /// (wrong size, a full queue, no room on the disk, files that can't be checked later: see + /// [`EntryFiles::of`]; a file dropped after `since`, the [`DiskCache::drop_generation`] the + /// 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 { + if rgba.len() as u64 != width as u64 * height as u64 * 4 { + return false; + } + let Some(files) = EntryFiles::of(files, virtual_id) else { return false }; + if self.contains(Kind::Frame, key) { + return true; + } + // (no room on the disk: refused before the pixels are 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))) + } + + /// A layer buffer (key: [`layer_disk_key`]) whose source files are unchanged, with those + /// files, as [`DiskCache::get_checked`]: an entry whose files changed, damaged, or of the + /// format before #3184 is a miss and is deleted, so the buffer renders again and replaces it. + /// `virtual_id` names files served from memory. + pub fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Buf, ReadFiles)> { + self.get_checked(Kind::Layer, key, FORMAT_LAYER_FILES, virtual_id, decode_layer) + } + + /// Queue a layer buffer for writing with the source files it was made of (see + /// [`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) + if self.contains(Kind::Layer, key) || !self.accepts_writes() { return; } - let _ = self.enqueue(Kind::Layer, key, encode_layer(buf)); + self.count_encode(); + let _ = self.enqueue_format(Kind::Layer, key, FORMAT_LAYER_FILES, with_files(files, encode_layer(buf)), Some((files, since))); } - /// Write a layer buffer synchronously (tests). - pub fn put_layer_now(&self, key: u128, buf: &Buf) { - self.write_now(Kind::Layer, key, &encode_layer(buf)); + /// Write a layer buffer synchronously (tests); a list that can't be stored is not. + pub fn put_layer_now(&self, key: u128, buf: &Buf, files: &ReadFiles) { + if let Some(files) = EntryFiles::of(files, None) { + self.write_format(Kind::Layer, key, FORMAT_LAYER_FILES, &with_files(&files, encode_layer(buf))); + } } /// A segmentation (chain key, scale) written by [`DiskCache::put_seg`]. @@ -767,7 +1115,8 @@ impl DiskCache { /// has no links. `false` when `from` isn't cached, `to` already is, or the file system /// refused. (Entries don't contain their key, so the same file serves both.) pub fn link_frame(&self, from: u128, to: u128) -> bool { - if !self.contains(Kind::Frame, from) || self.contains(Kind::Frame, to) { + // (while files are being dropped, `from` may be made of one: it renders again instead) + if !self.contains(Kind::Frame, from) || self.contains(Kind::Frame, to) || self.is_dropping() { return false; } let (src, dst) = (self.path(Kind::Frame, from), self.path(Kind::Frame, to)); @@ -794,12 +1143,23 @@ impl DiskCache { true } - /// Write a frame synchronously (tests). - pub fn put_frame_now(&self, key: u128, width: u32, height: u32, rgba: &[u8]) { - self.write_now(Kind::Frame, key, &encode_frame(width, height, rgba)); + /// Write a frame synchronously (tests); a list that can't be stored is not. + pub fn put_frame_now(&self, key: u128, width: u32, height: u32, rgba: &[u8], files: &ReadFiles) { + if let Some(files) = EntryFiles::of(files, None) { + self.write_format(Kind::Frame, key, FORMAT_RGBA8_FILES, &with_files(&files, encode_frame(width, height, rgba))); + } } } +/// A frame or layer entry's payload: its source files, then its pixels. +fn with_files(files: &EntryFiles, pixels: Vec) -> Vec { + let mut out = Vec::with_capacity(pixels.len() + 64); + files.encode(&mut out); + out.extend_from_slice(&pixels); + out +} + +/// The writer thread: evicts to the limit at open, then drains the job queue until the cache is dropped. #[cfg(not(target_arch = "wasm32"))] fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { let (m, cv) = &*q; @@ -830,12 +1190,20 @@ fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { } }; let Some(cache) = dc.upgrade() else { return }; - let Job::Write(kind, key, format, payload) = job; - cache.write_format(kind, key, format, &payload); + let written = match job { + Job::Write(kind, key, format, payload) => { + cache.write_format(kind, key, format, &payload); + (kind == Kind::Frame).then_some(key) + } + Job::Drop(paths) => { + cache.sweep(&paths); + None + } + }; drop(cache); if let Ok(mut g) = m.lock() { g.busy = false; - if kind == Kind::Frame { + if let Some(key) = written { g.pending_frames.remove(&key); } } @@ -843,12 +1211,9 @@ fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { } } -/// An entry file's bytes: header (magic, kind, format 0), payload, checksum. -pub fn seal(kind: Kind, payload: &[u8]) -> Vec { - seal_format(kind, FORMAT_RGBA8, payload) -} - -/// [`seal`] with the header's format byte (frames: 0 = 8-bit, 1 = half float). +/// An entry file's bytes: header (magic, kind, the format byte: [`FORMAT_RGBA8`] for +/// segmentations, [`FORMAT_RGBA8_FILES`] / [`FORMAT_F16_FILES`] for frames, +/// [`FORMAT_LAYER_FILES`] for layer buffers), payload, checksum. fn seal_format(kind: Kind, format: u8, payload: &[u8]) -> Vec { let mut data = Vec::with_capacity(payload.len() + 16); data.extend_from_slice(MAGIC); @@ -860,26 +1225,47 @@ fn seal_format(kind: Kind, format: u8, payload: &[u8]) -> Vec { data } -/// A sealed frame entry ([`seal`] of the encoded frame): what the browser's frame workers -/// write to the Origin Private File System. -pub fn frame_entry(width: u32, height: u32, rgba: &[u8]) -> Vec { - seal(Kind::Frame, &encode_frame(width, height, rgba)) +/// A sealed 8-bit frame entry with the source files it was made of: what the browser's frame +/// workers write to the Origin Private File System. `None` when the frame must not be stored +/// (its files can't be checked later, see [`EntryFiles::of`]; or a wrong size). +pub fn frame_entry(width: u32, height: u32, rgba: &[u8], files: &ReadFiles, virtual_id: Option>) -> Option> { + if rgba.len() as u64 != width as u64 * height as u64 * 4 { + return None; + } + let files = EntryFiles::of(files, virtual_id)?; + Some(seal_format(Kind::Frame, FORMAT_RGBA8_FILES, &with_files(&files, encode_frame(width, height, rgba)))) } -/// A frame from a sealed entry's bytes (`None` when damaged). -pub fn read_frame_entry(data: &[u8]) -> Option { - decode_frame(verify(data, Kind::Frame)?) +/// The frame of a sealed entry's bytes, if its source files are unchanged. `None` when damaged, +/// of another format, or made of files that changed since: a miss, to be rendered again. +pub fn read_frame_entry(data: &[u8], virtual_id: Option>) -> Option { + let payload = verify(data, Kind::Frame)?; + if data.get(5) != Some(&FORMAT_RGBA8_FILES) { + return None; + } + let (files, pixels) = EntryFiles::decode(payload)?; + if !files.unchanged(virtual_id) { + return None; + } + decode_frame(pixels) } -/// A sealed layer-buffer entry: what the browser's frame workers write to the Origin Private -/// File System (`layers/<32 hex>.ecc`). -pub fn layer_entry(buf: &Buf) -> Vec { - seal(Kind::Layer, &encode_layer(buf)) +/// A sealed layer-buffer entry with the source files it was made of: what the browser's frame +/// workers write to the Origin Private File System (`layers/<32 hex>.ecc`). +pub fn layer_entry(buf: &Buf, files: &EntryFiles) -> Vec { + seal_format(Kind::Layer, FORMAT_LAYER_FILES, &with_files(files, encode_layer(buf))) } -/// A layer buffer from a sealed entry's bytes (`None` when damaged). -pub fn read_layer_entry(data: &[u8]) -> Option { - decode_layer(verify(data, Kind::Layer)?) +/// A layer buffer and its source files from a sealed entry's bytes (`None` when damaged or of +/// another format). The files are not looked at here: the store checks them when the buffer is +/// used ([`EntryFiles::unchanged`]). +pub fn read_layer_entry(data: &[u8]) -> Option<(Buf, EntryFiles)> { + let payload = verify(data, Kind::Layer)?; + if data.get(5) != Some(&FORMAT_LAYER_FILES) { + return None; + } + let (files, body) = EntryFiles::decode(payload)?; + Some((decode_layer(body)?, files)) } /// The disk key of a segmentation: its chain key and scale. @@ -901,11 +1287,20 @@ impl effectcraft_effects::roto::SegStore for DiskCache { } impl crate::cache::LayerStore for DiskCache { - fn get_layer(&self, key: u128) -> Option> { - DiskCache::get_layer(self, key).map(Arc::new) + /// The layer for `key` from the disk cache, or `None` when it is missing or stale. + fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Arc, ReadFiles)> { + DiskCache::get_layer(self, key, virtual_id).map(|(b, f)| (Arc::new(b), f)) } - fn put_layer(&self, key: u128, buf: &Buf) { - DiskCache::put_layer(self, key, buf); + /// Queues a layer write on the disk cache. + fn put_layer(&self, key: u128, buf: &Buf, files: &EntryFiles, since: u64) { + DiskCache::put_layer(self, key, buf, files, since); + } + /// The current drop generation of the disk cache. + fn drop_generation(&self) -> u64 { + DiskCache::drop_generation(self) + } + fn dropped_since(&self, files: &ReadFiles, since: u64) -> bool { + DiskCache::dropped_since(self, files, since) } fn min_layer_ms(&self) -> u64 { DiskCache::min_layer_ms(self) @@ -1015,6 +1410,7 @@ fn encode_layer(b: &Buf) -> Vec { out } +/// Decodes a stored layer payload into a buffer, or `None` when it is too short or damaged. fn decode_layer(p: &[u8]) -> Option { if p.len() < 32 { return None; @@ -1025,8 +1421,8 @@ fn decode_layer(p: &[u8]) -> Option { let offset = [f(8)?, f(16)?]; let scale = f(24)?; let planes = lz4_flex::decompress_size_prepended(&p[32..]).ok()?; - let n = w as usize * h as usize; - if planes.len() != n * 16 { + let n = (w as usize).checked_mul(h as usize)?; + if planes.len() != n.checked_mul(16)? { return None; } let mut img = Image::new(w, h); @@ -1047,6 +1443,7 @@ fn decode_layer(p: &[u8]) -> Option { /// Bump when rendering changes in a way that makes old disk entries wrong. const RENDER_VERSION: &str = "effectcraft-render-6"; +/// Feeds the `Debug` form of `v` into the hash, followed by a separator byte. fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { h.write(format!("{v:?}").as_bytes()); h.write(&[0xff]); @@ -1055,126 +1452,63 @@ fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { /// Size and content hash of a path that has no native metadata (the browser file table). pub type VirtualFileId<'a> = &'a (dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync); -/// A file's size and modification time (so edited footage gets new keys). When the path has no -/// native metadata, `virtual_id` supplies a durable size and content hash instead. -fn file_stamp(h: &mut Hash128, path: &str, virtual_id: Option>) { - if path.is_empty() { - return; - } - if let Ok(md) = std::fs::metadata(path) { - h.write_u64(md.len()); - if let Ok(t) = md.modified() - && let Ok(d) = t.duration_since(std::time::UNIX_EPOCH) - { - h.write_u64(d.as_nanos() as u64); - } - return; - } - if let Some((len, hash)) = virtual_id.and_then(|id| id(path)) { - h.write_u64(len); - h.write_u64(hash); - } -} +/// A [`VirtualFileId`] a cache can keep (the browser file table's identities). +pub type SharedVirtualFileId = Arc Option<(u64, u64)> + Send + Sync>; -fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage, virtual_id: Option>) { - file_stamp(h, &f.path, virtual_id); - for p in &f.sequence { - file_stamp(h, p, virtual_id); - } -} +/// Layer entries with their source files (#3184), part of every layer disk key: entries of the +/// earlier format (keyed by a project-wide footage salt instead) are never looked up again; +/// least recently used, they are evicted. +const LAYER_ENTRY_VERSION: &[u8] = b"layer-entry-files-2"; -/// Salt for layer cache keys on disk: the renderer version and every footage file's identity -/// (layer keys name footage items, not file contents). -pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { - footage_salt_with(project, None) -} - -/// [`footage_salt`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. -pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Option>) -> u64 { +/// Disk key of a layer buffer: the layer cache's content key ([`crate::cache::layer_key`] and +/// the keys derived from it), the layer entry format and the renderer version. The content key +/// comes from memory alone: it names the footage items the buffer uses with their settings and +/// paths, so two projects only share an entry for the same pixels of the same files; whether +/// those files still hold what the buffer was made of is checked when the entry is read +/// ([`EntryFiles`]). No file is looked at here. +pub fn layer_disk_key(key: u64) -> u128 { let mut h = Hash128::default(); + h.write(LAYER_ENTRY_VERSION); h.write(RENDER_VERSION.as_bytes()); - // Footage another layer reads (Audio Spectrum's Audio Layer); a read precomp can hold any. - let mut read = std::collections::BTreeSet::new(); - let mut read_comp = false; - for (_, c) in project.comps() { - for l in c.layers.iter() { - for r in l.referenced_layers().into_iter().filter(|r| *r != l.id) { - match c.layer(r).map(|x| &x.source) { - Some(effectcraft_project::LayerSource::Footage { item }) => _ = read.insert(*item), - Some(effectcraft_project::LayerSource::Comp { .. }) => read_comp = true, - _ => {} - } - } - } - } - for (id, it) in &project.items { - // (files without video make layer buffers only through a layer that reads them: - // importing music keeps the keys) - if let effectcraft_project::ItemKind::Footage(f) = &it.kind - && (f.has_video || read_comp || read.contains(id)) - { - h.write_u64(id.0); - hash_debug(&mut h, f); - footage_stamp(&mut h, f, virtual_id); - } - if let Some(proxy) = it.proxy.as_ref().filter(|p| p.enabled) { - h.write_u64(id.0); - hash_debug(&mut h, proxy); - footage_stamp(&mut h, &proxy.footage, virtual_id); - } - } - let v = h.finish(); - (v as u64) ^ ((v >> 64) as u64) + h.write_u64(key); + h.finish() } -/// Content key of a composition's pixels: project render settings, the comp and everything it -/// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy -/// files' size and time). With expressions in play (which can read any comp) the whole project -/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count, and -/// neither do markers unless an expression mentions them. +/// Content key of a composition's pixels, from the project in memory alone (no file is looked +/// at: the frame entries check their own source files when read, see [`EntryFiles`]): project +/// render settings, the comp and the items it can draw (nested comps, footage items with their +/// settings and paths, solids, Media Replacement footage, their proxies and Use Proxy switches; +/// [`effectcraft_project::Project::pixel_reach`]). With expressions among them, everything those +/// expressions can read counts too: every layer of their comps, markers, the comps and footage +/// they name with literal names (and which items have those names), or the whole project when +/// they compute a name or reach items another way. An edit to an item the comp can't reach keeps +/// its key and its frames on disk (#3184, #1492). Switches that don't change pixels (Audio, Lock, +/// Shy, Hide Shy Layers) don't count, and neither do markers unless an expression can read them. pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 { - comp_content_key_with(project, comp, None) + comp_content_key_for_renderer(project, comp, RENDER_VERSION) } -/// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. -pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option>) -> u128 { - comp_content_key_for_renderer(project, comp, virtual_id, RENDER_VERSION) -} - -fn comp_content_key_for_renderer( - project: &effectcraft_project::Project, - comp: effectcraft_project::ItemId, - virtual_id: Option>, - renderer: &str, -) -> u128 { - use effectcraft_project::{ItemKind, LayerSource}; +/// The content key of `comp` for one renderer: its settings, the comps and footage it uses, and the renderer name. +fn comp_content_key_for_renderer(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, renderer: &str) -> u128 { + use effectcraft_project::ItemKind; let mut h = Hash128::default(); h.write(renderer.as_bytes()); hash_debug(&mut h, &project.settings); - // Expressions can read anything (every item counts); without them, layers that can't - // change the picture (music, hidden layers nothing reads) and their files don't count. - let expressions = project.reads_markers(); let heard = project.heard_layers(); + let reach = project.pixel_reach(comp, &heard); + // Expressions can read any layer of the comps they reach, hidden or not, and their markers + // (#1083); without them, layers that can't change the picture (music, hidden layers nothing + // reads) don't count. + let expressions = reach.expressions; let silent = |id, c: &effectcraft_project::Comp| if expressions { Default::default() } else { project.silent_layers(id, c, &heard) }; - let mut seen = std::collections::BTreeSet::new(); - let mut stack = vec![comp]; - while let Some(id) = stack.pop() { - if !seen.insert(id) { - continue; - } - let Some(it) = project.item(id) else { continue }; - if let ItemKind::Comp(c) = &it.kind { - let quiet = silent(id, c); - for l in c.layers.iter().filter(|l| !quiet.contains(&l.id)) { - if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source { - stack.push(item); - } - } - } + // (the names expressions look items up by: which items have them now) + if !reach.names.is_empty() { + hash_debug(&mut h, &reach.names); } - // Markers are pixels only to the expressions that read them (#1083). - let markers = expressions; - let ids: Vec = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() }; + let ids: Vec = match reach.items { + Some(items) => items.into_iter().collect(), + None => project.items.keys().copied().collect(), + }; for id in ids { let Some(it) = project.item(id) else { continue }; h.write_u64(id.0); @@ -1184,30 +1518,30 @@ fn comp_content_key_for_renderer( let quiet = silent(id, c); let heard = |l| heard.contains(id, l); if quiet.is_empty() { - hash_debug(&mut h, &c.pixel_form_with(markers, &heard)); + hash_debug(&mut h, &c.pixel_form_with(expressions, &heard)); } else { let mut pic = (**c).clone(); pic.layers.retain(|l| !quiet.contains(&l.id)); - hash_debug(&mut h, &pic.pixel_form_with(markers, &heard)); + hash_debug(&mut h, &pic.pixel_form_with(expressions, &heard)); } } k => hash_debug(&mut h, k), } - if let ItemKind::Footage(f) = &it.kind { - footage_stamp(&mut h, f, virtual_id); - } hash_debug(&mut h, &it.proxy); - if let Some(px) = &it.proxy { - footage_stamp(&mut h, &px.footage, virtual_id); - } } h.write_u64(comp.0); h.finish() } +/// Frame entries with their source files (#3184), part of every frame key: entries of the +/// earlier format (keyed by footage stamps instead) are never looked up again; least recently +/// used, they are evicted. +const FRAME_ENTRY_VERSION: &[u8] = b"frame-entry-files-2"; + /// Disk key of one viewer frame. pub fn frame_key(content: u128, frame: i64, scale_milli: u32, view: u64) -> u128 { let mut h = Hash128::default(); + h.write(FRAME_ENTRY_VERSION); h.write(&content.to_le_bytes()); h.write_u64(frame as u64); h.write_u64(scale_milli as u64); @@ -1231,18 +1565,18 @@ mod tests { fn link_frame_serves_a_frame_under_a_new_key() { let dc = DiskCache::open(tmpdir("link"), 1 << 30).unwrap(); let rgba: Vec = (0..16).collect(); - dc.put_frame_now(1, 2, 2, &rgba); + dc.put_frame_now(1, 2, 2, &rgba, &ReadFiles::default()); assert!(dc.link_frame(1, 2)); - assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba.clone())); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).map(|f| f.rgba), Some(rgba.clone())); assert!(!dc.link_frame(1, 2), "already there"); assert!(!dc.link_frame(3, 4), "nothing to link"); assert!(!dc.contains(Kind::Frame, 4)); - dc.put_frame_now(5, 2, 2, &[7; 16]); + dc.put_frame_now(5, 2, 2, &[7; 16], &ReadFiles::default()); assert!(!dc.link_frame(5, 2), "an existing entry stays"); - assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba.clone())); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).map(|f| f.rgba), Some(rgba.clone())); let _ = std::fs::remove_file(dc.path(Kind::Frame, 1)); dc.forget(Kind::Frame, 1); - assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba), "the link outlives the original"); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).map(|f| f.rgba), Some(rgba), "the link outlives the original"); } fn buf(seed: u32, w: u32, h: u32) -> Buf { @@ -1277,11 +1611,21 @@ mod tests { #[test] fn frame_entries_round_trip_and_names_parse() { let rgba: Vec = (0..4 * 6 * 3).map(|i| (i * 7 % 256) as u8).collect(); - let e = frame_entry(6, 3, &rgba); - assert_eq!(read_frame_entry(&e), Some(Frame8 { width: 6, height: 3, rgba: rgba.clone() })); + let files: ReadFiles = [crate::reads::ReadFile::new("/v.png", crate::reads::FileStamp::Virtual)].into_iter().collect(); + let (one, two) = (|_: &str| Some((4, 1)), |_: &str| Some((4, 2))); + assert!(frame_entry(6, 3, &rgba, &files, None).is_none(), "a file from memory needs its identity"); + assert!(frame_entry(6, 3, &rgba[1..], &files, Some(&one)).is_none(), "a wrong size"); + let e = frame_entry(6, 3, &rgba, &files, Some(&one)).unwrap(); + assert_eq!(read_frame_entry(&e, Some(&one)), Some(Frame8 { width: 6, height: 3, rgba: rgba.clone() })); + assert!(read_frame_entry(&e, Some(&two)).is_none(), "the file changed"); + let mut incomplete = files.clone(); + incomplete.mark_incomplete(); + assert!(frame_entry(6, 3, &rgba, &incomplete, Some(&one)).is_none(), "an incomplete list is not stored"); let mut bad = e.clone(); bad[20] ^= 1; - assert!(read_frame_entry(&bad).is_none()); + assert!(read_frame_entry(&bad, Some(&one)).is_none()); + // An entry of the format before #3184 (pixels without files) is never read. + assert!(read_frame_entry(&seal_format(Kind::Frame, FORMAT_RGBA8, &encode_frame(6, 3, &rgba)), Some(&one)).is_none()); let key = 0x0123_4567_89ab_cdef_0011_2233_4455_6677u128; assert_eq!(parse_entry_name(&entry_name(key)), Some(key)); assert_eq!(parse_entry_name("abc.ecc"), None); @@ -1298,27 +1642,27 @@ mod tests { let (w, h) = (7u32, 5u32); // 4.0, 16.0, -0.25, 65504 and a spread of bit patterns. let rgba: Vec = (0..w * h * 4).map(|i| [0x4400, 0x4c00, 0xb400, 0x7bff][i as usize % 4] ^ ((i * 37) as u16 & 0x03ff)).collect(); - dc.put_frame_f16_now(1, w, h, &rgba); - assert_eq!(dc.get_frame_f16(1), Some(FrameF16 { width: w, height: h, rgba: rgba.clone() })); - assert!(dc.get_frame(1).is_none(), "not an 8-bit frame"); - dc.put_frame_now(2, 2, 2, &[9; 16]); - assert!(dc.get_frame_f16(2).is_none() && dc.contains(Kind::Frame, 2), "an 8-bit entry stays valid"); - assert_eq!(dc.get_frame(2).unwrap().rgba, vec![9; 16]); + dc.put_frame_f16_now(1, w, h, &rgba, &ReadFiles::default()); + assert_eq!(dc.get_frame_f16(1, None).map(|f| f.0), Some(FrameF16 { width: w, height: h, rgba: rgba.clone() })); + assert!(dc.get_frame(1, None).map(|f| f.0).is_none(), "not an 8-bit frame"); + dc.put_frame_now(2, 2, 2, &[9; 16], &ReadFiles::default()); + assert!(dc.get_frame_f16(2, None).map(|f| f.0).is_none() && dc.contains(Kind::Frame, 2), "an 8-bit entry stays valid"); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).unwrap().rgba, vec![9; 16]); assert!(dc.stats().bytes > 0); // Reopened, still there (format byte persisted). drop(dc); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); - assert_eq!(dc.get_frame_f16(1).unwrap().rgba, rgba); + assert_eq!(dc.get_frame_f16(1, None).map(|f| f.0).unwrap().rgba, rgba); // A sealed entry with a payload of the wrong size: deleted on read. let p = dc.path(Kind::Frame, 3); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); - std::fs::write(&p, seal_format(Kind::Frame, FORMAT_F16, &encode_frame_f16(w, h + 1, &rgba[..])[..])).unwrap(); + std::fs::write(&p, seal_format(Kind::Frame, FORMAT_F16_FILES, &with_files(&EntryFiles::default(), encode_frame_f16(w, h + 1, &rgba[..])))).unwrap(); dc.rescan(); assert!(dc.contains(Kind::Frame, 3)); - assert!(dc.get_frame_f16(3).is_none()); + assert!(dc.get_frame_f16(3, None).map(|f| f.0).is_none()); assert!(!dc.contains(Kind::Frame, 3) && !p.exists()); // Wrong lengths are not written. - dc.put_frame_f16(4, w, h, &rgba[1..]); + dc.put_frame_f16(4, w, h, &rgba[1..], &ReadFiles::default(), None, 0); dc.flush(); assert!(!dc.contains(Kind::Frame, 4)); let _ = std::fs::remove_dir_all(&dir); @@ -1360,26 +1704,27 @@ mod tests { fn hit_miss_and_exact_round_trip() { let dir = tmpdir("hit"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); - assert!(dc.get_layer(42).is_none()); + assert!(dc.get_layer(42, None).is_none()); let b = buf(1, 33, 17); - dc.put_layer(42, &b); + dc.put_layer(42, &b, &EntryFiles::default(), 0); dc.flush(); - let back = dc.get_layer(42).unwrap(); + let (back, files) = dc.get_layer(42, None).unwrap(); + assert!(files.is_empty() && files.is_complete()); assert_eq!(back.img, b.img); assert_eq!((back.offset, back.scale), (b.offset, b.scale)); let rgba: Vec = (0..20 * 10 * 4).map(|i| (i % 251) as u8).collect(); - assert!(dc.put_frame(7, 20, 10, &rgba)); - assert!(!dc.put_frame(8, 2, 2, &[0; 3]), "a wrong-size frame is refused"); + 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"); dc.flush(); - assert_eq!(dc.get_frame(7).unwrap().rgba, rgba); + assert_eq!(dc.get_frame(7, 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)); // Wrong kind is a miss. - assert!(dc.get_frame(42).is_none()); + assert!(dc.get_frame(42, None).map(|f| f.0).is_none()); } /// Music doesn't change a comp's disk keys (adding it to the timeline kept no blue frame); - /// hiding a clip does. Importing music keeps the layer-cache salt. + /// hiding a clip does. Importing music keeps them too. #[test] fn music_keeps_the_disk_keys() { use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, Project, Solid, build}; @@ -1391,14 +1736,14 @@ mod tests { let clip = build::layer(&mut p, &comp, "Clip", LayerSource::Solid { item: sid }, (64, 64), None); let clip_id = clip.id; p.comp_mut(cid).unwrap().layers.push(clip); - let (key, salt) = (comp_content_key(&p, cid), footage_salt(&p)); + let key = comp_content_key(&p, cid); let song = p.add_item( "song.wav", Default::default(), None, ItemKind::Footage(Footage { kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }), ); - assert_eq!(footage_salt(&p), salt, "importing music"); + assert_eq!(comp_content_key(&p, cid), key, "importing music"); let music = build::layer(&mut p, &comp, "Music", LayerSource::Footage { item: song }, (64, 64), None); p.comp_mut(cid).unwrap().layers.insert(0, music); assert_eq!(comp_content_key(&p, cid), key, "music on the timeline"); @@ -1406,6 +1751,28 @@ mod tests { assert_ne!(comp_content_key(&p, cid), key, "a hidden clip"); } + /// An expression that reads a 3D model layer's source (`thisComp.layer("Model").source.name`) + /// gives a new key when the model item is renamed: the frames on disk hold the old name. + #[test] + fn renaming_a_model_an_expression_reads_changes_the_disk_key() { + use effectcraft_project::{Comp, Expression, Footage, FootageKind, ItemKind, LayerSource, Project, Solid, build}; + use effectcraft_time::{FrameRate, Tick}; + let mut p = Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Main", Default::default(), None, ItemKind::Comp(comp.clone().into())); + let model = p.add_item("chair.glb", Default::default(), None, ItemKind::Footage(Footage { kind: FootageKind::Model, ..Default::default() })); + let sid = p.add_item("Solid", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 64, height: 64, pixel_aspect: 1.0 })); + let mut m = build::layer(&mut p, &comp, "Model", LayerSource::Model { item: model }, (64, 64), None); + m.switches.video = false; + let mut s = build::layer(&mut p, &comp, "Reader", LayerSource::Solid { item: sid }, (64, 64), None); + s.props.prop_mut("transform/opacity").unwrap().expr = + Some(Expression { text: "thisComp.layer(\"Model\").source.name.length * 10".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.extend([s, m]); + let key = comp_content_key(&p, cid); + p.item_mut(model).unwrap().name = "table.glb".into(); + assert_ne!(comp_content_key(&p, cid), key, "the expression reads the model's name"); + } + /// 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] @@ -1413,11 +1780,11 @@ 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)); + dc.put_layer(10_000 + k, &buf(1, 64, 64), &EntryFiles::default(), 0); } let rgba = vec![7u8; 32 * 32 * 4]; for k in 0..300 { - dc.put_frame(k, 32, 32, &rgba); + dc.put_frame(k, 32, 32, &rgba, &ReadFiles::default(), None, 0); } dc.flush(); assert!((0..300).all(|k| dc.contains(Kind::Frame, k)), "every frame written"); @@ -1432,17 +1799,17 @@ mod tests { let comp = Comp::new(2, 2, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); let id = project.add_item("Cached composition", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); let key = frame_key(comp_content_key(&project, id), 0, 1000, 0); - let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-5"), 0, 1000, 0); + let previous_key = frame_key(comp_content_key_for_renderer(&project, id, "effectcraft-render-5"), 0, 1000, 0); let dir = tmpdir("previous-renderer"); { let cache = DiskCache::open(&dir, 1 << 30).unwrap(); - cache.put_frame_now(previous_key, 2, 2, &[9; 16]); + cache.put_frame_now(previous_key, 2, 2, &[9; 16], &ReadFiles::default()); } let cache = DiskCache::open(&dir, 1 << 30).unwrap(); - assert_eq!(cache.get_frame(previous_key).unwrap().rgba, vec![9; 16]); - assert!(cache.get_frame(key).is_none()); - cache.put_frame_now(key, 2, 2, &[17; 16]); - assert_eq!(cache.get_frame(key).unwrap().rgba, vec![17; 16]); + assert_eq!(cache.get_frame(previous_key, None).map(|f| f.0).unwrap().rgba, vec![9; 16]); + assert!(cache.get_frame(key, None).map(|f| f.0).is_none()); + cache.put_frame_now(key, 2, 2, &[17; 16], &ReadFiles::default()); + assert_eq!(cache.get_frame(key, None).map(|f| f.0).unwrap().rgba, vec![17; 16]); } #[test] @@ -1450,16 +1817,16 @@ mod tests { let dir = tmpdir("persist"); { let dc = DiskCache::open(&dir, 1 << 30).unwrap(); - dc.put_layer_now(1, &buf(1, 8, 8)); - dc.put_frame_now(2, 2, 2, &[9; 16]); + dc.put_layer_now(1, &buf(1, 8, 8), &ReadFiles::default()); + dc.put_frame_now(2, 2, 2, &[9; 16], &ReadFiles::default()); } let dc = DiskCache::open(&dir, 1 << 30).unwrap(); assert_eq!(dc.stats().entries, 2); assert!(dc.contains(Kind::Layer, 1) && dc.contains(Kind::Frame, 2)); - assert_eq!(dc.get_frame(2).unwrap().rgba, vec![9; 16]); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).unwrap().rgba, vec![9; 16]); dc.clear(); assert_eq!(dc.stats().entries, 0); - assert!(dc.get_frame(2).is_none()); + assert!(dc.get_frame(2, None).map(|f| f.0).is_none()); let again = DiskCache::open(&dir, 1 << 30).unwrap(); assert_eq!(again.stats().entries, 0); } @@ -1469,11 +1836,11 @@ mod tests { let dir = tmpdir("lru"); let dc = DiskCache::open(&dir, u64::MAX).unwrap(); for k in 0..6u128 { - dc.put_layer_now(k, &buf(k as u32, 64, 64)); + dc.put_layer_now(k, &buf(k as u32, 64, 64), &ReadFiles::default()); } let per = dc.stats().bytes / 6; // Touch 0 so it is the most recently used. - assert!(dc.get_layer(0).is_some()); + assert!(dc.get_layer(0, None).is_some()); dc.set_max_bytes(per * 3 + per * 3 / 4); let st = dc.stats(); assert!(st.bytes <= per * 3 + per * 3 / 4, "{st:?}"); @@ -1485,7 +1852,7 @@ mod tests { let files = walk(&dir); assert_eq!(files, 3); // New writes keep evicting. - dc.put_layer_now(99, &buf(9, 64, 64)); + dc.put_layer_now(99, &buf(9, 64, 64), &ReadFiles::default()); assert!(dc.stats().bytes <= per * 3 + per * 3 / 4); } @@ -1510,7 +1877,7 @@ mod tests { let dir = tmpdir("space"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); for k in 0..4u128 { - dc.put_layer_now(k, &buf(k as u32, 64, 64)); + dc.put_layer_now(k, &buf(k as u32, 64, 64), &ReadFiles::default()); } let used = dc.stats().bytes; // Unknown space: the setting alone. @@ -1519,17 +1886,18 @@ mod tests { // A disk reported as 0 bytes is unknown. dc.set_disk_space(Some((0, 0))); assert_eq!(dc.stats().limit_bytes, 1 << 30); - // 100 GB disk, 5 GB reserved, exactly 5 GB free: the cache may not grow. + // 100 GB disk, 5 GB reserved (5%, between the 2 GB floor and the 10 GB cap), exactly + // 5 GB free: the cache may not grow. dc.set_disk_space(Some((100 << 30, 5 << 30))); let st = dc.stats(); assert_eq!((st.max_bytes, st.limit_bytes), (1 << 30, used)); - dc.put_layer_now(9, &buf(9, 64, 64)); + dc.put_layer_now(9, &buf(9, 64, 64), &ReadFiles::default()); let st = dc.stats(); assert!(st.bytes <= st.limit_bytes && st.bytes <= used - used / 10 && st.evictions > 0, "{st:?}"); assert!(dc.contains(Kind::Layer, 9), "the new entry is the most recently used"); // Below the reserve the cache gives its space back, and skips writes that can't fit. dc.set_disk_space(Some((100 << 30, 4 << 30))); - dc.put_layer_now(10, &buf(10, 64, 64)); + dc.put_layer_now(10, &buf(10, 64, 64), &ReadFiles::default()); let st = dc.stats(); assert_eq!((st.limit_bytes, st.entries, st.bytes), (0, 0, 0), "{st:?}"); // Plenty of space again: back to the setting. @@ -1538,14 +1906,86 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The reserve is 5% of the disk, at least 2 GB and at most 10 GB: a large disk with plenty + /// free keeps the user's size. (A 4 TB disk with 190 GB free reserved 200 GB, held a 20 GB + /// cache at 0, and every write was refused.) + #[test] + fn a_large_disk_reserves_at_most_ten_gb() { + const GB: u64 = 1 << 30; + assert_eq!(disk_reserve(4096 * GB), 10 * GB); + assert_eq!(disk_reserve(100 * GB), 5 * GB); + assert_eq!(disk_reserve(32 * GB), 2 * GB); + assert_eq!(disk_reserve(1), 2 * GB); + let dir = tmpdir("space-large"); + let dc = DiskCache::open(&dir, 20 * GB).unwrap(); + dc.set_disk_space(Some((4096 * GB, 190 * GB))); + let st = dc.stats(); + assert_eq!((st.max_bytes, st.limit_bytes), (20 * GB, 20 * GB), "{st:?}"); + assert!(dc.accepts_writes()); + assert_eq!(dc.reserve_bytes(), Some(10 * GB)); + // Just above the 10 GB reserve: the cache may use what is left above it. + dc.set_disk_space(Some((4096 * GB, 15 * GB))); + assert_eq!(dc.stats().limit_bytes, 5 * GB); + // A small disk still keeps 2 GB free: 3 GB free leaves 1 GB for the cache. + dc.set_disk_space(Some((32 * GB, 3 * GB))); + assert_eq!(dc.stats().limit_bytes, GB); + // Below the floor: nothing. + dc.set_disk_space(Some((32 * GB, GB))); + assert_eq!(dc.stats().limit_bytes, 0); + assert!(!dc.accepts_writes()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A write the free disk space refuses is refused before its pixels are compressed: the + /// frame workers spent about 8 ms per frame and 14 ms per layer encoding entries that were + /// then refused (`limit_bytes=0`). + #[test] + fn no_encode_while_the_disk_has_no_room() { + const GB: u64 = 1 << 30; + let dir = tmpdir("space-no-encode"); + let dc = DiskCache::open(&dir, 20 * GB).unwrap(); + // 4 TB disk, 1 GB free: under the 10 GB reserve, the limit is 0. + dc.set_disk_space(Some((4096 * GB, GB))); + assert_eq!(dc.stats().limit_bytes, 0); + 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.flush(); + assert_eq!(dc.encodes(), 0, "nothing compressed"); + assert_eq!(dc.stats().entries, 0); + // 32 MB of room above the reserve: a few frames would fill it, the rest would be + // compressed only to be refused. Not worth encoding either. + dc.set_disk_space(Some((4096 * GB, 10 * GB + (32 << 20)))); + 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); + assert_eq!(dc.encodes(), 0, "nothing compressed"); + // A small limit the setting asks for (not free space) is used. + dc.set_disk_space(None); + dc.set_max_bytes(32 << 20); + assert!(dc.accepts_writes()); + dc.set_max_bytes(20 * GB); + // Room again: the same writes are compressed and stored. + 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.flush(); + assert_eq!(dc.encodes(), 3); + assert_eq!(dc.stats().entries, 3); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn failed_writes_are_counted() { let dir = tmpdir("failed"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); // A file where the layers folder should be: every layer write fails. std::fs::write(dir.join(VERSION).join(Kind::Layer.dir()), b"x").unwrap(); - dc.put_layer_now(1, &buf(1, 8, 8)); - dc.put_layer_now(2, &buf(2, 8, 8)); + dc.put_layer_now(1, &buf(1, 8, 8), &ReadFiles::default()); + dc.put_layer_now(2, &buf(2, 8, 8), &ReadFiles::default()); let st = dc.stats(); assert_eq!((st.failed, st.writes, st.entries), (2, 0, 0)); let _ = std::fs::remove_dir_all(&dir); @@ -1570,13 +2010,13 @@ mod tests { fn damaged_files_are_misses() { let dir = tmpdir("damaged"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); - dc.put_frame_now(5, 4, 4, &[1; 64]); + dc.put_frame_now(5, 4, 4, &[1; 64], &ReadFiles::default()); let p = dc.path(Kind::Frame, 5); let mut d = std::fs::read(&p).unwrap(); let n = d.len(); d[n / 2] ^= 0xff; std::fs::write(&p, d).unwrap(); - assert!(dc.get_frame(5).is_none()); + assert!(dc.get_frame(5, None).map(|f| f.0).is_none()); assert!(!dc.contains(Kind::Frame, 5)); assert!(!p.exists()); } @@ -1594,8 +2034,8 @@ mod tests { for i in 0..20u128 { let k = i % 5; let w = if t % 2 == 0 { &a } else { &b }; - w.put_layer_now(k, &buf(k as u32, 16, 16)); - if let Some(x) = w.get_layer(k) { + w.put_layer_now(k, &buf(k as u32, 16, 16), &ReadFiles::default()); + if let Some((x, _)) = w.get_layer(k, None) { assert_eq!(x.img, buf(k as u32, 16, 16).img); } } @@ -1604,39 +2044,16 @@ mod tests { }); b.rescan(); for k in 0..5u128 { - assert_eq!(b.get_layer(k).unwrap().img, buf(k as u32, 16, 16).img); + assert_eq!(b.get_layer(k, None).unwrap().0.img, buf(k as u32, 16, 16).img); } } - /// An audio file nothing reads stays out of the layer salt; one a layer reads (Audio - /// Spectrum) is in it, so overwriting it doesn't reuse that layer's buffers. + /// Replaced bytes of a path with no native metadata (the browser file table) change neither + /// the comp key nor a layer's disk key (both from memory): the frame and layer entries made + /// of them check the hook's size and content hash when read and are misses, deleted (#1253, + /// #3184). #[test] - fn read_audio_counts_in_the_footage_salt() { - use effectcraft_project::build; - use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, MatteKind, Project, TrackMatte}; - use effectcraft_time::{FrameRate, Tick}; - - let mut p = Project::default(); - let f = Footage { path: "/song.wav".into(), kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }; - let song = p.add_item("song.wav", effectcraft_color::Label::None, None, ItemKind::Footage(f)); - let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); - let music = build::layer(&mut p, &comp, "music", LayerSource::Footage { item: song }, (16, 9), None); - let mut reader = build::layer(&mut p, &comp, "reader", LayerSource::Null, (16, 9), None); - let music_id = music.id; - comp.layers = vec![reader.clone(), music].into(); - let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); - - let id = |mark: u64| move |path: &str| (path == "/song.wav").then_some((8, mark)); - let (one, two) = (id(1), id(2)); - assert_eq!(footage_salt_with(&p, Some(&one)), footage_salt_with(&p, Some(&two)), "unread music stays out"); - reader.track_matte = Some(TrackMatte { layer: music_id, kind: MatteKind::Alpha }); - *p.comp_mut(cid).unwrap().layers.first_mut().unwrap() = reader; - assert_ne!(footage_salt_with(&p, Some(&one)), footage_salt_with(&p, Some(&two)), "read audio counts"); - } - - /// A path with no native metadata takes its key from the caller's size and content hash (#1253). - #[test] - fn virtual_file_bytes_change_content_keys() { + fn virtual_file_bytes_are_checked_by_the_entries_not_the_keys() { use effectcraft_project::build; use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project}; use effectcraft_time::{FrameRate, Tick}; @@ -1650,12 +2067,335 @@ mod tests { let id = |mark: u64| move |path: &str| (path == "/virtual.mov").then_some((8, mark)); let (one, two) = (id(1), id(2)); - let a = footage_salt_with(&p, Some(&one)); - let b = footage_salt_with(&p, Some(&two)); - let ka = comp_content_key_with(&p, cid, Some(&one)); - let kb = comp_content_key_with(&p, cid, Some(&two)); - assert_ne!(a, b, "replaced virtual media must not reuse the salt"); - assert_ne!(ka, kb, "replaced virtual media must not reuse the frame key"); - assert_eq!(comp_content_key(&p, cid), comp_content_key_with(&p, cid, None)); + let dc = DiskCache::open(tmpdir("virtual"), 1 << 30).unwrap(); + let k = frame_key(comp_content_key(&p, cid), 0, 1000, 0); + let files: ReadFiles = [crate::reads::ReadFile::new("/virtual.mov", crate::reads::FileStamp::Virtual)].into_iter().collect(); + assert!(dc.put_frame(k, 1, 1, &[1, 2, 3, 4], &files, Some(&one), 0)); + dc.flush(); + assert!(dc.get_frame(k, Some(&one)).is_some_and(|(_, f)| f == files)); + 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.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.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. + let e = layer_entry(&buf(2, 3, 2), &EntryFiles::of(&files, Some(&one)).unwrap()); + let (b, list) = read_layer_entry(&e).unwrap(); + assert!(b.img == buf(2, 3, 2).img && list.unchanged(Some(&one)) && !list.unchanged(Some(&two))); + assert!(read_layer_entry(&seal_format(Kind::Layer, FORMAT_RGBA8, &encode_layer(&buf(2, 3, 2)))).is_none(), "the format before #3184"); + let mut bad = e.clone(); + bad[12] ^= 1; + assert!(read_layer_entry(&bad).is_none(), "damaged"); + } + + /// Layer disk keys come from the layer cache's key alone (no file, no project-wide salt) and + /// differ per key. + #[test] + fn layer_disk_keys_need_no_files() { + assert_eq!(layer_disk_key(1), layer_disk_key(1)); + assert_ne!(layer_disk_key(1), layer_disk_key(2)); + // Not the layout of the salted keys before #3184 (`salt << 64 | key`). + assert_ne!(layer_disk_key(1) as u64, 1); + } + + /// A layer entry is used only while the files it lists are what the buffer was made of: a + /// new size, a new time, a missing file that is back, a file replaced by a folder, a damaged + /// files section and the format before #3184 are misses, and the entry is deleted so the + /// buffer is rendered and stored again. A buffer whose files can't all be named is not + /// stored. Only the listed files are looked at. + #[test] + fn layer_entries_check_their_files() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("layer-check"); + let src = dir.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let file = |name: &str, bytes: &[u8]| { + let p = src.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() + }; + let (a, b) = (file("a.png", b"aa"), file("b.png", b"bb")); + let gone = src.join("gone.png").to_string_lossy().to_string(); + let read = |paths: &[&str]| -> ReadFiles { paths.iter().map(|p| ReadFile::new(*p, FileStamp::native(p).unwrap_or(FileStamp::Missing))).collect() }; + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_layer_now(1, &buf(1, 4, 4), &read(&[&a])); + dc.put_layer_now(2, &buf(2, 4, 4), &read(&[&b, &gone])); + dc.put_layer_now(3, &buf(3, 4, 4), &read(&[&b])); + let (one, files) = dc.get_layer(1, None).unwrap(); + assert!(one.img == buf(1, 4, 4).img && files == read(&[&a]) && files.is_complete()); + assert_eq!(dc.file_checks(), 1, "only the file of that buffer"); + // a.png gets a new size: entry 1 is a miss and deleted, the others stay. + std::fs::write(&a, b"aaa").unwrap(); + assert!(dc.get_layer(1, None).is_none() && !dc.contains(Kind::Layer, 1)); + assert!(dc.get_layer(2, None).is_some() && dc.get_layer(3, None).is_some()); + // b.png gets a new time only. + let t = std::fs::metadata(&b).unwrap().modified().unwrap() + std::time::Duration::from_secs(7); + std::fs::File::options().append(true).open(&b).unwrap().set_modified(t).unwrap(); + assert!(dc.get_layer(3, None).is_none() && !dc.contains(Kind::Layer, 3)); + assert!(dc.get_layer(2, None).is_none(), "b.png is listed there too"); + // A missing file that is back. + dc.put_layer_now(4, &buf(4, 4, 4), &read(&[&gone])); + assert!(dc.get_layer(4, None).is_some(), "missing, still missing"); + file("gone.png", b"g"); + assert!(dc.get_layer(4, None).is_none() && !dc.contains(Kind::Layer, 4), "back"); + // A file replaced by a folder (unreadable). + let c = file("c.png", b"cc"); + dc.put_layer_now(5, &buf(5, 4, 4), &read(&[&c])); + std::fs::remove_file(&c).unwrap(); + std::fs::create_dir(&c).unwrap(); + assert!(dc.get_layer(5, None).is_none() && !dc.contains(Kind::Layer, 5)); + // Written again after the miss: a hit with the new identity. + dc.put_layer_now(1, &buf(9, 4, 4), &read(&[&a])); + assert_eq!(dc.get_layer(1, None).map(|l| l.0.img), Some(buf(9, 4, 4).img)); + // The format before #3184 (no files): a miss, deleted. + dc.write_format(Kind::Layer, 6, FORMAT_RGBA8, &encode_layer(&buf(6, 4, 4))); + assert!(dc.contains(Kind::Layer, 6)); + assert!(dc.get_layer(6, None).is_none() && !dc.contains(Kind::Layer, 6)); + // A valid checksum over a damaged files section, or over a cut buffer: misses, deleted. + let mut payload = vec![]; + payload.extend_from_slice(&3u32.to_le_bytes()); + payload.extend_from_slice(&encode_layer(&buf(7, 4, 4))); + dc.write_format(Kind::Layer, 7, FORMAT_LAYER_FILES, &payload); + assert!(dc.get_layer(7, None).is_none() && !dc.contains(Kind::Layer, 7)); + let mut cut = vec![]; + EntryFiles::default().encode(&mut cut); + cut.extend_from_slice(&encode_layer(&buf(8, 4, 4))[..40]); + dc.write_format(Kind::Layer, 8, FORMAT_LAYER_FILES, &cut); + assert!(dc.get_layer(8, None).is_none() && !dc.contains(Kind::Layer, 8)); + // A damaged file on disk. + dc.put_layer_now(9, &buf(9, 4, 4), &ReadFiles::default()); + let p = dc.path(Kind::Layer, 9); + let mut d = std::fs::read(&p).unwrap(); + let n = d.len(); + d[n / 2] ^= 0xff; + std::fs::write(&p, d).unwrap(); + assert!(dc.get_layer(9, None).is_none() && !dc.contains(Kind::Layer, 9) && !p.exists()); + // Buffers whose files can't all be named are not stored. + let mut incomplete = read(&[&a]); + incomplete.mark_incomplete(); + dc.put_layer_now(10, &buf(10, 4, 4), &incomplete); + assert!(!dc.contains(Kind::Layer, 10)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A frame entry is used only while the files it lists are what it was made of: a new size, + /// a new time, a missing file that is back, a file replaced by a folder are misses, and the + /// entry is deleted so the frame is rendered and stored again. Only the listed files are + /// looked at. Entries of the format before #3184 are misses and deleted. + #[test] + fn frame_entries_check_their_files() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("check"); + let src = dir.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let file = |name: &str, bytes: &[u8]| { + let p = src.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() + }; + let (a, b) = (file("a.png", b"aa"), file("b.png", b"bb")); + let gone = src.join("gone.png").to_string_lossy().to_string(); + let read = |paths: &[&str]| -> ReadFiles { paths.iter().map(|p| ReadFile::new(*p, FileStamp::native(p).unwrap_or(FileStamp::Missing))).collect() }; + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_frame_now(1, 1, 1, &[1; 4], &read(&[&a])); + dc.put_frame_now(2, 1, 1, &[2; 4], &read(&[&b, &gone])); + dc.put_frame_now(3, 1, 1, &[3; 4], &read(&[&b])); + assert_eq!(dc.get_frame(1, None).map(|f| f.1), Some(read(&[&a]))); + assert_eq!(dc.file_checks(), 1, "only the file of that frame"); + // a.png gets a new size: frame 1 is a miss and deleted, the others stay. + std::fs::write(&a, b"aaa").unwrap(); + assert!(dc.get_frame(1, None).is_none() && !dc.contains(Kind::Frame, 1)); + assert!(dc.get_frame(2, None).is_some() && dc.get_frame(3, None).is_some()); + // b.png gets a new time only. + let t = std::fs::metadata(&b).unwrap().modified().unwrap() + std::time::Duration::from_secs(7); + std::fs::File::options().append(true).open(&b).unwrap().set_modified(t).unwrap(); + assert!(dc.get_frame(3, None).is_none() && !dc.contains(Kind::Frame, 3)); + assert!(dc.get_frame(2, None).is_none(), "b.png is listed there too"); + // A missing file that is back. + dc.put_frame_now(4, 1, 1, &[4; 4], &read(&[&gone])); + assert!(dc.get_frame(4, None).is_some(), "missing, still missing"); + file("gone.png", b"g"); + assert!(dc.get_frame(4, None).is_none() && !dc.contains(Kind::Frame, 4), "back"); + // A file replaced by a folder (unreadable). + let c = file("c.png", b"cc"); + dc.put_frame_now(5, 1, 1, &[5; 4], &read(&[&c])); + std::fs::remove_file(&c).unwrap(); + std::fs::create_dir(&c).unwrap(); + assert!(dc.get_frame(5, None).is_none() && !dc.contains(Kind::Frame, 5)); + // Written again after the miss: a hit with the new identity. + let fresh = file("d.png", b"dd"); + dc.put_frame_now(1, 1, 1, &[9; 4], &read(&[&fresh])); + assert_eq!(dc.get_frame(1, None).map(|f| f.0.rgba), Some(vec![9; 4])); + // The format before #3184 (no files): a miss, deleted. + dc.write_format(Kind::Frame, 6, FORMAT_RGBA8, &encode_frame(1, 1, &[6; 4])); + assert!(dc.contains(Kind::Frame, 6)); + assert!(dc.get_frame(6, None).is_none() && !dc.contains(Kind::Frame, 6)); + // A valid checksum over a damaged files section: a miss, deleted. + let mut payload = vec![]; + payload.extend_from_slice(&3u32.to_le_bytes()); + payload.extend_from_slice(&encode_frame(1, 1, &[7; 4])); + dc.write_format(Kind::Frame, 7, FORMAT_RGBA8_FILES, &payload); + assert!(dc.get_frame(7, None).is_none() && !dc.contains(Kind::Frame, 7)); + // Frames whose files can't all be named are not stored. + let mut incomplete = read(&[&fresh]); + incomplete.mark_incomplete(); + assert!(!dc.put_frame(8, 1, 1, &[8; 4], &incomplete, None, 0)); + dc.flush(); + assert!(!dc.contains(Kind::Frame, 8)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Reload Footage drops the entries made of the reloaded files (#3184): a file replaced by + /// one with the same size and modification time passes the check on hit, so only the drop + /// makes the next use a miss. Entries of other files stay; an entry written after the drop + /// is used; a layer entry and one written while the drop waits in the queue are dropped too; + /// the drop survives a restart (the files are gone). + #[test] + fn dropped_files_take_their_entries_with_them() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("drop"); + let src = dir.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let file = |name: &str, bytes: &[u8]| { + let p = src.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() + }; + let (a, b) = (file("a.png", b"aa"), file("b.png", b"bb")); + let read = |paths: &[&str]| -> ReadFiles { paths.iter().map(|p| ReadFile::new(*p, FileStamp::native(p).unwrap())).collect() }; + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_frame_now(1, 1, 1, &[1; 4], &read(&[&a])); + dc.put_frame_now(2, 1, 1, &[2; 4], &read(&[&b])); + dc.put_frame_now(3, 1, 1, &[3; 4], &read(&[&a, &b])); + dc.put_layer_now(4, &buf(4, 2, 2), &read(&[&a])); + // a.png gets other bytes of the same size and its old time back: the check can't tell. + let t = std::fs::metadata(&a).unwrap().modified().unwrap(); + std::fs::write(&a, b"AA").unwrap(); + std::fs::File::options().append(true).open(&a).unwrap().set_modified(t).unwrap(); + assert!(dc.get_frame(1, None).is_some(), "same size and time: still a hit"); + // A frame queued (not written yet) when the drop comes is dropped too. + assert!(dc.put_frame(5, 1, 1, &[5; 4], &read(&[&a]), None, 0)); + dc.drop_files([a.clone()]); + dc.flush(); + assert!(!dc.is_dropping()); + for (kind, key) in [(Kind::Frame, 1), (Kind::Frame, 3), (Kind::Layer, 4), (Kind::Frame, 5)] { + assert!(!dc.contains(kind, key), "{kind:?} {key} made of a.png is gone"); + } + assert!(dc.get_frame(1, None).is_none() && dc.get_layer(4, None).is_none()); + assert_eq!(dc.get_frame(2, None).map(|f| f.0.rgba), Some(vec![2; 4]), "b.png's frame stays"); + // Written again from the reloaded file: used. + dc.put_frame_now(1, 1, 1, &[9; 4], &read(&[&a])); + assert_eq!(dc.get_frame(1, None).map(|f| f.0.rgba), Some(vec![9; 4])); + // The drop is for good: a new session on the folder finds what is left. + drop(dc); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + assert_eq!(dc.keys(Kind::Frame).into_iter().collect::>(), [1, 2].into()); + assert!(dc.keys(Kind::Layer).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A render under way when Reload Footage drops its files offers its entries after the drop + /// (#3184), when the writer has already deleted the old ones: made of the files as they were, + /// they are refused, frames and layer buffers alike. What the same render made of other files + /// is stored, and so is what a render that started after the drop makes. + #[test] + fn entries_made_before_a_drop_are_refused_after_it() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("drop-in-flight"); + std::fs::create_dir_all(&dir).unwrap(); + let file = |name: &str| { + let p = dir.join(name); + std::fs::write(&p, name.as_bytes()).unwrap(); + p.to_string_lossy().to_string() + }; + let (a, b) = (file("a.png"), file("b.png")); + let read = |paths: &[&str]| -> ReadFiles { paths.iter().map(|p| ReadFile::new(*p, FileStamp::native(p).unwrap())).collect() }; + let dc = DiskCache::open(dir.join("cache"), 1 << 30).unwrap(); + // The render starts (and reads a.png and b.png), then a.png is reloaded. + let before = dc.drop_generation(); + dc.drop_files([a.clone()]); + dc.flush(); + assert!(!dc.is_dropping()); + 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); + 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)); + assert!(dc.get_frame(1, None).is_none() && dc.get_layer(3, None).is_none()); + assert_eq!(dc.get_frame(4, None).map(|f| f.0.rgba), Some(vec![4; 4])); + // A render that starts after the drop reads the reloaded file: stored and served. + 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.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()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The record of dropped files is bounded: past it, everything made before the drop that + /// forgot the record is refused, and what is made after it is stored. + #[test] + fn the_record_of_dropped_files_is_bounded() { + let mut d = Drops::default(); + let a: Arc = "/a.png".into(); + d.record(std::slice::from_ref(&a)); + assert!(d.stale([&a].into_iter(), 0) && !d.stale([&a].into_iter(), 1)); + let many: Vec> = (0..=MAX_DROPPED_PATHS).map(|i| format!("/f{i}").into()).collect(); + d.record(&many); + assert!(d.last.is_empty(), "forgotten, not grown"); + let other: Arc = "/other.png".into(); + assert!(d.stale([&other].into_iter(), 1), "made before the drop that forgot the record"); + assert!(!d.stale([&a].into_iter(), 2), "made after it"); + } + + /// While a drop waits, an entry made of a dropped file is a miss (and deleted), and frames + /// aren't linked to new keys. + #[test] + fn entries_of_a_pending_drop_are_misses() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("drop-pending"); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("a.png"); + std::fs::write(&a, b"aa").unwrap(); + let a = a.to_string_lossy().to_string(); + let files: ReadFiles = [ReadFile::new(a.as_str(), FileStamp::native(&a).unwrap())].into_iter().collect(); + let dc = DiskCache::open(dir.join("cache"), 1 << 30).unwrap(); + dc.put_frame_now(1, 1, 1, &[1; 4], &files); + dc.dropping.lock().unwrap().insert(a.as_str().into(), 1); + assert!(!dc.link_frame(1, 2)); + assert!(dc.get_frame(1, None).is_none() && !dc.contains(Kind::Frame, 1)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// An entry read while its file's drop was pending is a miss even when the writer ends the + /// sweep between the read and the check (it deleted the file, but its bytes were read), and + /// so is one read before a drop that starts meanwhile. Found under load: a stale hit. + #[test] + fn a_sweep_ending_during_a_read_never_makes_a_hit() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("drop-race"); + let dc = DiskCache::open(dir.join("cache"), 1 << 30).unwrap(); + let files = EntryFiles::of(&[ReadFile::new("/a.png", FileStamp::Missing)].into_iter().collect(), None).unwrap(); + // Read while the drop is pending; the sweep ends before the check. + dc.dropping.lock().unwrap().insert("/a.png".into(), 1); + let drops = dc.drops_now(); + dc.dropping.lock().unwrap().clear(); + assert!(dc.made_of_dropped(&files, &drops)); + // Read before a drop that starts (and ends) before the check. + let drops = dc.drops_now(); + dc.drop_files(["/a.png"]); + dc.flush(); + assert!(dc.made_of_dropped(&files, &drops)); + assert!(!dc.made_of_dropped(&files, &dc.drops_now()), "read after the drop: used"); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/render/src/disk_cache/entry_files.rs b/crates/render/src/disk_cache/entry_files.rs new file mode 100644 index 000000000..a36ca1033 --- /dev/null +++ b/crates/render/src/disk_cache/entry_files.rs @@ -0,0 +1,276 @@ +//! The source files a frame entry on disk was made of (#3184): stored with the pixels, checked +//! when the entry is used. Only the files that frame read are looked at, never every file of the +//! project. +//! +//! Layout (little endian), before the frame's pixel payload: +//! `u32 count`, then per file `u32 path length`, the path (UTF-8), a tag byte and its fields: +//! `0` a file on disk (`u64 size`, `u8 has time`, `i128 time`: nanoseconds since the Unix epoch, +//! negative before it), `1` bytes from the browser's file table (`u64 size`, `u64 content +//! hash`), `2` a file that could not be read. + +use std::sync::Arc; + +use super::VirtualFileId; +use crate::reads::{FileStamp, MAX_READ_FILES, ReadFile, ReadFiles}; + +/// Longest path an entry stores (bytes): a longer one is not a path this app opened. +const MAX_PATH_BYTES: usize = 1 << 16; + +/// A file's identity as a frame entry keeps it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryStamp { + /// Size and modification time (nanoseconds since the Unix epoch, negative before it) on + /// disk. + Native { len: u64, modified: Option }, + /// Size and content hash from the [`VirtualFileId`] hook (the browser's file table). + Virtual { len: u64, hash: u64 }, + /// It could not be read when the frame was made: it still must not be readable. + Missing, +} + +/// One file of a frame entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EntryFile { + pub path: Arc, + pub stamp: EntryStamp, +} + +/// Every file a frame entry was made of. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EntryFiles(Vec); + +impl EntryFiles { + /// The list to store for a frame that read `files`: `None` when the frame can't be checked + /// against its files later (the list is incomplete, or a file from memory has no identity), + /// so the frame must not be stored. Native stamps are the ones the files were read with, + /// never a fresh look at the disk (a file changed during the render keeps the old one). + pub fn of(files: &ReadFiles, virtual_id: Option>) -> Option { + if !files.is_complete() { + return None; + } + let mut out = Vec::with_capacity(files.len()); + for f in files.files() { + let stamp = match f.stamp { + FileStamp::Native { len, modified } => EntryStamp::Native { len, modified }, + FileStamp::Virtual => { + let (len, hash) = virtual_id.and_then(|id| id(&f.path))?; + EntryStamp::Virtual { len, hash } + } + FileStamp::Missing => EntryStamp::Missing, + }; + out.push(EntryFile { path: f.path.clone(), stamp }); + } + Some(EntryFiles(out)) + } + + /// The listed files. + pub fn files(&self) -> &[EntryFile] { + &self.0 + } + + /// The number of listed files. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Whether no files are listed. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// The files as a render reports them (to keep with the frame in memory). + pub fn to_read_files(&self) -> ReadFiles { + self.0 + .iter() + .map(|f| { + let stamp = match f.stamp { + EntryStamp::Native { len, modified } => FileStamp::Native { len, modified }, + EntryStamp::Virtual { .. } => FileStamp::Virtual, + EntryStamp::Missing => FileStamp::Missing, + }; + ReadFile::new(f.path.clone(), stamp) + }) + .collect() + } + + /// Whether every file still is what the frame was made of: one `metadata` call per file + /// (and the hook for files from memory). + pub fn unchanged(&self, virtual_id: Option>) -> bool { + self.0.iter().all(|f| file_unchanged(f, virtual_id)) + } + + /// Appends the list to `out` in little-endian binary form. + pub(super) fn encode(&self, out: &mut Vec) { + out.extend_from_slice(&(self.0.len() as u32).to_le_bytes()); + for f in &self.0 { + out.extend_from_slice(&(f.path.len() as u32).to_le_bytes()); + out.extend_from_slice(f.path.as_bytes()); + match f.stamp { + EntryStamp::Native { len, modified } => { + out.push(0); + out.extend_from_slice(&len.to_le_bytes()); + out.push(u8::from(modified.is_some())); + out.extend_from_slice(&modified.unwrap_or(0).to_le_bytes()); + } + EntryStamp::Virtual { len, hash } => { + out.push(1); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&hash.to_le_bytes()); + } + EntryStamp::Missing => out.push(2), + } + } + } + + /// The list at the start of `p` and what follows it; `None` when it is damaged. + pub(super) fn decode(p: &[u8]) -> Option<(EntryFiles, &[u8])> { + let mut r = Reader(p); + let n = r.u32()? as usize; + if n > MAX_READ_FILES { + return None; + } + let mut out = Vec::with_capacity(n); + for _ in 0..n { + let len = r.u32()? as usize; + if len > MAX_PATH_BYTES { + return None; + } + let path: Arc = std::str::from_utf8(r.take(len)?).ok()?.into(); + let stamp = match r.u8()? { + 0 => { + let len = r.u64()?; + let has = r.u8()?; + let t = r.i128()?; + match has { + 0 => EntryStamp::Native { len, modified: None }, + 1 => EntryStamp::Native { len, modified: Some(t) }, + _ => return None, + } + } + 1 => EntryStamp::Virtual { len: r.u64()?, hash: r.u64()? }, + 2 => EntryStamp::Missing, + _ => return None, + }; + out.push(EntryFile { path, stamp }); + } + Some((EntryFiles(out), r.0)) + } +} + +/// Whether the file still matches its stored stamp, by its native stamp or by its in-memory identity. +fn file_unchanged(f: &EntryFile, virtual_id: Option>) -> bool { + let from_memory = || virtual_id.and_then(|id| id(&f.path)); + match f.stamp { + EntryStamp::Native { len, modified } => FileStamp::native(&f.path) == Some(FileStamp::Native { len, modified }), + EntryStamp::Virtual { len, hash } => from_memory() == Some((len, hash)), + // (a folder at the path is still not a file to read) + EntryStamp::Missing => !std::fs::metadata(&*f.path).is_ok_and(|m| m.is_file()) && from_memory().is_none(), + } +} + +/// Reads little-endian fields off the front of a byte slice. +struct Reader<'a>(&'a [u8]); + +impl<'a> Reader<'a> { + /// Takes the next `n` bytes, or `None` when fewer remain. + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let head = self.0.get(..n)?; + self.0 = self.0.get(n..)?; + Some(head) + } + /// Reads one byte. + fn u8(&mut self) -> Option { + self.take(1)?.first().copied() + } + /// Reads a little-endian `u32`. + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + /// Reads a little-endian `u64`. + fn u64(&mut self) -> Option { + Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?)) + } + /// Reads a little-endian `i128`. + fn i128(&mut self) -> Option { + Some(i128::from_le_bytes(self.take(16)?.try_into().ok()?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A sample list of native, missing and virtual files for the tests. + fn files() -> ReadFiles { + [ + ReadFile::new("/a.png", FileStamp::Native { len: 3, modified: Some(9) }), + ReadFile::new("/b.png", FileStamp::Native { len: 4, modified: None }), + // Before 1970, and past where `u64` nanoseconds end (2554). + ReadFile::new("/d.png", FileStamp::Native { len: 5, modified: Some(-86_400_000_000_000) }), + ReadFile::new("/e.png", FileStamp::Native { len: 6, modified: Some(i128::from(u64::MAX) + 1) }), + ReadFile::new("/c.png", FileStamp::Missing), + ReadFile::new("/v.mov", FileStamp::Virtual), + ] + .into_iter() + .collect() + } + + #[test] + fn lists_round_trip_and_damage_is_caught() { + let id = |p: &str| (p == "/v.mov").then_some((8, 77)); + let list = EntryFiles::of(&files(), Some(&id)).unwrap(); + assert_eq!(list.files()[5].stamp, EntryStamp::Virtual { len: 8, hash: 77 }); + let mut bytes = vec![]; + list.encode(&mut bytes); + bytes.extend_from_slice(b"rest"); + let (back, rest) = EntryFiles::decode(&bytes).unwrap(); + assert_eq!((back, rest), (list.clone(), &b"rest"[..])); + assert_eq!(list.to_read_files(), files()); + // Every truncation is caught (never a panic, never a shorter list). + let mut plain = vec![]; + list.encode(&mut plain); + for n in 0..plain.len() { + assert!(EntryFiles::decode(&plain[..n]).is_none(), "cut at {n}"); + } + // Bad tags and counts. + let mut bad = plain.clone(); + bad[0..4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(EntryFiles::decode(&bad).is_none()); + } + + #[test] + fn frames_that_cant_be_checked_are_not_stored() { + let mut incomplete = files(); + incomplete.mark_incomplete(); + let id = |_: &str| Some((1, 1)); + assert!(EntryFiles::of(&incomplete, Some(&id)).is_none(), "an incomplete list"); + assert!(EntryFiles::of(&files(), None).is_none(), "a file from memory without the hook"); + assert!(EntryFiles::of(&ReadFiles::default(), None).is_some_and(|l| l.is_empty()), "a frame that reads no file"); + } + + #[test] + fn the_check_looks_at_the_listed_files() { + let dir = std::env::temp_dir().join(format!("effectcraft-entry-files-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("f.bin"); + std::fs::write(&p, b"abc").unwrap(); + let path = p.to_string_lossy().to_string(); + let gone = dir.join("gone.bin").to_string_lossy().to_string(); + let stamp = FileStamp::native(&path).unwrap(); + let read: ReadFiles = [ReadFile::new(path.as_str(), stamp), ReadFile::new(gone.as_str(), FileStamp::Missing)].into_iter().collect(); + let list = EntryFiles::of(&read, None).unwrap(); + assert!(list.unchanged(None)); + std::fs::write(&p, b"abcd").unwrap(); + assert!(!list.unchanged(None), "a new size"); + std::fs::write(&gone, b"x").unwrap(); + let list = EntryFiles::of(&[ReadFile::new(gone.as_str(), FileStamp::Missing)].into_iter().collect(), None).unwrap(); + assert!(!list.unchanged(None), "a missing file that is back"); + let v: ReadFiles = [ReadFile::new("/v", FileStamp::Virtual)].into_iter().collect(); + let one = |_: &str| Some((1, 1)); + let two = |_: &str| Some((1, 2)); + let list = EntryFiles::of(&v, Some(&one)).unwrap(); + assert!(list.unchanged(Some(&one)) && !list.unchanged(Some(&two)) && !list.unchanged(None)); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 0a10afa45..41e6132d3 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -44,11 +44,15 @@ pub struct EvalCtx<'a> { /// Media Replacement values overridden by the Essential Properties of a collapsed precomp /// instance. pub media_overrides: &'a [crate::MediaOverride], + /// Where the source files of what expressions render (`sampleImage`) are noted: the + /// renderer's [`Renderer::reads`](crate::Renderer::reads) while rendering. + pub reads: Option<&'a crate::reads::ReadLog>, } impl<'a> EvalCtx<'a> { + /// A context for `comp` at `time` with no expressions, footage, overrides or read log. pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> { - EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[] } + EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[], reads: None } } pub fn at(&self, time: Tick) -> EvalCtx<'a> { EvalCtx { time, ..*self } diff --git a/crates/render/src/footage.rs b/crates/render/src/footage.rs index 4c90967d3..0e17f152d 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -1,4 +1,5 @@ use super::{Arc, AtomicBool, Footage, Image, ItemId, ItemKind, Ordering, Tick}; +use crate::reads::ReadLog; /// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are /// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other @@ -92,6 +93,15 @@ pub trait FootageSource: Send + Sync { fn called_off(&self) -> bool { false } + /// Run `f`, which reads from this source on this thread, noting in `reads` every source file + /// those reads use, with its identity as read (see [`crate::reads`]): the file a frame was + /// decoded from, also when it comes from the source's own memory cache (without reading the + /// file again). Sources that can't tell which files they read keep the default, which marks + /// `reads` incomplete. + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + reads.mark_incomplete(); + f() + } /// Why `footage` shows no picture although its file is there: a read of it failed because /// no decoder handles its codec, profile, chroma format or bit depth, or the stream is damaged /// (e.g. "H.264 (High 4:2:2) isn't supported by the built-in decoder: ..."). `None` while it @@ -194,6 +204,10 @@ impl FootageSource for CancellableFootage<'_> { fn called_off(&self) -> bool { self.cancel.load(Ordering::Relaxed) || self.inner.called_off() } + /// Runs `f` with the inner source, collecting the files it reads into `reads`. + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + self.inner.recording(reads, f) + } fn problem(&self, footage: &Footage) -> Option { self.inner.problem(footage) } @@ -211,7 +225,12 @@ pub fn is_vector_footage(f: &Footage) -> bool { pub struct NoFootage; impl FootageSource for NoFootage { + /// Always `None`: there is no footage. fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { None } + /// Reads no files. + fn recording(&self, _: &ReadLog, f: &mut dyn FnMut()) { + f() + } } diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs index 65bafe002..3a6f1a34c 100644 --- a/crates/render/src/fx_host.rs +++ b/crates/render/src/fx_host.rs @@ -95,6 +95,7 @@ impl EffectHost for FxHost<'_, '_, '_> { Some(self.pixels(other, buf, false)) } + /// The audio of footage layer `id` from `start`, or `None` when the layer is not footage. fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option> { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; let LayerSource::Footage { item } = &other.source else { return None }; @@ -104,7 +105,7 @@ impl EffectHost for FxHost<'_, '_, '_> { return None; } let st = other.layer_time(Tick::from_seconds_f64(start)); - self.r.footage.audio(item, f, st, frames, rate) + self.r.read(|| self.r.footage.audio(item, f, st, frames, rate)) } fn self_at(&self, layer_time: f64, effects: usize) -> Option { diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index d105c6f13..df976a857 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -34,21 +34,43 @@ impl Shutter { } } +/// `files` (a content's) with the files of the buffers made of it that the layer cache kept: +/// `files` itself when they are all its own. +fn with_kept_files(files: Arc, kept: &[Arc]) -> Arc { + if kept.iter().all(|f| Arc::ptr_eq(f, &files)) { + return files; + } + let mut all = (*files).clone(); + kept.iter().for_each(|f| all.extend(f)); + Arc::new(all) +} + impl<'a> Renderer<'a> { /// The layer with its styles: exterior passes and body (cached per pass). pub(crate) fn styled(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option { - let (content, ckey) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?; + let (content, ckey, files) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?; if !styles::active(ctx, layer) { - return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: ckey, ckey, plain: true }); + return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: ckey, ckey, plain: true, files }); } let modes = styles::pass_modes(ctx, layer); let key = ckey.map(|k| cache::styles_key(ctx, layer, k)); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(body) = c.get(k) - { - let passes: Option> = modes.iter().enumerate().map(|(i, m)| c.get(cache::derive(k, i as u64 + 1)).map(|b| (b, *m))).collect(); + // (a cached body or pass may have been made of other files than the content: another + // worker's, or one from disk; the styled layer lists the files of every buffer it holds) + if let Some((body, body_files)) = self.cached(key) { + let mut kept_files = vec![body_files]; + let passes: Option> = modes + .iter() + .enumerate() + .map(|(i, m)| { + self.cached(key.map(|k| cache::derive(k, i as u64 + 1))).map(|(b, f)| { + kept_files.push(f); + (b, *m) + }) + }) + .collect(); if let Some(passes) = passes { - return Some(StyledLayer { passes, body, content, key, ckey, plain: false }); + let files = with_kept_files(files, &kept_files); + return Some(StyledLayer { passes, body, content, key, ckey, plain: false, files }); } } let t0 = web_time::Instant::now(); @@ -59,16 +81,23 @@ impl<'a> Renderer<'a> { t.cached = false; t.effects.push(("layerStyles".into(), t0.elapsed().as_secs_f64() * 1e3)); } - let mut body = Arc::new(st.body); - let mut passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect(); - // The buffers the cache keeps (another worker's, if it kept them first). - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - body = c.insert(k, body); - for (i, (b, _)) in passes.iter_mut().enumerate() { - *b = c.insert(cache::derive(k, i as u64 + 1), b.clone()); - } - } - Some(StyledLayer { passes, body, content, key, ckey, plain: false }) + // The buffers the cache keeps (another worker's, if it kept them first), made of the + // content's files; the styled layer then lists the files of every buffer it holds. + let mut kept_files = vec![]; + let (body, f) = self.keep(key, Arc::new(st.body), &files); + kept_files.push(f); + let passes: Vec<_> = st + .passes + .into_iter() + .enumerate() + .map(|(i, (b, m))| { + let (b, f) = self.keep(key.map(|k| cache::derive(k, i as u64 + 1)), Arc::new(b), &files); + kept_files.push(f); + (b, m) + }) + .collect(); + let files = with_kept_files(files, &kept_files); + Some(StyledLayer { passes, body, content, key, ckey, plain: false, files }) } /// The styled layer's buffers in the blending space. @@ -76,9 +105,10 @@ impl<'a> Renderer<'a> { if self.pipe.to_blend().is_none() { return st; } - let body = self.to_blend(st.body, st.key); - let content = if st.plain { body.clone() } else { self.to_blend(st.content, st.ckey) }; - let passes = st.passes.into_iter().enumerate().map(|(i, (b, m))| (self.to_blend(b, st.key.map(|k| cache::derive(k, i as u64 + 1))), m)).collect(); + let body = self.to_blend(st.body, st.key, &st.files); + let content = if st.plain { body.clone() } else { self.to_blend(st.content, st.ckey, &st.files) }; + let passes = + st.passes.into_iter().enumerate().map(|(i, (b, m))| (self.to_blend(b, st.key.map(|k| cache::derive(k, i as u64 + 1)), &st.files), m)).collect(); StyledLayer { passes, body, content, ..st } } @@ -236,7 +266,11 @@ impl<'a> Renderer<'a> { /// A processed-layer cache key that also covers content motion blur (the averaged source /// depends on the content at every sub-sample). pub(crate) fn content_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { - let k = key?; + let k = self.mask_gate_key(ctx, layer, key)?; + // A layer that reads other layers through its effects' layer parameters gets them drawn + // by this renderer: the switches it passes down (Switches Affect Nested Comps, the + // precomp layers above, motion blur on) change those pixels, not just the layers' own. + let k = if cache::reads_layers(ctx, layer) { cache::derive(k, self.nested_look()) } else { k }; let Some(times) = self.content_times(ctx, layer) else { return Some(k) }; times.iter().try_fold(cache::derive(k, 0x6d62_6c72), |d, t| Some(cache::derive(d, cache::layer_key(&ctx.at(*t), layer, 1.0, false, false)?))) } @@ -257,6 +291,15 @@ impl<'a> Renderer<'a> { } } + /// A cache key of a masked layer's buffer, told apart when mask motion blur can't run at all + /// in this render ([`Self::mask_blur`]'s gate: the render options, the precomp layers above). + /// The key has the layer's own motion blur state, but a mask set to On blurs without it. + pub(crate) fn mask_gate_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { + let k = key?; + let masked = layer.masks().is_some_and(|m| !m.children.is_empty()); + Some(if masked && !self.mask_blur(ctx, layer).allowed { cache::derive(k, 0x6d62_6f66) } else { k }) + } + /// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D /// layer gets about one sample per output pixel its corners travel during the shutter, /// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that @@ -582,6 +625,8 @@ pub struct StyledLayer { ckey: Option, /// No layer styles: composite `body` the usual way. pub plain: bool, + /// The source files the layer's buffers were made of (see [`crate::reads`]). + pub(crate) files: Arc, } /// Where a layer buffer lands (see [`Renderer::placement`]). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dbd948320..e09794de2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -17,6 +17,7 @@ use color::Region; pub mod eval; pub mod masks; pub mod passes; +pub mod reads; pub mod shapes; pub mod styles; pub mod text; @@ -69,6 +70,10 @@ pub struct Renderer<'a> { pub opts: RenderOpts, /// Processed-layer cache shared across frames (see [`cache`]). `None` renders everything. pub cache: Option<&'a LayerCache>, + /// Where the source files this render reads are noted (see [`reads`]): the frame's own, + /// from the footage source and from the buffers the layer cache serves. `None` records + /// nothing for the frame (buffers kept in the layer cache still record theirs). + pub reads: Option<&'a reads::ReadLog>, /// Optional per-layer timing sink (`frame --bench` / perf readouts). pub profile: Option<&'a std::sync::Mutex>>, /// GPU backend used when [`RenderOpts::backend`] asks for it. diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index e84348fdf..6c79ba7bb 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -3,6 +3,7 @@ use super::footage::{FootageSource, is_vector_footage}; use super::frames::{blend_frames, cineon_frame, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; 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, @@ -101,6 +102,7 @@ fn collapsed_instance(ctx: &EvalCtx, layer: &Layer, item: ItemId) -> Option<(Vec } impl<'a> Renderer<'a> { + /// A renderer over `project` and `footage` with the given options. pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { Renderer { project, @@ -108,6 +110,7 @@ impl<'a> Renderer<'a> { expr: None, opts, cache: None, + reads: None, profile: None, accel: None, depth: 0, @@ -129,6 +132,67 @@ impl<'a> Renderer<'a> { self.cache.filter(|_| !self.footage.called_off()) } + /// `read`, a read of this render's footage source, with the files it uses noted in + /// [`Renderer::reads`] (see [`FootageSource::recording`]). + pub(crate) fn read(&self, read: impl FnOnce() -> Option) -> Option { + let Some(log) = self.reads else { return read() }; + let (mut read, mut out) = (Some(read), None); + self.footage.recording(log, &mut || out = read.take().and_then(|f| f())); + out + } + + /// `ctx` noting what it reads (fonts, `sampleImage`) in this render's [`Renderer::reads`]: + /// a context made by an outer render carries that render's log, not the one of the buffer + /// [`Renderer::recorded`] makes. + pub(crate) fn with_reads<'c>(&'c self, ctx: &EvalCtx<'c>) -> EvalCtx<'c> { + EvalCtx { reads: self.reads, ..*ctx } + } + + /// The buffer the layer cache keeps under `key`, with the files it was made of noted in + /// [`Renderer::reads`]. + pub(crate) fn cached(&self, key: Option) -> Option<(Arc, Arc)> { + let (buf, files) = self.cache?.get(key?)?; + if let Some(log) = self.reads { + log.note_all(&files); + } + Some((buf, files)) + } + + /// Keep `buf`, made of `files`, under `key` (see [`Renderer::recorded`]): the buffer the cache + /// keeps (another worker's, if it kept one first), or `buf` when nothing is kept, with the + /// files that buffer was made of. Another worker's files are noted in [`Renderer::reads`] + /// too: its pixels are the ones this render uses (a file that changed between the two reads + /// then has two identities, and the list is incomplete). + pub(crate) fn keep(&self, key: Option, buf: Arc, files: &Arc) -> (Arc, Arc) { + let (Some(c), Some(k)) = (self.cache_to_keep(), key) else { return (buf, files.clone()) }; + let (kept, kept_files) = c.insert(k, buf, files.clone()); + if !Arc::ptr_eq(&kept_files, files) + && let Some(log) = self.reads + { + log.note_all(&kept_files); + } + (kept, kept_files) + } + + /// Run `make`, which makes a buffer to keep under `key` in the layer cache, on this renderer + /// with its reads noted apart: what `make` returns and the files it read (from the footage + /// source, or the files of cached buffers it used), which go to [`Renderer::reads`] too. A + /// cached buffer then reports the files it was made of whichever render uses it. Without a + /// cache or a key, `make` runs on this renderer as it is (no files: nothing is kept). + pub(crate) fn recorded(&self, key: Option, make: impl FnOnce(&Renderer<'_>) -> T) -> (T, Arc) { + if key.is_none() || self.cache.is_none() { + return (make(self), Arc::default()); + } + // (the generation before any read: a file dropped while `make` runs makes it stale) + let log = self.cache.and_then(LayerCache::drop_generation).map_or_else(ReadLog::new, ReadLog::starting_at); + let out = make(&Renderer { reads: Some(&log), ..*self }); + let files = log.into_files(); + if let Some(own) = self.reads { + own.note_all(&files); + } + (out, Arc::new(files)) + } + /// A layer's Quality switch, limited by the precomp layers above it when /// [`RenderOpts::nested_switches`] is on. pub fn quality(&self, layer: &Layer) -> Quality { @@ -143,6 +207,24 @@ impl<'a> Renderer<'a> { } } + /// What this renderer passes down to the layers it draws beyond their own switches + /// ([`RenderOpts::nested_switches`], the switches inherited from the precomp layers above, + /// motion blur on): part of the cache key of a buffer that reads other layers. + pub(crate) fn nested_look(&self) -> u64 { + let inherited = match self.inherited { + None => 0, + Some((q, mb)) => { + let q = match q { + Quality::Best => 0, + Quality::Draft => 1, + Quality::Wireframe => 2, + }; + 1 + q * 2 + u64::from(mb) + } + }; + 0x6e65_7374_0000_0000 | inherited << 2 | u64::from(self.opts.nested_switches) << 1 | u64::from(self.opts.motion_blur) + } + /// A layer's Motion Blur switch, limited by the precomp layers above it. pub(crate) fn layer_motion_blur(&self, layer: &Layer) -> bool { layer.switches.motion_blur && self.inherited.is_none_or(|(_, mb)| mb) @@ -366,8 +448,19 @@ impl<'a> Renderer<'a> { } } + /// The evaluation context of `comp` at `t`, carrying this renderer's expressions, footage and read log. pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { - EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage), property_overrides: &[], media_overrides: &[] } + EvalCtx { + project: self.project, + comp_id, + comp, + time: t, + expr: self.expr, + footage: Some(self.footage), + property_overrides: &[], + media_overrides: &[], + reads: self.reads, + } } /// This renderer with another accelerator (or none): the passes of @@ -760,7 +853,7 @@ impl<'a> Renderer<'a> { // Continuously rasterised vector footage: drawn at the on-screen scale. if layer.switches.collapse && is_vector_footage(f) { let k = self.raster_scale(ctx, layer); - if let Some(img) = self.footage.vector_frame(item, f, k) { + if let Some(img) = self.read(|| self.footage.vector_frame(item, f, k)) { let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k }; if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); @@ -776,7 +869,20 @@ 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)))), + // (the font files go to this render's log, which `ctx` may not carry: see + // [`Renderer::with_reads`]) + LayerSource::Text => { + // An Expression Selector evaluates per character: its results at other characters + // than the first are not in the layer's cache key (an amount reading a footage + // item's size by name, `textIndex == 1 ? 0 : footage("ref.png").width`), so the + // buffer is kept in memory only, never on disk (the frame's key covers them). + if cache::per_character_expressions(layer) + && let Some(log) = self.reads + { + log.mark_unkeyed(); + } + Some(self.authored(text::render(&self.with_reads(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)))), _ => None, } @@ -831,7 +937,7 @@ impl<'a> Renderer<'a> { if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den); let i = field_rate.frame_at(t).max(0); - let img = cineon_frame(self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?, f); + let img = cineon_frame(self.read(|| self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f)))?, f); let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; // Upper field = even lines (0, 2, …). let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; @@ -844,6 +950,8 @@ impl<'a> Renderer<'a> { if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } + /// The footage frame at source time `t` after frame blending: the average of the source frames one comp frame spans, else a blend of the two neighbouring frames, else the plain frame. + /// Log scans are converted first, and blended frames are cached. fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { let mode = frame_blend_mode(ctx, layer); let float = self.pipe.float_curve(f); @@ -855,6 +963,9 @@ impl<'a> Renderer<'a> { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h); + // Every file of a sequence: the key must tell two projects apart whose items + // share an id and a first frame (on disk, no project-wide salt does, #3184). + f.sequence.hash(&mut h); (f.alpha as u8).hash(&mut h); if f.alpha == effectcraft_project::AlphaMode::Premultiplied { f.premul_color.map(f32::to_bits).hash(&mut h); @@ -865,24 +976,22 @@ impl<'a> Renderer<'a> { } h.finish() }); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { + if let Some((b, _)) = self.cached(key) { return Some(Arc::new(b.img.clone())); } // Log scans are converted before they blend: the conversion is not linear. - let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float).map(|a| cineon_frame(a, f)); - let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float).map(|b| cineon_frame(b, f)); + let ((a, b), files) = self.recorded(key, |r| { + let frame = |i| r.read(|| r.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float)).map(|a| cineon_frame(a, f)); + (frame(i), frame(i + 1)) + }); if let (Some(a), Some(b)) = (a, b) { let img = blend_frames(mode, &a, &b, w as f32); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 })); - } + let _kept = self.keep(key, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }), &files); return Some(Arc::new(img)); } } } - self.footage.frame_with(item, f, t, size, float).map(|img| cineon_frame(img, f)) + self.read(|| self.footage.frame_with(item, f, t, size, float)).map(|img| cineon_frame(img, f)) } /// Source → masks, clamped/quantised to the project depth. @@ -902,52 +1011,41 @@ impl<'a> Renderer<'a> { /// Fully processed layer buffer (source → masks → effects → layer styles, flattened). /// Served from the layer cache when the layer's content key matches. pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { - self.layer_buf_keyed(ctx, layer).map(|(b, _)| b) + self.layer_buf_keyed(ctx, layer).map(|(b, _, _)| b) } /// [`Self::layer_buf`] in the blending space (linear with Blend Colors Using 1.0 Gamma). pub fn blend_layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { - let (b, k) = self.layer_buf_keyed(ctx, layer)?; - Some(self.to_blend(b, k)) + let (b, k, files) = self.layer_buf_keyed(ctx, layer)?; + Some(self.to_blend(b, k, &files)) } - /// A buffer converted to the blending space (cached under a key derived from `key`). - pub(crate) fn to_blend(&self, b: Arc, key: Option) -> Arc { + /// A buffer converted to the blending space (cached under a key derived from `key`, with + /// `files`, the source files `b` was made of). + pub(crate) fn to_blend(&self, b: Arc, key: Option, files: &Arc) -> Arc { let Some(c) = self.pipe.to_blend() else { return b }; let key = key.map(|k| cache::derive(k, 0x1ea7)); - if let (Some(cache), Some(k)) = (self.cache, key) - && let Some(x) = cache.get(k) - { + if let Some((x, _)) = self.cached(key) { return x; } let mut nb = (*b).clone(); color::convert(&mut nb.img, &c); - let nb = Arc::new(nb); - // The buffer the cache keeps (another worker's, if it kept one first). - match (self.cache_to_keep(), key) { - (Some(cache), Some(k)) => cache.insert(k, nb), - _ => nb, - } + self.keep(key, Arc::new(nb), files).0 } - fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option)> { + /// The flattened layer buffer, its cache key and the source files it was made of. + fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option, Arc)> { let st = self.styled(ctx, layer, None)?; if st.passes.is_empty() { - return Some((st.body, st.key)); + return Some((st.body, st.key, st.files)); } let key = st.key.map(|k| cache::derive(k, 0xf1a7)); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { - return Some((b, key)); + if let Some((b, files)) = self.cached(key) { + return Some((b, key, files)); } let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten()); - // The buffer the cache keeps (another worker's, if it kept one first). - let flat = match (self.cache_to_keep(), key) { - (Some(c), Some(k)) => c.insert(k, flat), - _ => flat, - }; - Some((flat, key)) + let (flat, files) = self.keep(key, flat, &st.files); + Some((flat, key, files)) } /// A layer's source pixels at the context time, before masks, effects and the transform @@ -983,28 +1081,32 @@ impl<'a> Renderer<'a> { /// Layer pixels after source → masks → effects (no layer styles). pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { - self.content_buf_timed(ctx, layer, None).map(|(b, _)| b) + self.content_buf_timed(ctx, layer, None).map(|(b, _, _)| b) } - pub(crate) fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc, Option)> { + /// [`Self::content_buf`], its cache key and the source files it was made of. + pub(crate) fn content_buf_timed( + &self, + ctx: &EvalCtx, + layer: &Layer, + mut timing: Option<&mut LayerTiming>, + ) -> Option<(Arc, Option, Arc)> { 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); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { + if let Some((b, files)) = self.cached(key) { if let Some(t) = timing.as_deref_mut() { t.cached = true; } - return Some((b, key)); + return Some((b, key, files)); } - let buf = self.masked_source(ctx, layer)?; - let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))); - // The buffer the cache keeps (another worker's, if it kept one first). - let buf = match (self.cache_to_keep(), key) { - (Some(c), Some(k)) => c.insert(k, buf), - _ => buf, - }; - Some((buf, key)) + let (buf, files) = self.recorded(key, |r| { + // (what expressions read while it is made, at shutter times too, goes to its files) + let ctx = &r.with_reads(ctx); + let buf = r.masked_source(ctx, layer)?; + Some(r.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))) + }); + let (buf, files) = self.keep(key, Arc::new(buf?), &files); + Some((buf, key, files)) } /// A layer's input at the context time: source → masks → its first `effects` effects @@ -1014,23 +1116,19 @@ impl<'a> Renderer<'a> { 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); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { + if let Some((b, _)) = self.cached(key) { return Some(b); } - let mut buf = self.masked_source(ctx, layer)?; - let origin = ctx.effect_bounds(layer).1; - buf.rebase(origin); - if effects > 0 { - buf = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment: false, limit: effects, origin }, None); - } - let buf = Arc::new(buf); - // The buffer the cache keeps (another worker's, if it kept one first). - let buf = match (self.cache_to_keep(), key) { - (Some(c), Some(k)) => c.insert(k, buf), - _ => buf, - }; - Some(buf) + let (buf, files) = self.recorded(key, |r| { + let ctx = &r.with_reads(ctx); + let mut buf = r.masked_source(ctx, layer)?; + let origin = ctx.effect_bounds(layer).1; + buf.rebase(origin); + if effects > 0 { + buf = r.effect_space_stack(ctx, layer, buf, EffectStack { adjustment: false, limit: effects, origin }, None); + } + Some(buf) + }); + Some(self.keep(key, Arc::new(buf?), &files).0) } } diff --git a/crates/render/src/reads.rs b/crates/render/src/reads.rs new file mode 100644 index 000000000..ed97e50fd --- /dev/null +++ b/crates/render/src/reads.rs @@ -0,0 +1,348 @@ +//! The source files a render read: which footage files its pixels came from, each with its +//! identity (size and modification time) as it was when it was read. +//! +//! A [`FootageSource`](crate::FootageSource) that knows which files it opens notes them while +//! [`FootageSource::recording`](crate::FootageSource::recording) runs; the renderer collects them +//! in a [`ReadLog`] ([`Renderer::reads`](crate::Renderer::reads)). Buffers in the layer cache keep +//! the files they were made of, so a frame served from memory reports the same files as the +//! render that made it, without touching the disk. +//! +//! The list is what a cache entry on disk has to check before it is used: a handful of files per +//! frame, never every file of every footage item. + +use std::sync::{Arc, Mutex, PoisonError}; + +/// The identity of a file as a render read it (frame and layer entries on disk store it, see +/// [`EntryFiles`](crate::disk_cache::EntryFiles)). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum FileStamp { + /// A file on disk: its size in bytes and its modification time (nanoseconds since the Unix + /// epoch, negative before it; `None` only where the file system has none). + Native { len: u64, modified: Option }, + /// Bytes given from memory instead of the file system (the browser's file table): their + /// identity is the size and content hash a + /// [`VirtualFileId`](crate::disk_cache::VirtualFileId) gives for the path. + Virtual, + /// The file could not be read (missing, not a regular file, no permission). What was made + /// without it changes once it can be read again. + Missing, +} + +impl FileStamp { + /// The identity of a file from metadata the caller already has (of an open file). + pub fn of_metadata(md: &std::fs::Metadata) -> FileStamp { + FileStamp::Native { len: md.len(), modified: md.modified().ok().map(epoch_nanos) } + } + + /// The identity of the file at `path` now: one `metadata` call. `None` when the file system + /// can't describe it (missing, or a path served from memory). + pub fn native(path: &str) -> Option { + std::fs::metadata(path).ok().map(|md| FileStamp::of_metadata(&md)) + } +} + +/// `t` in nanoseconds since the Unix epoch, negative before it: every time a file system can +/// store fits (`i128` nanoseconds span far more than any `SystemTime`), saturating otherwise. +pub fn epoch_nanos(t: std::time::SystemTime) -> i128 { + let nanos = |d: std::time::Duration| i128::try_from(d.as_nanos()).unwrap_or(i128::MAX); + match t.duration_since(std::time::UNIX_EPOCH) { + Ok(after) => nanos(after), + Err(before) => nanos(before.duration()).saturating_neg(), + } +} + +/// One file a render read. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ReadFile { + pub path: Arc, + pub stamp: FileStamp, +} + +impl ReadFile { + /// A file read with the given path and stamp. + pub fn new(path: impl Into>, stamp: FileStamp) -> ReadFile { + ReadFile { path: path.into(), stamp } + } +} + +/// At most this many files are listed per render or cached buffer; more mark the list +/// incomplete (a comp reading more files than this for one frame is not checked file by file). +pub const MAX_READ_FILES: usize = 4096; + +/// The files a render (or one cached buffer) read, each once, sorted by path. +/// +/// Incomplete when it can't say for sure: a footage source that doesn't tell what it reads, a +/// buffer from a store that doesn't keep its files, more than [`MAX_READ_FILES`] files, or one +/// file read with two identities (it changed during the render). A cache entry whose list is +/// incomplete can't be checked against the files, so it must not outlive the session. +/// +/// It also carries where its reads come from, which equality and hashing ignore: the drop +/// generation the earliest of them started at ([`ReadFiles::since`]), and whether a layer cache +/// key can't capture what it was made of ([`ReadFiles::is_unkeyed`]). +#[derive(Clone, Debug, Default)] +pub struct ReadFiles { + files: Vec, + incomplete: bool, + /// The disk cache's drop generation ([`DiskCache::drop_generation`]) when the earliest read + /// behind the list started; `None` when nothing says (a list built by hand). + /// + /// [`DiskCache::drop_generation`]: crate::disk_cache::DiskCache::drop_generation + since: Option, + /// Made of values a layer's cache key doesn't capture (a text Expression Selector evaluates + /// per character): such a buffer is not stored on disk. + unkeyed: bool, +} + +impl PartialEq for ReadFiles { + /// The same files with the same identities (where the reads come from doesn't count). + fn eq(&self, other: &Self) -> bool { + self.files == other.files && self.incomplete == other.incomplete + } +} + +impl Eq for ReadFiles {} + +impl std::hash::Hash for ReadFiles { + /// Hashes what [`PartialEq`] compares. + fn hash(&self, state: &mut H) { + self.files.hash(state); + self.incomplete.hash(state); + } +} + +impl ReadFiles { + /// Note a file read (once per path). + pub fn add(&mut self, file: ReadFile) { + match self.files.binary_search_by(|f| f.path.cmp(&file.path)) { + Ok(i) => { + if self.files.get(i).is_some_and(|f| f.stamp != file.stamp) { + self.incomplete = true; + } + } + Err(i) => { + if self.files.len() >= MAX_READ_FILES { + self.incomplete = true; + } else { + self.files.insert(i, file); + } + } + } + } + + /// Note every file of `other` (whether it is incomplete or unkeyed, and its generation). + pub fn extend(&mut self, other: &ReadFiles) { + self.incomplete |= other.incomplete; + self.unkeyed |= other.unkeyed; + if let Some(g) = other.since { + self.note_since(g); + } + for f in &other.files { + self.add(f.clone()); + } + } + + /// Some files read may be missing from the list (see [`ReadFiles`]). + pub fn mark_incomplete(&mut self) { + self.incomplete = true; + } + + /// Whether the list names every file read. + pub fn is_complete(&self) -> bool { + !self.incomplete + } + + /// The drop generation the earliest read behind the list started at (`None`: unknown). A + /// buffer made of a file dropped after it holds that file as it was before the drop. + pub fn since(&self) -> Option { + self.since + } + + /// A read behind the list started at drop generation `generation` (the earliest one counts). + pub fn note_since(&mut self, generation: u64) { + self.since = Some(self.since.map_or(generation, |s| s.min(generation))); + } + + /// Made of values a layer cache key doesn't capture (see [`ReadFiles::is_unkeyed`]). + pub fn mark_unkeyed(&mut self) { + self.unkeyed = true; + } + + /// Made of values a layer cache key doesn't capture: kept in memory, never stored on disk as + /// a layer buffer. (A frame's key captures them: frame entries are not affected.) + pub fn is_unkeyed(&self) -> bool { + self.unkeyed + } + + /// The files, sorted by path. + pub fn files(&self) -> &[ReadFile] { + &self.files + } + + /// The identity `path` was read with. + pub fn get(&self, path: &str) -> Option { + let i = self.files.binary_search_by(|f| (*f.path).cmp(path)).ok()?; + self.files.get(i).map(|f| f.stamp) + } + + /// The number of files in the list. + pub fn len(&self) -> usize { + self.files.len() + } + + /// Whether the list holds no files. + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + +impl FromIterator for ReadFiles { + /// Collects files into a list, adding each in turn. + fn from_iter>(iter: I) -> Self { + let mut out = ReadFiles::default(); + for f in iter { + out.add(f); + } + out + } +} + +/// Collects the files a render reads, from any thread (the layers of a frame render in +/// parallel). +#[derive(Debug, Default)] +pub struct ReadLog { + files: Mutex, +} + +impl ReadLog { + /// An empty read log. + pub fn new() -> ReadLog { + ReadLog::default() + } + + /// An empty read log for reads that start at drop generation `generation` (see + /// [`ReadFiles::since`]). + pub fn starting_at(generation: u64) -> ReadLog { + let mut files = ReadFiles::default(); + files.note_since(generation); + ReadLog { files: Mutex::new(files) } + } + + /// Locks the file list, recovering from a poisoned lock. + fn lock(&self) -> std::sync::MutexGuard<'_, ReadFiles> { + self.files.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Note a file read. + pub fn note(&self, file: ReadFile) { + self.lock().add(file); + } + + /// Note every file of `files` (and whether it is incomplete). + pub fn note_all(&self, files: &ReadFiles) { + if files.is_empty() && files.is_complete() && files.since.is_none() && !files.unkeyed { + return; + } + self.lock().extend(files); + } + + /// Some files read can't be listed (see [`ReadFiles`]). + pub fn mark_incomplete(&self) { + self.lock().mark_incomplete(); + } + + /// What is read is made of values a layer cache key doesn't capture + /// ([`ReadFiles::is_unkeyed`]). + pub fn mark_unkeyed(&self) { + self.lock().mark_unkeyed(); + } + + /// The files noted so far. + pub fn files(&self) -> ReadFiles { + self.lock().clone() + } + + /// The files noted. + pub fn into_files(self) -> ReadFiles { + self.files.into_inner().unwrap_or_else(PoisonError::into_inner) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A native file with the given length for the tests. + fn file(path: &str, len: u64) -> ReadFile { + ReadFile::new(path, FileStamp::Native { len, modified: Some(7) }) + } + + #[test] + fn files_are_listed_once_sorted_by_path() { + let files: ReadFiles = [file("/b", 1), file("/a", 2), file("/b", 1)].into_iter().collect(); + assert_eq!(files.files().iter().map(|f| &*f.path).collect::>(), ["/a", "/b"]); + assert!(files.is_complete()); + assert_eq!(files.get("/a"), Some(FileStamp::Native { len: 2, modified: Some(7) })); + assert_eq!(files.get("/c"), None); + } + + /// A file read twice with different identities changed during the render: the list can't + /// vouch for what was made of it. + #[test] + fn a_file_read_with_two_identities_makes_the_list_incomplete() { + let files: ReadFiles = [file("/a", 1), file("/a", 2)].into_iter().collect(); + assert_eq!(files.len(), 1); + assert!(!files.is_complete()); + } + + /// Modification times keep their full value: before 1970 (not "no time") and past 2554 + /// (where `u64` nanoseconds end), so two such files never look alike. + #[test] + fn modification_times_are_kept_whole() { + use std::time::{Duration, UNIX_EPOCH}; + let day = Duration::from_secs(86_400); + assert_eq!(epoch_nanos(UNIX_EPOCH), 0); + assert_eq!(epoch_nanos(UNIX_EPOCH - day), -86_400_000_000_000); + let far = 600 * 365 * day; + if let Some(t) = UNIX_EPOCH.checked_add(far) { + assert_eq!(epoch_nanos(t), i128::try_from(far.as_nanos()).unwrap()); + } + let dir = std::env::temp_dir().join(format!("effectcraft-stamp-1969-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.bin"), dir.join("b.bin")); + let mut stamps = vec![]; + for (p, back) in [(&a, day), (&b, 2 * day)] { + std::fs::write(p, b"abc").unwrap(); + let f = std::fs::File::options().write(true).open(p).unwrap(); + if f.set_modified(UNIX_EPOCH - back).is_err() { + // (a file system that can't store times before 1970) + let _ = std::fs::remove_dir_all(&dir); + return; + } + drop(f); + stamps.push(FileStamp::native(&p.to_string_lossy()).unwrap()); + } + assert!(matches!(stamps[0], FileStamp::Native { modified: Some(t), .. } if t < 0), "{:?}", stamps[0]); + assert_ne!(stamps[0], stamps[1], "two files from before 1970 with the same size"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_list_is_bounded() { + let files: ReadFiles = (0..MAX_READ_FILES + 3).map(|i| file(&format!("/f{i:06}"), 1)).collect(); + assert_eq!(files.len(), MAX_READ_FILES); + assert!(!files.is_complete(), "files past the bound make the list incomplete, never silently short"); + } + + #[test] + fn incompleteness_carries_over() { + let mut part = ReadFiles::default(); + part.mark_incomplete(); + let log = ReadLog::new(); + log.note(file("/a", 1)); + log.note_all(&part); + let all = log.into_files(); + assert_eq!(all.len(), 1); + assert!(!all.is_complete()); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index c77fc2d94..80b1aa12d 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -482,6 +482,145 @@ impl crate::eval::ExprHost for ControllerRate { } } +/// An expression that reads a file (as `sampleImage` does, into [`EvalCtx::reads`]) and keeps +/// the value. +/// +/// [`EvalCtx::reads`]: crate::eval::EvalCtx::reads +struct ReadsAFile; + +impl crate::eval::ExprHost for ReadsAFile { + fn eval(&self, ctx: &crate::eval::EvalCtx, _: &effectcraft_project::Layer, _: &effectcraft_project::Property, value: &Value) -> Result { + if let Some(log) = ctx.reads { + log.note(crate::reads::ReadFile::new("/sampled.png", crate::reads::FileStamp::Native { len: 3, modified: Some(1) })); + } + Ok(value.clone()) + } +} + +/// What expressions read while a cached buffer is made (a mask expression, which mask motion +/// blur also runs at shutter times the cache key doesn't see) goes to that buffer's files, not +/// only to the frame's (#3184): a later render served the buffer lists the file too. Content, +/// a Time effect's input and an adjustment layer's footprint. +#[test] +fn what_expressions_read_goes_to_the_files_of_the_buffer_they_make() { + use crate::reads::ReadLog; + use crate::{NoFootage, RenderOpts, Renderer}; + let (mut p, cid, comp) = setup(); + for adjustment in [false, true] { + let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + l.switches.adjustment = adjustment; + let mut next = p.next_id; + let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]); + p.next_id = next; + l.props.sub_mut("masks").unwrap().children.push(m.into()); + l.props.prop_mut("masks/#1/path").unwrap().expr = Some(effectcraft_project::Expression { text: "sampled".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.push(l); + } + let cache = crate::LayerCache::default(); + let c = p.comp(cid).unwrap(); + let (content, adjustment) = (&c.layers[0], &c.layers[1]); + // `make`, rendered once with the expressions (the files go to `log`) and then served from + // the cache to a render without them: the files that render lists. + let files_served = |what: &str, make: &dyn Fn(&Renderer, &crate::eval::EvalCtx) -> bool| { + let log = ReadLog::new(); + let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + r.expr = Some(&ReadsAFile); + r.cache = Some(&cache); + r.reads = Some(&log); + assert!(make(&r, &r.ctx(cid, c, Tick::ZERO)), "{what}"); + assert!(log.files().get("/sampled.png").is_some(), "{what}: the frame lists the file"); + let hits = cache.stats().hits; + let served = ReadLog::new(); + r.expr = None; + r.reads = Some(&served); + assert!(make(&r, &r.ctx(cid, c, Tick::ZERO)), "{what}"); + assert!(cache.stats().hits > hits, "{what}: served from the cache"); + served.into_files() + }; + let files = files_served("content", &|r, ctx| r.content_buf(ctx, content).is_some()); + assert!(files.get("/sampled.png").is_some(), "content: {files:?}"); + let files = files_served("input", &|r, ctx| r.layer_input(ctx, content, 0).is_some()); + assert!(files.get("/sampled.png").is_some(), "input: {files:?}"); + let files = files_served("footprint", &|r, ctx| r.adjustment_footprint(ctx, adjustment).is_some()); + assert!(files.get("/sampled.png").is_some(), "footprint: {files:?}"); +} + +/// A text Expression Selector whose Amount is 0 for the first character and the width of the +/// footage item named `ref.png` for the others (`textIndex == 1 ? 0 : footage("ref.png").width`). +struct WidthOfRef; + +impl crate::eval::ExprHost for WidthOfRef { + fn eval(&self, _: &crate::eval::EvalCtx, _: &effectcraft_project::Layer, _: &effectcraft_project::Property, value: &Value) -> Result { + Ok(value.clone()) + } + fn eval_text_selector( + &self, + ctx: &crate::eval::EvalCtx, + _: &effectcraft_project::Layer, + _: &effectcraft_project::Property, + index: usize, + _: usize, + _: [f64; 3], + ) -> Result<[f64; 3], String> { + let width = ctx.project.items.values().find(|i| i.name == "ref.png").and_then(|i| i.dimensions()).map_or(0.0, |d| d.0 as f64); + Ok([if index == 1 { 0.0 } else { width }; 3]) + } +} + +/// A text layer whose Expression Selector reads a footage item's size for every character but +/// the first: the layer's key holds the first character's amount only, so its buffer is not +/// stored on disk. Reopened over the same disk cache after the footage's width changed (16 to +/// 64), the frame matches one rendered without a cache. +#[test] +fn a_text_expression_selector_is_not_served_from_the_disk_cache() { + use crate::disk_cache::{DiskCache, Kind}; + use crate::{LayerCache, NoFootage, RenderOpts, Renderer}; + let project = |width: u32| { + let (mut p, cid, comp) = setup(); + let f = effectcraft_project::Footage { has_video: true, width, height: 16, ..Default::default() }; + p.add_item("ref.png", Label::None, None, ItemKind::Footage(f)); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HH".into(), size: 60.0, ..Default::default() })); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let mut props = build::text_anim_props(&mut ids, "opacity", false); + for pr in &mut props { + pr.value = Value::Scalar(0.0); + } + let mut animator = build::text_animator(&mut ids, "Animator 1", props); + let sel = build::expression_selector(&mut ids, "Expression Selector 1"); + p.next_id = next; + animator.sub_mut("selectors").unwrap().children.push(sel.into()); + l.props.group_mut("text/animators").unwrap().children.push(animator.into()); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) + }; + let render = |p: &Project, cid: ItemId, cache: Option<&LayerCache>| { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.expr = Some(&WidthOfRef); + r.cache = cache; + r.comp_frame(cid, Tick::ZERO) + }; + let dir = tempfile::tempdir().unwrap(); + let dc = DiskCache::open(dir.path(), 1 << 30).unwrap(); + dc.set_min_layer_ms(0); + // First session: the 16 px wide footage. + let (p16, cid) = project(16); + let cache = LayerCache::default(); + cache.set_disk(Some(dc.clone())); + let first = render(&p16, cid, Some(&cache)); + dc.flush(); + assert!(first.data.iter().any(|px| px[3] > 0.5), "the text renders"); + // Reopened with a fresh memory cache, the footage now 64 px wide. + let (p64, cid) = project(64); + let uncached = render(&p64, cid, None); + assert_ne!(uncached.data, first.data, "the second character's opacity follows the width"); + let reopened = LayerCache::default(); + reopened.set_disk(Some(dc.clone())); + assert_eq!(render(&p64, cid, Some(&reopened)).data, uncached.data, "not the buffer from before the change"); + assert!(dc.keys(Kind::Layer).is_empty(), "the text layer's buffer is not on disk"); +} + /// A Birth Rate expression reading another layer: changing that layer changes the history but /// not the current rate (0) nor this layer's properties, which is all the layer cache key sees. /// CC Particle World layers with expressions therefore aren't layer cached. @@ -820,6 +959,41 @@ fn effects_read_layer_params() { assert!(px[2] > 0.99 && px[0] < 0.01, "{px:?}"); } +/// A cached layer whose effect reads a precomp layer: how the precomp's layers render depends on +/// Switches Affect Nested Comps (the precomp layer's Draft quality passes down) and on the +/// switches the reading layer's own comp inherits, so the key must tell them apart (a buffer +/// made under the other setting, from memory or disk, is stale). +#[test] +fn a_cached_layer_reading_a_precomp_is_keyed_by_the_nested_switches() { + let (mut p, cid, comp) = setup(); + let mut inner = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + // A rotated square off the pixel grid: Draft (nearest) and Best (bilinear) sampling differ. + let mut dot = solid(&mut p, &inner, [1.0, 1.0, 1.0], 40, 40); + dot.props.prop_mut("transform/position").unwrap().value = Value::Vec3([100.3, 50.6, 0.0]); + dot.props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(30.0); + inner.layers.push(dot); + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut pre = build::layer(&mut p, &comp, "Inner", LayerSource::Comp { item: iid }, (200, 100), None); + pre.switches.video = false; + pre.switches.quality = effectcraft_project::Quality::Draft; + let mut top = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + add_effect_200(&mut p, &mut top, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(pre.id.0)))]); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(top); + c.layers.push(pre); + let frame = |nested_switches: bool, cache: Option<&crate::LayerCache>| { + let mut r = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts { nested_switches, ..Default::default() }); + r.cache = cache; + r.comp_frame(cid, Tick::ZERO).data + }; + let (on, off) = (frame(true, None), frame(false, None)); + assert!(on != off, "the precomp layer's Draft quality passes down"); + let cache = crate::LayerCache::default(); + assert!(frame(true, Some(&cache)) == on); + assert!(frame(false, Some(&cache)) == off, "not the buffer made with switches affecting nested comps"); + assert!(frame(true, Some(&cache)) == on, "and back"); +} + /// Records whether the GPU compositor was asked to draw a frame. struct ProbeAccel(std::sync::atomic::AtomicBool); impl crate::Accelerator for ProbeAccel { diff --git a/crates/render/src/tests/collapse.rs b/crates/render/src/tests/collapse.rs index 30d2163c1..4104e0b38 100644 --- a/crates/render/src/tests/collapse.rs +++ b/crates/render/src/tests/collapse.rs @@ -161,8 +161,17 @@ fn referenced_layer_overrides_change_the_owner_cache_key() { let opacity = comp.layer(aid).unwrap().props.prop("transform/opacity").unwrap().uid; let key = |value: f64| { let overrides = [crate::PropertyOverride { layer: aid, prop: opacity, value: Value::Scalar(value), font: false }]; - let ctx = - EvalCtx { project: &p, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &overrides, media_overrides: &[] }; + let ctx = EvalCtx { + project: &p, + comp_id: cid, + comp, + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &overrides, + media_overrides: &[], + reads: None, + }; crate::cache::layer_key(&ctx, b, 1.0, false, false) }; let (opaque, half) = (key(100.0), key(50.0)); diff --git a/crates/render/src/tests/mask_blur.rs b/crates/render/src/tests/mask_blur.rs index 07bc1e95f..5f3ff4e2a 100644 --- a/crates/render/src/tests/mask_blur.rs +++ b/crates/render/src/tests/mask_blur.rs @@ -75,3 +75,32 @@ fn mask_motion_blur_follows_the_render_and_the_comp() { let (p, cid) = project(false); assert!(alpha_at_25(&p, cid, true) < 0.01, "the comp's switch off"); } + +/// Whether mask motion blur may run at all (the render options, Switches Affect Nested Comps with +/// a precomp layer's switch off) is part of the layer cache key, also for a layer whose own +/// switch is off: a buffer made with the gate open must not serve a render with it closed. +#[test] +fn the_mask_motion_blur_gate_keys_the_layer_cache() { + let (mut p, inner) = project(true); + let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + outer.enable_motion_blur = true; + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: inner }, (100, 100), None); + pre.switches.motion_blur = false; + outer.layers.push(pre); + let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); + let half = Tick::from_seconds_f64(0.5 / 30.0); + let cache = crate::LayerCache::default(); + let render = |cid: ItemId, opts: RenderOpts, cached: bool| { + let mut r = Renderer::new(&p, &NoFootage, opts); + r.cache = cached.then_some(&cache); + r.comp_frame(cid, half).data + }; + let on = RenderOpts { motion_blur: true, nested_switches: true, ..Default::default() }; + let off = RenderOpts { nested_switches: false, ..on }; + assert!(render(oid, on, false) != render(oid, off, false), "the precomp layer's switch closes the gate"); + assert!(render(oid, off, true) == render(oid, off, false)); + assert!(render(oid, on, true) == render(oid, on, false), "not the buffer blurred with the gate open"); + let still = RenderOpts { motion_blur: false, ..on }; + assert!(render(inner, on, true) == render(inner, on, false)); + assert!(render(inner, still, true) == render(inner, still, false), "Render Settings ▸ Motion Blur off"); +} diff --git a/crates/render/src/tests/styles.rs b/crates/render/src/tests/styles.rs index 2450fcf64..a9de6fb52 100644 --- a/crates/render/src/tests/styles.rs +++ b/crates/render/src/tests/styles.rs @@ -507,3 +507,34 @@ fn workers_styling_a_layer_at_once_share_its_buffers() { assert!(st.passes.iter().zip(&first.passes).all(|(a, b)| std::sync::Arc::ptr_eq(&a.0, &b.0)), "one set of passes"); } } + +/// A styled layer served from the cache lists the files of the body and passes it was served, +/// not only its content's (#3184): one made of other files (another worker's, or from disk, +/// whose list can't vouch for them) passes that on to the buffers made of it (flattened, in the +/// blending space), which are then not kept on disk as complete. +#[test] +fn a_cached_styled_layer_lists_the_files_of_its_cached_buffers() { + let (p, cid) = scene([1.0; 3], None, |p, c, l| add(p, c, l, "dropShadow")); + let layer = &p.comp(cid).unwrap().layers[0]; + let styled = |cache: &crate::LayerCache| { + let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + r.cache = Some(cache); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + r.styled_layer(&ctx, layer).unwrap() + }; + let first = styled(&crate::LayerCache::default()); + assert!(first.files.is_complete() && !first.passes.is_empty()); + let key = first.key.unwrap(); + // Body and passes kept with a list that can't vouch for them; the content renders again. + let cache = crate::LayerCache::default(); + let mut unsure = crate::reads::ReadFiles::default(); + unsure.mark_incomplete(); + let unsure = std::sync::Arc::new(unsure); + cache.insert(key, first.body.clone(), unsure.clone()); + for (i, (b, _)) in first.passes.iter().enumerate() { + cache.insert(crate::cache::derive(key, i as u64 + 1), b.clone(), unsure.clone()); + } + let hit = styled(&cache); + assert!(std::sync::Arc::ptr_eq(&hit.body, &first.body), "the cached body"); + assert!(!hit.files.is_complete(), "{:?}", hit.files); +} diff --git a/crates/render/src/tests/time.rs b/crates/render/src/tests/time.rs index e9599f70b..715c37ef0 100644 --- a/crates/render/src/tests/time.rs +++ b/crates/render/src/tests/time.rs @@ -143,6 +143,7 @@ fn input_keys_ignore_later_effects() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &comp.layers[0]; (crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2)) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 683da82ee..ed369afa5 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -243,6 +243,13 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: & } "expressionSelector" => { let Some(prop) = sel.get("amount") else { continue }; + // Evaluated per character (`textIndex`): the layer's cache key holds the amount + // for the first character only, so the buffer must not outlive the session. + if prop.has_expression() + && let Some(log) = ctx.reads + { + log.mark_unkeyed(); + } let stat = ctx.v3(layer, sel, "amount", [100.0; 3]); Box::new(move |i, n, prev| { let pv = prev.map(|x| x * 100.0); @@ -638,11 +645,73 @@ fn align_index(j: Justify) -> u8 { } } +/// Note the font files `lay` is drawn with in [`EvalCtx::reads`] (#3184), each with its identity +/// as its bytes were read, so a cache entry of the text is checked against them. What the +/// files can't vouch for makes the list incomplete: a family that is not installed (installing +/// it changes the text), a character drawn with a fallback face or with none (another installed +/// font could take it), a font from memory. +fn note_fonts(ctx: &EvalCtx, lay: &TextLayout) { + note_layout_fonts(ctx, &lay.styles, &lay.layout); +} + +/// A character put in by Character Offset or Character Value (`ch`, in style `st`), outside a +/// relayout: its outline and advance, with the font it is drawn with noted as [`note_fonts`] does +/// (the run's face may not have it: a fallback face then draws it). +fn substituted_glyph(ctx: &EvalCtx, st: &effectcraft_keyframe::CharStyle, ch: char) -> (BezPath, f64) { + if ctx.reads.is_some() { + // (the layout `char_glyph_style` makes, served from the layout cache there) + let lay = effectcraft_text::layout_text(&ch.to_string(), &effectcraft_text::text_style(st), &effectcraft_text::ParagraphStyle::default()); + note_layout_fonts(ctx, std::slice::from_ref(st), &lay); + } + char_glyph_style(st, ch) +} + +/// [`note_fonts`] for `layout`, whose glyphs' runs index `styles`. +fn note_layout_fonts(ctx: &EvalCtx, styles: &[effectcraft_keyframe::CharStyle], layout: &effectcraft_text::Layout) { + use crate::reads::{FileStamp, ReadFile}; + use effectcraft_text::fonts; + let Some(log) = ctx.reads else { return }; + let resolved: Vec = styles.iter().map(|s| fonts::resolve(&s.font, &s.style)).collect(); + if resolved.iter().any(|r| r.missing) { + log.mark_incomplete(); + } + // Each run's face, glyphs or not: an empty paragraph's face sets its line's metrics (in a + // box, the first baseline that places every later line). + let mut faces: Vec = Vec::new(); + for r in &resolved { + if !faces.contains(&r.face) { + faces.push(r.face); + } + } + for g in &layout.glyphs { + if g.id == 0 || resolved.get(g.run).is_none_or(|r| r.face != g.face) { + log.mark_incomplete(); + } + if !faces.contains(&g.face) { + faces.push(g.face); + } + } + for id in faces { + let face = fonts::face(id); + match &face.info.data { + fonts::FaceData::Static(_) => {} + fonts::FaceData::Shared(_) => log.mark_incomplete(), + fonts::FaceData::File(_) => { + if let Some((path, meta)) = face.file_read() { + let stamp = meta.map_or(FileStamp::Missing, FileStamp::of_metadata); + log.note(ReadFile::new(path.to_string_lossy().as_ref(), stamp)); + } + } + } + } +} + /// Lay out and animate a text layer. pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let doc = source_text(ctx, layer)?; let text = layer.props.sub("text")?; let lay = layout_doc(&doc); + note_fonts(ctx, &lay); let xfs = char_transforms(ctx, layer, text, &lay); let three = per_char_3d(ctx, layer); let more = text.sub("moreOptions"); @@ -665,6 +734,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { // Same character count: the style runs still line up. let d2 = TextDoc { text: chars.into_iter().collect(), ..doc.clone() }; let l2 = layout_doc(&d2); + note_fonts(ctx, &l2); let by_char: std::collections::HashMap = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect(); let x2: Vec = l2.glyphs.iter().map(|g| by_char.get(&g.char_index).copied().unwrap_or_default()).collect(); subs = vec![None; l2.glyphs.len()]; @@ -789,7 +859,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { // Outline (substituted characters aligned in the original slot). let (outline, adv) = match subs[gi] { Some(c) => { - let (p, a) = char_glyph_style(st, c); + let (p, a) = substituted_glyph(ctx, st, c); let dx = match x.char_align { 0 => 0.0, 2 => g.advance - a, diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 0280115a5..9b52d3098 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -715,3 +715,90 @@ fn range_bar_drag_values() { assert_eq!(range_value_of(3.6 / 7.0, 0.0, true, 7), 4.0); assert_eq!(range_value_of(3.6 / 7.0, 2.0 / 7.0, true, 7), 2.0, "less a 2-character Offset"); } + +/// Text names the font files it is drawn with (#3184), with their identity as they were read, so +/// a cache entry of it is checked against them; what the files can't vouch for (a family that +/// isn't installed) leaves the list incomplete. +#[test] +fn text_notes_the_font_files_it_draws_with() { + use crate::reads::{FileStamp, ReadLog}; + use effectcraft_text::fonts::{self, FaceData, FaceInfo, FontSource}; + struct OneFile(std::path::PathBuf); + impl FontSource for OneFile { + fn faces(&self) -> Vec { + vec![FaceInfo { + family: "Reads Test Sans".into(), + style: "Regular".into(), + weight: 400, + italic: false, + index: 0, + data: FaceData::File(self.0.clone()), + origin: "system", + native_family: None, + full_name: "Reads Test Sans Regular".into(), + postscript: None, + variable: false, + instances: vec![], + }] + } + } + // (a fresh directory, removed when the test ends, panicking or not) + let dir = tempfile::tempdir().unwrap(); + let font = dir.path().join("reads-test-sans.ttf"); + std::fs::write(&font, fonts::INTER_REGULAR).unwrap(); + fonts::add_source(&OneFile(font.clone())); + let stamp = FileStamp::native(&font.to_string_lossy()).unwrap(); + // (`value`: a Character Value animator puts that character in for every one) + let files_with = |family: &str, value: Option| { + let (mut p, cid, comp) = setup(200, 80); + let mut l = + text_layer(&mut p, &comp, TextDoc { text: "Hi there\rtwo".into(), font: family.into(), style: "Regular".into(), size: 30.0, ..Default::default() }); + if let Some(c) = value { + animator(&mut p, &mut l, &["characterValue"], &["range"]); + set(&mut l, "text/animators/#1/properties/characterValue", Value::Scalar(c as u32 as f64)); + } + p.comp_mut(cid).unwrap().layers.push(l); + let log = ReadLog::new(); + let c = EvalCtx { reads: Some(&log), ..ctx(&p, cid, 0.0) }; + assert!(text_geom(&c, &p.comp(cid).unwrap().layers[0]).is_some()); + log.into_files() + }; + let files_of = |family: &str| files_with(family, None); + let files = files_of("Reads Test Sans"); + assert!(files.is_complete(), "{files:?}"); + assert_eq!(files.files().len(), 1, "{files:?}"); + assert_eq!(files.get(&font.to_string_lossy()), Some(stamp)); + // The bundled family reads no file. + let files = files_of(effectcraft_text::fonts::DEFAULT_FAMILY); + assert!(files.is_complete() && files.is_empty(), "{files:?}"); + // A family that isn't installed falls back: installing it would change the text. + assert!(!files_of("No Such Family 3184").is_complete()); + // A substituted character the face has is drawn with it: the same one file. + let files = files_with("Reads Test Sans", Some('B')); + assert!(files.is_complete(), "{files:?}"); + assert_eq!(files.get(&font.to_string_lossy()), Some(stamp)); + // One it lacks (Inter has no CJK) is drawn with a fallback face, or none: another installed + // font could take it, so the list can't vouch for the text. + assert!(!files_with("Reads Test Sans", Some('\u{4e2d}')).is_complete()); + // An empty first paragraph in the face draws no glyph, but in a box its face sets the first + // baseline that places the lines after it: the file is read all the same. + let (mut p, cid, comp) = setup(200, 80); + let base = TextDoc { + text: "\rHi".into(), + font: fonts::DEFAULT_FAMILY.into(), + style: "Regular".into(), + size: 30.0, + box_size: Some([180.0, 60.0]), + ..Default::default() + }; + let first = effectcraft_keyframe::CharStyle { font: "Reads Test Sans".into(), ..base.base_style() }; + let runs = vec![effectcraft_keyframe::StyleRun { len: 1, style: first }, effectcraft_keyframe::StyleRun { len: 2, style: base.base_style() }]; + let l = text_layer(&mut p, &comp, TextDoc { runs, ..base }); + p.comp_mut(cid).unwrap().layers.push(l); + let log = ReadLog::new(); + let c = EvalCtx { reads: Some(&log), ..ctx(&p, cid, 0.0) }; + assert!(text_geom(&c, &p.comp(cid).unwrap().layers[0]).is_some()); + let files = log.into_files(); + assert!(files.is_complete(), "{files:?}"); + assert_eq!(files.get(&font.to_string_lossy()), Some(stamp), "{files:?}"); +} diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 22507069e..6d2b071d5 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -477,6 +477,7 @@ fn clip_start(ctx: &EvalCtx, layer: &Layer, prop: &Property, lt: Tick) -> Tick { start } +/// Adds a model layer to the Advanced 3D scene with its material options, opacity and world transform. fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { let lin = b.s.linear_io; let (opts, prim_mat) = model_options(ctx, layer, lin); @@ -487,7 +488,7 @@ fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { LayerSource::Model { item } => { let Some(it) = r.project.item(*item) else { return }; let ItemKind::Footage(f) = &it.kind else { return }; - let Some(m) = r.footage.model(*item, f) else { return }; + let Some(m) = r.read(|| r.footage.model(*item, f)) else { return }; let unit = geo.map_or(1.0, |g| ctx.f(layer, g, "unitScale", 1.0)); let clip = geo.map_or(0, |g| ctx.e(layer, g, "animation")) as usize; let clip = clip.checked_sub(1).filter(|c| *c < m.animations.len()); @@ -651,12 +652,13 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude out } +/// Adds an extruded layer to the Advanced 3D scene with its card material, opacity and world transform. fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams, b: &mut Builder) { let base = card_material(ctx, layer); let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); let world = b.outer * ctx.world_matrix(layer); let lin = b.s.linear_io; - for (mesh, c, local) in extruded_meshes(ctx, layer, p) { + for (mesh, c, local) in extruded_meshes(&r.with_reads(ctx), layer, p) { let l = |v: f32| if lin { v } else { srgb_to_linear(v) }; let m = b.material(Material { base: [l(c[0]), l(c[1]), l(c[2]), c[3]], @@ -694,13 +696,14 @@ fn card(b: &mut Builder, buf: &Buf, world: Mat4, mut mat: Material) { b.emit(&p, m, &|_| world); } +/// Adds a flat card layer to the Advanced 3D scene with its material, opacity and world transform. fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { let mut mat = card_material(ctx, layer); mat.opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); let world = b.outer * ctx.world_matrix(layer); if crate::text::per_char_3d(ctx, layer) { - for (buf, m) in crate::text::per_char_planes(ctx, layer, r.opts.scale) { - let buf = r.to_blend(Arc::new(buf), None); + for (buf, m) in crate::text::per_char_planes(&r.with_reads(ctx), layer, r.opts.scale) { + let buf = r.to_blend(Arc::new(buf), None, &Arc::default()); card(b, &buf, world * m, mat); } return; diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index b2612de98..88e805882 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -822,6 +822,7 @@ fn extruded_text_renders_as_a_mesh() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); @@ -870,6 +871,7 @@ fn shape_surface_items_colour_the_extruded_surfaces() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); @@ -915,6 +917,7 @@ fn text_animators_colour_the_extruded_surfaces() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); @@ -1122,6 +1125,7 @@ fn extruded_strokes_render_as_bevelled_meshes() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index a5a7c170e..ff08eb05f 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -203,9 +203,11 @@ pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, ord /// Prepare a layer as planes: one per character for per-character 3D text, else one. pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { if crate::text::per_char_3d(ctx, layer) { - return crate::text::per_char_planes(ctx, layer, r.opts.scale) + return crate::text::per_char_planes(&r.with_reads(ctx), layer, r.opts.scale) .into_iter() - .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf: r.to_blend(Arc::new(buf), None), local: m })) + .filter_map(|(buf, m)| { + prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf: r.to_blend(Arc::new(buf), None, &Arc::default()), local: m }) + }) .collect(); } prepare(r, ctx, layer, order, out, in_run).into_iter().collect() diff --git a/crates/script/src/tests/ui_more.rs b/crates/script/src/tests/ui_more.rs index 483d686ef..c15aee0ae 100644 --- a/crates/script/src/tests/ui_more.rs +++ b/crates/script/src/tests/ui_more.rs @@ -231,6 +231,7 @@ fn position_now(s: &Session, layer: u64) -> [f64; 2] { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components(); [v[0], v[1]] diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 5388629cf..d199bf0ff 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -158,11 +158,24 @@ fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo { } } +/// A font file's bytes and its metadata from the same open file (so the metadata describes the +/// bytes, not a later version of the file). +fn read_with_metadata(path: &std::path::Path) -> std::io::Result<(Vec, std::fs::Metadata)> { + use std::io::Read; + let mut f = std::fs::File::open(path)?; + let meta = f.metadata()?; + let mut bytes = Vec::new(); + f.read_to_end(&mut bytes)?; + Ok((bytes, meta)) +} + /// A loaded (or loadable) face. pub struct Face { pub id: FaceId, pub info: FaceInfo, bytes: OnceLock>, + /// The file's metadata as its bytes were read (a [`FaceData::File`] face, once loaded). + file_meta: OnceLock, shaper: OnceLock, } @@ -195,16 +208,29 @@ pub struct VMetrics { } impl Face { + /// The font file bytes, loaded once on first use, or `None` when they cannot be read. fn bytes(&self) -> Option<&[u8]> { self.bytes .get_or_init(|| match &self.info.data { FaceData::Static(b) => Some(FaceBytes::Static(b)), FaceData::Shared(b) => Some(FaceBytes::Shared(b.clone())), - FaceData::File(p) => std::fs::read(p).ok().map(|v| FaceBytes::Shared(Arc::new(v))), + FaceData::File(p) => { + let (bytes, meta) = read_with_metadata(p).ok()?; + let _ = self.file_meta.set(meta); + Some(FaceBytes::Shared(Arc::new(bytes))) + } }) .as_ref() .map(FaceBytes::get) } + /// The file a [`FaceData::File`] face is drawn from, with its metadata as its bytes were read + /// (`None` when it could not be read): what text drawn with the face was made of. Loads the + /// face. `None` for bundled faces and faces from memory. + pub fn file_read(&self) -> Option<(&std::path::Path, Option<&std::fs::Metadata>)> { + let FaceData::File(p) = &self.info.data else { return None }; + let _ = self.bytes(); + Some((p, self.file_meta.get())) + } /// The parsed font (None if the file is missing or unreadable). pub fn font(&self) -> Option> { FontRef::from_index(self.bytes()?, self.info.index).ok() @@ -314,7 +340,7 @@ fn bundled_default_face() -> Arc { variable: false, instances: Vec::new(), }); - Arc::new(Face { id: 0, info, bytes: OnceLock::new(), shaper: OnceLock::new() }) + Arc::new(Face { id: 0, info, bytes: OnceLock::new(), file_meta: OnceLock::new(), shaper: OnceLock::new() }) } fn db() -> &'static RwLock { @@ -331,9 +357,10 @@ fn db() -> &'static RwLock { }) } +/// Adds a face to the database and returns its id. fn push(db: &mut Db, info: FaceInfo) -> FaceId { let id = db.faces.len(); - db.faces.push(Arc::new(Face { id, info, bytes: OnceLock::new(), shaper: OnceLock::new() })); + db.faces.push(Arc::new(Face { id, info, bytes: OnceLock::new(), file_meta: OnceLock::new(), shaper: OnceLock::new() })); id } diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index e77f07840..8ebea08fc 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -170,10 +170,6 @@ impl EffectcraftApp { self.session.poll_jobs(); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } - // The disk cache's footage salt, taken off the UI thread after footage changed. - if self.session.poll_disk_salt() { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } // Analyses running in a worker (the web app). if !self.session.offloaded.is_empty() { self.session.poll_offload(); diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 001790116..968b2d791 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -15,9 +15,12 @@ //! egui frames, within a time budget. //! //! With the disk cache on (Settings ▸ Media & Disk Cache), every CPU-rendered frame is also -//! written to disk under a content key (the comp's content hash, frame, scale, view and render -//! options), and a job first looks there: frames survive restarts and project reopenings. The -//! timeline shows disk-only frames as a blue cache bar. +//! written to disk under a content key (the comp's content hash from the project in memory, +//! frame, scale, view and render options) with the source files it read, and a job first looks +//! there: frames survive restarts and project reopenings. An entry is used only while the files +//! it lists are unchanged, checked by the frame worker when it reads the entry (a frame whose +//! files can't all be named is shown but not stored). The timeline shows disk-only frames as a +//! blue cache bar (present entries; their files are checked only when a frame is used). //! //! With Mercury GPU Acceleration (a [`Gpu`] on egui-wgpu's device and the project's renderer set //! to the GPU) frames stay on the GPU: the compositor writes an RGBA8 texture that the viewer @@ -29,11 +32,12 @@ //! cache keeps them as half-float entries. use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use effectcraft_engine::project::{Comp, Heard, ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; +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::time::Tick; use effectcraft_gpu::{DisplayFrame, FrameError, Gpu}; @@ -156,8 +160,41 @@ impl Playhead { } } +/// A frame in the RAM preview and the source files it was made of (`None`: unknown, a frame +/// rendered elsewhere or kept on the GPU), so it can be written to the disk cache later +/// ([`Frames::persist_missing`]). +struct Cached { + img: FrameImage, + /// (the browser's frames are stored by its frame workers, not from here) + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + files: Option>, + /// The disk cache its render started with and that cache's drop generation then: the frame + /// is written to that cache only, and only while none of its files was dropped since + /// (`None`: rendered without a disk cache, or elsewhere; never written from here). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + since: Option, +} + +/// A disk cache and its drop generation ([`DiskCache::drop_generation`]) when a render started. +#[derive(Clone)] +struct DiskSince(std::sync::Weak, u64); + +impl DiskSince { + /// A marker for the drop generation of `dc` at the time of a render. + fn of(dc: &Arc, generation: u64) -> DiskSince { + DiskSince(Arc::downgrade(dc), generation) + } + + /// The generation, when `dc` is the cache it was taken from (the weak reference keeps that + /// cache's address from being reused by another). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + fn for_cache(&self, dc: &Arc) -> Option { + std::ptr::eq(self.0.as_ptr(), Arc::as_ptr(dc)).then_some(self.1) + } +} + struct Cache { - map: HashMap, + map: HashMap, order: VecDeque, bytes: usize, /// Bytes of GPU frames (also counted in `bytes`). @@ -174,10 +211,17 @@ struct Cache { } impl Cache { + /// A frame without its source files (tests). + #[cfg(test)] fn insert(&mut self, k: FrameKey, img: FrameImage) { + self.insert_with(k, img, None, None); + } + /// [`Cache::insert`] with the source files the frame was made of and the disk cache's drop + /// generation when its render started. + fn insert_with(&mut self, k: FrameKey, img: FrameImage, files: Option>, since: Option) { let (sz, gpu) = (img.bytes(), img.is_gpu()); - if let Some(old) = self.map.insert(k, img) { - self.forget(&old); + if let Some(old) = self.map.insert(k, Cached { img, files, since }) { + self.forget(&old.img); } else { self.order.push_back(k); } @@ -215,7 +259,7 @@ impl Cache { /// than the one on screen first (a zoom or resolution change). fn evict_gpu(&mut self) { while self.gpu_bytes > self.gpu_budget { - let is_gpu = |k: &FrameKey| self.map.get(k).is_some_and(FrameImage::is_gpu); + let is_gpu = |k: &FrameKey| self.map.get(k).is_some_and(|c| c.img.is_gpu()); let other_scale = self.order.iter().position(|k| Some(k.scale) != self.scale && is_gpu(k)); let Some(i) = other_scale.or_else(|| self.order.iter().position(is_gpu)) else { break }; if let Some(old) = self.order.remove(i) { @@ -251,9 +295,10 @@ impl Cache { self.gpu_bytes = self.gpu_bytes.saturating_sub(img.bytes()); } } + /// Removes the entry for `k` and releases its image. fn remove(&mut self, k: &FrameKey) { if let Some(i) = self.map.remove(k) { - self.forget(&i); + self.forget(&i.img); } } /// Shown again: last in line for eviction. @@ -316,10 +361,10 @@ pub struct RenderSource { /// ([`Frames::identity`]). type Identity = (u64, Option>); -/// Content keys of (project revision, comp, virtual-file generation). The generation is one -/// counter bumped when a browser file is replaced, so a lookup stays a map read. Hashing the -/// new bytes happens only when that generation misses. -type ContentKeys = Arc>>; +/// Disk-cache content keys ([`disk_cache::comp_content_key`]) of (project revision, comp). They +/// come from the project in memory alone: the files a frame read are checked by its entry +/// when it is read (#3184). +type ContentKeys = Arc>>; #[cfg(not(target_arch = "wasm32"))] #[derive(Default)] @@ -328,16 +373,17 @@ struct ContentKeyJobs { pending: VecDeque<(Arc, u64, u64)>, } -fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64, epoch: u64, virtual_id: Option>) -> u128 { - if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp, epoch)).copied()) { +/// The content key of `comp` at `revision`, memoised per revision. +fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) -> u128 { + if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp)).copied()) { return k; } - let k = disk_cache::comp_content_key_with(project, ItemId(comp), virtual_id); + let k = disk_cache::comp_content_key(project, ItemId(comp)); if let Ok(mut m) = keys.lock() { if m.len() > 256 { m.clear(); } - m.insert((revision, comp, epoch), k); + m.insert((revision, comp), k); } k } @@ -388,7 +434,8 @@ fn comp_content_with<'a>( let Some(ItemKind::Comp(c)) = item(id).map(|i| &i.kind) else { continue }; let c = version(id, c); for l in &c.layers { - if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source { + // (a 3D model's item too) + if let Some(item) = l.source.item() { stack.push(item); } expressions = expressions || (everything && has_expression(&l.props)); @@ -423,18 +470,30 @@ pub fn opts_hash(o: &RenderOpts) -> u64 { } /// Disk key of a viewer frame (render options included: region of interest, draft…). -fn frame_disk_key( - keys: &ContentKeys, - project: &Project, - key: &FrameKey, - opts_hash: u64, - epoch: u64, - virtual_id: Option>, -) -> u128 { - let content = content_key(keys, project, key.revision, key.comp, epoch, virtual_id); +fn frame_disk_key(keys: &ContentKeys, project: &Project, key: &FrameKey, opts_hash: u64) -> u128 { + let content = content_key(keys, project, key.revision, key.comp); disk_cache::frame_key(content, key.frame, key.scale, key.view ^ opts_hash) } +/// Queue `img` for the disk cache under `dk` with the source files it was made of. `false` when +/// refused: a GPU frame (no CPU pixels), files that can't be checked when the entry is read +/// (an incomplete list, #3184: the frame is shown, not stored), a full queue, a file dropped +/// (File ▸ Reload Footage) after `since`, the drop generation its render started at; no room on +/// the disk (checked first: the conversion and compression would be wasted). +fn put_disk_frame(dc: &DiskCache, dk: u128, img: &FrameImage, files: &ReadFiles, virtual_id: Option>, since: u64) -> bool { + if !files.is_complete() || !dc.accepts_writes() { + 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) + } + FrameImage::CpuF16(f) => dc.put_frame_f16(dk, f.width, f.height, f.bits(), files, virtual_id, since), + FrameImage::Gpu(_) => false, + } +} + /// A frame for a [`RemoteFrames`] renderer. pub struct RemoteJob { pub project: Arc, @@ -512,9 +571,8 @@ pub struct Frames { /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, content_keys: ContentKeys, - /// Bumped when a virtual file is replaced. Lookups key on it instead of statting footage. - virtual_epoch: Arc, - /// Size and content hash for paths with no native metadata. Called only on a cache miss. + /// Size and content hash for paths with no native metadata (the browser's file table): to + /// store and check the files of disk entries. virtual_id: Option Option<(u64, u64)> + Send + Sync>>, #[cfg(not(target_arch = "wasm32"))] content_key_jobs: Arc>, @@ -602,7 +660,6 @@ impl Default for Frames { ctx: None, last_ms: Arc::new(Mutex::new(0.0)), content_keys: Arc::default(), - virtual_epoch: Arc::new(AtomicU64::new(0)), virtual_id: None, #[cfg(not(target_arch = "wasm32"))] content_key_jobs: Arc::default(), @@ -754,7 +811,7 @@ impl Frames { pub fn retire_gpu(&self) { self.gpu_retired.store(true, Ordering::Release); if let Ok(mut cache) = self.cache.lock() { - let keys: Vec<_> = cache.map.iter().filter_map(|(key, image)| image.is_gpu().then_some(*key)).collect(); + let keys: Vec<_> = cache.map.iter().filter_map(|(key, c)| c.img.is_gpu().then_some(*key)).collect(); for key in keys { cache.remove(&key); } @@ -789,7 +846,7 @@ impl Frames { /// The cached frame, which becomes the most recently used. pub fn get(&self, k: &FrameKey) -> Option { let mut c = self.cache.lock().ok()?; - let img = c.map.get(k).cloned()?; + let img = c.map.get(k).map(|c| c.img.clone())?; c.touch(k); Some(img) } @@ -825,7 +882,7 @@ impl Frames { }) .max_by_key(|k| k.revision) .copied()?; - let img = c.map.get(&newest).cloned()?; + let img = c.map.get(&newest).map(|c| c.img.clone())?; c.touch(&newest); Some((newest, img)) } @@ -968,7 +1025,6 @@ impl Frames { if src.disk.is_none() && remote.is_none() { return vec![]; } - let epoch = self.virtual_epoch.load(Ordering::Relaxed); #[cfg(not(target_arch = "wasm32"))] { self.note_disk_series(base.comp, base.scale, base.view ^ opts_hash(opts)); @@ -976,7 +1032,7 @@ impl Frames { } #[cfg(not(target_arch = "wasm32"))] let content = { - let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp, epoch)).copied(); + let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied(); let Some(content) = known else { self.request_content_key(src.project.clone(), base.revision, base.comp); return vec![]; @@ -984,7 +1040,7 @@ impl Frames { content }; #[cfg(target_arch = "wasm32")] - let content = content_key(&self.content_keys, &src.project, base.revision, base.comp, epoch, self.virtual_id.as_deref()); + let content = content_key(&self.content_keys, &src.project, base.revision, base.comp); let ram: HashSet = self.cached_frames(base).into_iter().collect(); let oh = opts_hash(opts); (start..end).filter(|f| !ram.contains(f)).filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))).collect() @@ -995,20 +1051,27 @@ impl Frames { /// are converted on the frame pool, each queued once. `true` while there may be more to /// write (a content key being computed, frames being converted): call again soon, since a /// refused write is only retried by a later call. Disk keys hash `opts` the way the worker - /// writes them (`base.opts` also carries the Roto Brush model stamp). + /// writes them (`base.opts` also carries the Roto Brush model stamp). Only frames that know + /// every source file they were made of are written (an entry is checked against them). #[cfg(not(target_arch = "wasm32"))] pub fn persist_missing(&self, src: &RenderSource, base: &FrameKey, opts: &RenderOpts) -> bool { let Some(dc) = src.disk.clone() else { return false }; let oh = opts_hash(opts); self.note_disk_series(base.comp, base.scale, base.view ^ oh); self.carry_disk(src); - let epoch = self.virtual_epoch.load(Ordering::Relaxed); - let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp, epoch)).copied(); + // Free disk space leaves the cache (next to) no room: nothing would be stored, so nothing + // is converted (a refusal doesn't count as a try, so every idle pass would convert them + // all again). A later idle pass writes them once there is room (`prefs_live::memory_watch` + // keeps frames coming meanwhile). + if !dc.accepts_writes() { + return false; + } + let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied(); let Some(content) = known else { self.request_content_key(src.project.clone(), base.revision, base.comp); return true; }; - let (missing, converting): (Vec<(u128, FrameImage)>, bool) = { + let (missing, converting): (Vec<(u128, FrameImage, Arc, u64)>, bool) = { let (Ok(c), Ok(mut queued), Ok(mut written)) = (self.cache.lock(), self.persisted.lock(), self.written.lock()) else { return false }; if queued.len() > 100_000 { queued.clear(); @@ -1021,14 +1084,17 @@ impl Frames { .map .iter() .filter(|(k, _)| k.same_series(base)) - .filter_map(|(k, img)| { - // (GPU frames have no CPU pixels to write) - if img.is_gpu() { - return None; - } + .filter_map(|(k, c)| { + // (GPU frames have no CPU pixels to write; a frame that can't name its files + // can't be checked when read) + let files = c.files.as_ref().filter(|f| f.is_complete() && !c.img.is_gpu())?; + // (only with the generation its render started at, for this cache: a frame + // made of a file Reload Footage dropped since holds the pixels from before) + let since = c.since.as_ref().and_then(|s| s.for_cache(&dc)).filter(|s| !dc.dropped_since(files, *s))?; let dk = disk_cache::frame_key(content, k.frame, k.scale, k.view ^ oh); let tries = written.1.get(&dk).copied().unwrap_or(0); - (tries < 2 && !dc.contains(disk_cache::Kind::Frame, dk) && !dc.frame_pending(dk) && queued.insert(dk)).then(|| (dk, img.clone())) + (tries < 2 && !dc.contains(disk_cache::Kind::Frame, dk) && !dc.frame_pending(dk) && queued.insert(dk)) + .then(|| (dk, c.img.clone(), files.clone(), since)) }) .collect(); (missing, !queued.is_empty()) @@ -1036,19 +1102,12 @@ impl Frames { if missing.is_empty() { return converting; } - let (persisted, written) = (self.persisted.clone(), self.written.clone()); + let (persisted, written, virtual_id) = (self.persisted.clone(), self.written.clone(), self.virtual_id.clone()); let work = move || { - for (dk, img) in missing { + for (dk, img, files, since) in missing { // (accepted: the disk cache tracks it until written, and it counts as a try; // refused by a full queue: retried, without counting) - let accepted = 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) - } - FrameImage::CpuF16(f) => dc.put_frame_f16(dk, f.width, f.height, f.bits()), - FrameImage::Gpu(_) => false, - }; + let accepted = put_disk_frame(&dc, dk, &img, &files, virtual_id.as_deref(), since); if accepted && let Ok(mut w) = written.lock() { let n = w.1.entry(dk).or_default(); *n = n.saturating_add(1); @@ -1091,7 +1150,6 @@ impl Frames { return; } let (carries, scheduled, series, ctx) = (self.disk_carries.clone(), self.carry_scheduled.clone(), self.disk_series.clone(), self.ctx.clone()); - let virtual_id = self.virtual_id.clone(); let work = move || { let mut linked = false; loop { @@ -1111,7 +1169,7 @@ impl Frames { let Some(series) = series.lock().ok().and_then(|m| m.get(&c.comp).cloned()) else { return false }; let comp = ItemId(c.comp); let n = c.to.comp(comp).map_or(0, |k| k.frame_rate.frame_at(k.duration)).clamp(0, 100_000); - let key = |p: &Project| disk_cache::comp_content_key_with(p, comp, virtual_id.as_deref()); + let key = |p: &Project| disk_cache::comp_content_key(p, comp); let (a, b) = (key(&c.from), key(&c.to)); if a == b { return false; @@ -1137,6 +1195,7 @@ impl Frames { } } + /// Queues a background hash of `comp` at `revision`, coalescing rapid edits so only a bounded number of snapshots are kept. #[cfg(not(target_arch = "wasm32"))] fn request_content_key(&self, project: Arc, revision: u64, comp: u64) { let mut jobs = self.content_key_jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1153,7 +1212,6 @@ impl Frames { jobs.running = true; drop(jobs); let (jobs, keys, ctx) = (self.content_key_jobs.clone(), self.content_keys.clone(), self.ctx.clone()); - let (epoch, id) = (self.virtual_epoch.clone(), self.virtual_id.clone()); let work = move || loop { let next = { let mut jobs = jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1164,9 +1222,7 @@ impl Frames { next }; let Some((project, revision, comp)) = next else { return }; - let epoch = epoch.load(Ordering::Relaxed); - let id = id.clone(); - if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp, epoch, id.as_deref()))).is_err() { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp))).is_err() { log::error!("could not compute a preview disk-cache key; the cache indicator will retry"); } if let Some(ctx) = &ctx { @@ -1192,6 +1248,7 @@ impl Frames { self.cache.lock().map(|c| c.budget).unwrap_or(0) } + /// Drops every cached frame and purges the remote server, if there is one. pub fn clear(&self) { if let Some(r) = &self.remote { r.purge(); @@ -1205,9 +1262,8 @@ impl Frames { c.gpu_pressure = None; c.gpu_adjusted = None; } - // Disk keys hash the footage files' size and time, but are remembered per revision; a - // file reloaded from disk without a new revision (a re-rendered image sequence) must be - // keyed again, or the disk cache serves the frames from before. + // (the disk keys of a revision don't depend on files: a file changed on disk is caught + // when its frames' entries are read; they are forgotten with the frames all the same) if let Ok(mut k) = self.content_keys.lock() { k.clear(); } @@ -1386,6 +1442,7 @@ impl Frames { } } + /// A render worker sharing this cache's queue, cache and in-flight set. fn worker(&self) -> Worker { Worker { queue: self.queue.clone(), @@ -1394,20 +1451,15 @@ impl Frames { ctx: self.ctx.clone(), last: self.last_ms.clone(), content_keys: self.content_keys.clone(), - virtual_epoch: self.virtual_epoch.clone(), virtual_id: self.virtual_id.clone(), gpu_retired: self.gpu_retired.clone(), } } - /// Browser file table: `epoch` bumps on each replacement, and `id` is the size and content - /// hash used when a content key is rebuilt. Neither runs on a cache hit. - pub fn set_virtual_media(&mut self, epoch: Arc, id: Arc Option<(u64, u64)> + Send + Sync>) { - self.virtual_epoch = epoch; + /// Browser file table: `id` gives the size and content hash of a path served from memory, + /// stored with the disk entries of the frames that read it and checked when they are read. + pub fn set_virtual_id(&mut self, id: Arc Option<(u64, u64)> + Send + Sync>) { self.virtual_id = Some(id); - if let Ok(mut keys) = self.content_keys.lock() { - keys.clear(); - } } /// Render frames with `remote` from now on (`None`: on this thread again). @@ -1465,8 +1517,10 @@ impl Frames { let gpu_here = job.src.gpu.is_some() && job.src.gpu_display && !(remote.gpu() && comp_has_effects(&job.src.project, job.comp)); if gpu_here { let t0 = web_time::Instant::now(); - if let Some(img) = w.render_gpu(&job) { - w.finish(&job, img, t0); + // (A frame kept on the GPU is not written to the disk cache: its files are not + // needed.) + if let Some((img, _files)) = w.render_gpu(&job) { + w.finish(&job, img, None, None, t0); continue; } } @@ -1478,8 +1532,7 @@ impl Frames { let fw = self.worker(); let busy = self.remote_busy.clone(); let t0 = web_time::Instant::now(); - let epoch = w.virtual_epoch.load(Ordering::Relaxed); - let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts), epoch, w.virtual_id.as_deref())); + let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts))); let rj = RemoteJob { project: job.src.project.clone(), revision: key.revision, comp: job.comp, t: job.t, opts: job.opts, disk_key }; remote.start( rj, @@ -1490,7 +1543,7 @@ impl Frames { match r { Ok(f) => { let img = egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba); - fw.finish_key(key, urgent, FrameImage::Cpu(Arc::new(img)), t0); + fw.finish_key(key, urgent, FrameImage::Cpu(Arc::new(img)), None, None, t0); } Err(e) => { // Not rendered (a stale revision, a lost worker): the viewer asks again. @@ -1536,7 +1589,6 @@ struct Worker { ctx: Option, last: Arc>, content_keys: ContentKeys, - virtual_epoch: Arc, virtual_id: Option Option<(u64, u64)> + Send + Sync>>, gpu_retired: Arc, } @@ -1599,28 +1651,17 @@ impl Worker { job }; let t0 = web_time::Instant::now(); - let epoch = self.virtual_epoch.load(Ordering::Relaxed); - let disk = job - .src - .disk - .as_ref() - .map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts), epoch, self.virtual_id.as_deref()))); - // Half-float frames (32 bpc) have entries of their own (their key has the options). - let from_disk = disk.as_ref().and_then(|(dc, k)| { - if job.opts.skip_hdr_step { - let f = dc.get_frame_f16(*k)?; - let encoding = effectcraft_engine::viewer::FrameEncoding::of(&job.src.project.settings, true); - Some(FrameImage::CpuF16(Arc::new(F16Frame::from_bits(f.width, f.height, f.rgba, encoding)))) - } else { - let f = dc.get_frame(*k)?; - Some(FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba)))) - } - }); + // (the drop generation before the frame reads anything: File ▸ Reload Footage meanwhile + // makes what it read from a reloaded file stale) + let disk = + job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts)), dc.drop_generation())); + let from_disk = disk.as_ref().and_then(|(dc, k, _)| self.disk_frame(dc, *k, &job)); let disk_hit = from_disk.is_some(); - let ci = match from_disk { - Some(f) => f, + // `files`: the source files the frame was made of (see `render::reads`). + let (ci, files) = match from_disk { + Some(hit) => hit, None => match self.render_guarded(&job).filter(|_| !job.cancel.load(Ordering::Relaxed)) { - Some(ci) => ci, + Some(rendered) => rendered, // Called off (frames it read may be missing) or failed: not cached, and not // "in progress" forever: the viewer asks for it again. None => { @@ -1629,42 +1670,61 @@ impl Worker { } }, }; + let mut files = files; + if let Some((dc, _, since)) = &disk + && dc.dropped_since(&files, *since) + { + // Made of a file as it was before File ▸ Reload Footage dropped it: shown, but its + // list can't vouch for it, so neither this worker nor `persist_missing` stores it. + files.mark_incomplete(); + } + let files = Arc::new(files); // Publish the shared frame and wake the viewer before optional RGBA conversion // and compression. Persistence still runs on this worker, using the same snapshot. - self.finish(&job, ci.clone(), t0); - if !disk_hit && let Some((dc, k)) = &disk { - let written = match &ci { - FrameImage::Cpu(img) => { - let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); - dc.put_frame(*k, img.size[0] as u32, img.size[1] as u32, &rgba); - true - } - FrameImage::CpuF16(f) => { - dc.put_frame_f16(*k, f.width, f.height, f.bits()); - true - } - FrameImage::Gpu(_) => false, - }; - if written && let Some(ctx) = &self.ctx { - ctx.request_repaint(); - } + let since = disk.as_ref().map(|(dc, _, since)| DiskSince::of(dc, *since)); + self.finish(&job, ci.clone(), Some(files.clone()), since, t0); + if !disk_hit + && let Some((dc, k, since)) = &disk + && put_disk_frame(dc, *k, &ci, &files, self.virtual_id.as_deref(), *since) + && let Some(ctx) = &self.ctx + { + ctx.request_repaint(); } true } - /// Store a finished frame and release its job. - fn finish(&self, job: &Job, img: FrameImage, t0: web_time::Instant) { - self.finish_key(job.key, job.urgent, img, t0); + /// The frame of `job` from the disk cache (key `k`), if its entry is there and the source + /// files it lists are unchanged (checked here, on the frame worker: a few `metadata` calls + /// for that frame's files). An entry whose files changed is deleted: the frame renders again + /// and its new entry replaces it. + fn disk_frame(&self, dc: &DiskCache, k: u128, job: &Job) -> Option<(FrameImage, ReadFiles)> { + let virtual_id = self.virtual_id.as_deref(); + // Half-float frames (32 bpc) have entries of their own (their key has the options). + if job.opts.skip_hdr_step { + let (f, files) = dc.get_frame_f16(k, virtual_id)?; + let encoding = effectcraft_engine::viewer::FrameEncoding::of(&job.src.project.settings, true); + Some((FrameImage::CpuF16(Arc::new(F16Frame::from_bits(f.width, f.height, f.rgba, encoding))), files)) + } else { + let (f, files) = dc.get_frame(k, virtual_id)?; + Some((FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))), files)) + } } - fn finish_key(&self, key: FrameKey, urgent: bool, img: FrameImage, t0: web_time::Instant) { + /// Store a finished frame (with the source files it was made of, when known) and release its + /// job. + fn finish(&self, job: &Job, img: FrameImage, files: Option>, since: Option, t0: web_time::Instant) { + self.finish_key(job.key, job.urgent, img, files, since, t0); + } + + /// Stores a finished frame in the cache with its read files, records the render time for urgent jobs, then releases the job. + fn finish_key(&self, key: FrameKey, urgent: bool, img: FrameImage, files: Option>, since: Option, t0: web_time::Instant) { if urgent && let Ok(mut l) = self.last.lock() { *l = t0.elapsed().as_secs_f64() * 1000.0; } if let Ok(mut c) = self.cache.lock() && (!img.is_gpu() || !self.gpu_retired.load(Ordering::Acquire)) { - c.insert(key, img); + c.insert_with(key, img, files, since); } self.release(key, urgent); } @@ -1689,9 +1749,13 @@ impl Worker { } /// [`Worker::render_job`], again on the CPU if it panicked (the panic hook has logged it); - /// `None` if that panicked too. - fn render_guarded(&self, job: &Job) -> Option { - let attempt = |gpu: bool| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.render_job(job, gpu))).ok(); + /// `None` if that panicked too. With the source files the frame was made of. + fn render_guarded(&self, job: &Job) -> Option<(FrameImage, ReadFiles)> { + let attempt = |gpu: bool| { + let files = ReadLog::new(); + let img = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.render_job(job, gpu, &files))).ok()?; + Some((img, files.into_files())) + }; let img = attempt(true).or_else(|| { log::error!("a frame render panicked (frame {})", job.key.frame); job.src.gpu.as_ref().and_then(|_| attempt(false)) @@ -1721,26 +1785,31 @@ impl Worker { } } + /// Tells the cache that a GPU render succeeded. fn gpu_succeeded(&self) { if let Ok(mut c) = self.cache.lock() { c.gpu_succeeded(); } } + /// Whether GPU rendering is available and has not been retired. fn gpu_ready(&self) -> bool { !self.gpu_retired.load(Ordering::Acquire) && self.cache.lock().is_ok_and(|c| c.gpu_ready()) } - /// The frame on the GPU, kept there for the viewer, if the compositor can do it alone. - fn render_gpu(&self, job: &Job) -> Option { + /// The frame on the GPU, kept there for the viewer, if the compositor can do it alone, with + /// the source files it was made of. + fn render_gpu(&self, job: &Job) -> Option<(FrameImage, ReadFiles)> { if !self.gpu_ready() { return None; } let g = job.src.gpu.as_ref()?; let footage = job.footage(); + let files = ReadLog::new(); let mut r = Renderer::new(&job.src.project, &footage, job.opts); r.expr = job.src.expr.as_deref(); r.cache = Some(&job.src.layer_cache); + r.reads = Some(&files); 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)); @@ -1750,7 +1819,7 @@ impl Worker { if f.is_some() { self.gpu_succeeded(); } - f.map(|f| FrameImage::Gpu(Arc::new(f))) + f.map(|f| (FrameImage::Gpu(Arc::new(f)), files.into_files())) } Err(e) => { self.gpu_attempt_failed(g, &e); @@ -1759,11 +1828,13 @@ impl Worker { } } - /// Render a frame; `gpu: false` keeps it off the GPU compositor. - fn render_job(&self, job: &Job, gpu: bool) -> FrameImage { + /// Render a frame; `gpu: false` keeps it off the GPU compositor. The source files it reads + /// are noted in `files` (a GPU attempt that falls back to the CPU notes both renders'). + fn render_job(&self, job: &Job, gpu: bool, files: &ReadLog) -> FrameImage { let footage = job.footage(); let mut r = Renderer::new(&job.src.project, &footage, job.opts); r.expr = job.src.expr.as_deref(); + r.reads = Some(files); let gpu_allowed = gpu && self.gpu_ready(); // A failed GPU attempt may have populated intermediate entries before its scope // reported failure. The CPU retry must compute its own pixels from the snapshot. @@ -1789,7 +1860,7 @@ impl Worker { // Time the GPU's work, not just its recording. g.wait(); if g.context().check_health().is_err() { - return self.render_job(job, false); + return self.render_job(job, false, files); } p.record(key, true, t0.elapsed().as_secs_f64() * 1000.0); } @@ -1802,7 +1873,7 @@ impl Worker { } Err(e) => { self.gpu_attempt_failed(g, &e); - return self.render_job(job, false); + return self.render_job(job, false, files); } } } @@ -1822,7 +1893,7 @@ impl Worker { } Err(e) => { self.gpu_attempt_failed(g, &e); - self.render_job(job, false) + self.render_job(job, false, files) } }; } diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index df7b3900c..360ddf1b6 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -1,4 +1,5 @@ use super::*; +use effectcraft_engine::project::LayerSource; #[test] fn render_scales_are_the_factors_the_thousandths_stand_for() { @@ -60,7 +61,7 @@ fn disk_fixture(stored_frames: &[i64]) -> (Frames, RenderSource, FrameKey, Rende disk.keys.lock().unwrap().extend(stored_frames.iter().map(|frame| disk_cache::frame_key(content, *frame, base.scale, base.view ^ opts_hash(&opts)))); let mut frames = Frames::default(); frames.set_remote(Some(disk.clone())); - frames.content_keys.lock().unwrap().insert((base.revision, base.comp, 0), content); + frames.content_keys.lock().unwrap().insert((base.revision, base.comp), content); let source = RenderSource { project: session.project.clone(), footage: session.footage.clone(), @@ -126,7 +127,7 @@ fn disk_indicator_does_not_hash_on_ui_thread_and_coalesces_edits() { assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1); release.send(()).unwrap(); let until = Instant::now() + Duration::from_secs(3); - while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp, 0)) { + while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp)) { assert!(Instant::now() < until); std::thread::sleep(Duration::from_millis(1)); } @@ -153,7 +154,7 @@ fn disk_indicator_range_tracks_live_membership() { let mut revised = base; revised.revision += 1; - frames.content_keys.lock().unwrap().insert((revised.revision, revised.comp, 0), 5678); + frames.content_keys.lock().unwrap().insert((revised.revision, revised.comp), 5678); assert!(frames.disk_frames(&src, &revised, 10..13, &opts).is_empty()); let changed = FrameKey { scale: base.scale + 1, ..base }; @@ -211,7 +212,7 @@ fn retirement_reaches_existing_workers_and_keeps_cpu_frames_and_completion_live( let next = key(1, 1, 0); frames.inflight.lock().unwrap().insert(next); frames.queue.lock().unwrap().running.insert(next); - worker.finish_key(next, false, img(), web_time::Instant::now()); + worker.finish_key(next, false, img(), None, None, web_time::Instant::now()); assert!(frames.is_cached(&next), "CPU retry can still publish"); assert_eq!(frames.inflight(), 0); assert!(frames.queue.lock().unwrap().running.is_empty()); @@ -996,7 +997,7 @@ fn the_frames_an_edit_keeps_match_a_fresh_render() { // The work area is a preview and render range: setting it (B, N) keeps every frame, on // disk too. let n = s.project.comp(comp).map(|c| c.frame_rate.frame_at(c.duration)).unwrap(); - let disk = |s: &Session| effectcraft_engine::render::disk_cache::comp_content_key_with(&s.project, comp, None); + let disk = |s: &Session| effectcraft_engine::render::disk_cache::comp_content_key(&s.project, comp); let d0 = disk(&s); let work_area = |start: f64, end: f64| json!({"cmd": "comp.workArea", "params": {"comp": comp.0, "start": start, "end": end}}); assert_eq!(kept(&mut s, comp, work_area(1.0, 4.0)), (0..n).collect::>()); @@ -1593,6 +1594,71 @@ fn half_float_ram_frames_missing_on_disk_are_written() { ram_frames_missing_on_disk_are_written_as(true); } +/// A RAM frame whose render started before Reload Footage dropped one of its files (#3184) +/// holds the pixels from before: writing it later to the disk cache (an idle pass after the +/// drop) would bring them back, also after a restart, since the file kept its size and time. It +/// is written only with the drop generation its render started at, for the cache it started +/// with: so it is not written, nor is a frame rendered without that cache. Frames of other files, +/// and frames rendered after the drop, are written. +#[test] +fn ram_frames_from_before_a_drop_are_not_written() { + use effectcraft_engine::Session; + use effectcraft_engine::render::reads::{FileStamp, ReadFile}; + use serde_json::json; + let dir = tempfile::tempdir().unwrap(); + let disk = DiskCache::open(dir.path().join("cache"), 1 << 30).unwrap(); + let other = DiskCache::open(dir.path().join("other"), 1 << 30).unwrap(); + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({})).unwrap(); + let src = RenderSource { + project: s.project.clone(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + let f = Frames::default(); + let series = FrameKey { revision: 1, content: f.content_of(&src.project, 1, cid), comp: cid.0, opts: opts_hash(&RenderOpts::default()), ..key(0, 0, 0) }; + let read = |p: &str| Some(Arc::new([ReadFile::new(p, FileStamp::Native { len: 3, modified: Some(1) })].into_iter().collect::())); + let before = DiskSince::of(&disk, disk.drop_generation()); + { + let mut c = f.cache.lock().unwrap(); + c.insert_with(FrameKey { frame: 0, ..series }, img(), read("/a.png"), Some(before.clone())); + c.insert_with(FrameKey { frame: 1, ..series }, img(), read("/b.png"), Some(before)); + c.insert_with(FrameKey { frame: 2, ..series }, img(), read("/a.png"), None); + c.insert_with(FrameKey { frame: 3, ..series }, img(), read("/a.png"), Some(DiskSince::of(&other, other.drop_generation()))); + } + // Reload Footage of a.png. + disk.drop_files(["/a.png"]); + disk.flush(); + f.cache.lock().unwrap().insert_with(FrameKey { frame: 4, ..series }, img(), read("/a.png"), Some(DiskSince::of(&disk, disk.drop_generation()))); + let on_disk = |fr: i64| { + disk.contains( + disk_cache::Kind::Frame, + frame_disk_key(&f.content_keys, &src.project, &FrameKey { frame: fr, ..series }, opts_hash(&RenderOpts::default())), + ) + }; + // Idle passes until there is nothing left to write. + let mut done = false; + for _ in 0..1000 { + let more = f.persist_missing(&src, &series, &RenderOpts::default()); + disk.flush(); + if !more && f.persisted.lock().unwrap().is_empty() { + done = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(done, "the idle passes end"); + assert!(on_disk(1) && on_disk(4), "another file's frame, and a frame rendered after the drop"); + assert!(!on_disk(0), "made of a.png as it was before the drop"); + assert!(!on_disk(2) && !on_disk(3), "rendered without this disk cache"); + assert_eq!(disk.stats().entries, 2); +} + fn ram_frames_missing_on_disk_are_written_as(half: bool) { use effectcraft_engine::Session; use serde_json::json; @@ -1616,19 +1682,12 @@ fn ram_frames_missing_on_disk_are_written_as(half: bool) { FrameKey { revision: 1, content: f.content_of(&src.project, 1, cid), comp: cid.0, opts: opts_hash(&RenderOpts::default()) ^ 1, ..key(0, 0, 0) }; for fr in 0..10 { let frame = if half { FrameImage::CpuF16(Arc::new(F16Frame::from_image(&effectcraft_engine::render::Image::new(8, 8), SRGB))) } else { img() }; - f.cache.lock().unwrap().insert(FrameKey { frame: fr, ..series }, frame); + f.cache.lock().unwrap().insert_with(FrameKey { frame: fr, ..series }, frame, Some(Arc::default()), Some(DiskSince::of(&disk, disk.drop_generation()))); } let on_disk = |fr: i64| { disk.contains( disk_cache::Kind::Frame, - frame_disk_key( - &f.content_keys, - &src.project, - &FrameKey { frame: fr, ..series }, - opts_hash(&RenderOpts::default()), - f.virtual_epoch.load(Ordering::Relaxed), - None, - ), + frame_disk_key(&f.content_keys, &src.project, &FrameKey { frame: fr, ..series }, opts_hash(&RenderOpts::default())), ) }; let persist = || { @@ -1674,6 +1733,71 @@ fn ram_frames_missing_on_disk_are_written_as(half: bool) { let _ = std::fs::remove_dir_all(&dir); } +/// While free disk space holds the disk cache at 0, frames are not converted or compressed for +/// it (the frame workers spent about 8 ms per frame on writes that were then refused), and idle +/// passes don't keep converting the RAM frames; with room again they are written. +#[test] +fn no_frame_is_encoded_while_the_disk_has_no_room() { + use effectcraft_engine::Session; + use serde_json::json; + const GB: u64 = 1 << 30; + let dir = std::env::temp_dir().join(format!("ec-persist-full-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let disk = DiskCache::open(&dir, 20 * GB).unwrap(); + // 4 TB disk, 1 GB free: under the 10 GB reserve. + disk.set_disk_space(Some((4096 * GB, GB))); + assert!(!disk.accepts_writes()); + // The frame worker's write: refused up front. + assert!(!put_disk_frame(&disk, 1, &img(), &ReadFiles::default(), None, disk.drop_generation())); + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({})).unwrap(); + let src = RenderSource { + project: s.project.clone(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + let f = Frames::default(); + let series = + FrameKey { revision: 1, content: f.content_of(&src.project, 1, cid), comp: cid.0, opts: opts_hash(&RenderOpts::default()) ^ 1, ..key(0, 0, 0) }; + for fr in 0..10 { + f.cache.lock().unwrap().insert_with(FrameKey { frame: fr, ..series }, img(), Some(Arc::default()), Some(DiskSince::of(&disk, disk.drop_generation()))); + } + // Idle passes: nothing to do, nothing converted. + for _ in 0..20 { + assert!(!f.persist_missing(&src, &series, &RenderOpts::default()), "no idle passes while there is no room"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + disk.flush(); + assert!(f.persisted.lock().unwrap().is_empty()); + assert_eq!((disk.encodes(), disk.stats().entries), (0, 0)); + // Room again: the RAM frames are written. + disk.set_disk_space(Some((4096 * GB, 500 * GB))); + let on_disk = |fr: i64| { + disk.contains( + disk_cache::Kind::Frame, + frame_disk_key(&f.content_keys, &src.project, &FrameKey { frame: fr, ..series }, opts_hash(&RenderOpts::default())), + ) + }; + let mut written = false; + for _ in 0..1000 { + f.persist_missing(&src, &series, &RenderOpts::default()); + disk.flush(); + if (0..10).all(on_disk) && f.persisted.lock().unwrap().is_empty() { + written = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(written, "every RAM frame written once there is room"); + assert_eq!(disk.encodes(), 10); + let _ = std::fs::remove_dir_all(&dir); +} + /// Audio changes pixels only where something draws it: a clip's Audio Levels, audio effects and /// Audio switch keep every frame, in RAM and on disk, until a waveform picks the clip (on another /// layer, on itself, or through a precomp of it); then the frames of the reader render again. @@ -1681,7 +1805,7 @@ fn ram_frames_missing_on_disk_are_written_as(half: bool) { fn audio_edits_keep_the_frames_until_something_draws_the_audio() { use effectcraft_engine::Session; use effectcraft_engine::project::{Footage, GroupKind, LayerId, Value, build}; - use effectcraft_engine::render::disk_cache::comp_content_key_with; + use effectcraft_engine::render::disk_cache::comp_content_key; use serde_json::json; let mut s = Session::default(); let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); @@ -1736,7 +1860,7 @@ fn audio_edits_keep_the_frames_until_something_draws_the_audio() { let mut p1 = p0.clone(); edit(Arc::make_mut(&mut p1)); let id1 = f.content_of(&p1, 2, view); - (f.cached_frames(&k(id1, 0)), comp_content_key_with(&p0, view, None) == comp_content_key_with(&p1, view, None)) + (f.cached_frames(&k(id1, 0)), comp_content_key(&p0, view) == comp_content_key(&p1, view)) }; let all: Vec = (0..120).collect(); let edits: [(&str, &dyn Fn(&mut Project)); 3] = [("levels", &levels), ("audio effect", &backwards), ("audio switch", &mute)]; @@ -1933,9 +2057,9 @@ fn disk_frames_outlive_an_edit_that_cant_change_them() { disk: Some(disk.clone()), }; // Every frame of `p0` on disk, none in RAM. - let content0 = disk_cache::comp_content_key_with(&p0, cid, None); + let content0 = disk_cache::comp_content_key(&p0, cid); for f in 0..30 { - disk.put_frame_now(disk_cache::frame_key(content0, f, 1000, oh), 1, 1, &[1, 2, 3, 255]); + disk.put_frame_now(disk_cache::frame_key(content0, f, 1000, oh), 1, 1, &[1, 2, 3, 255], &ReadFiles::default()); } let rename: fn(&mut Project, ItemId, LayerId) = |p, c, l| p.comp_mut(c).unwrap().layer_mut(l).unwrap().name = "Renamed".into(); let trim: fn(&mut Project, ItemId, LayerId) = |p, c, l| { @@ -1982,6 +2106,80 @@ fn disk_frames_outlive_an_edit_that_cant_change_them() { let _ = std::fs::remove_dir_all(&dir); } +/// With expressions the disk key covers what they can read (#3184, #1492): an edit to a comp no +/// expression names keeps every frame on disk under the same key; an edit to the comp one names +/// gives the comp a new key, and the frames outside the layer with that expression are carried to +/// it (the dirty spans of #2795). +#[test] +fn disk_frames_outlive_edits_their_expressions_cant_read() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Expression, LayerId, Value}; + use serde_json::json; + let dir = std::env::temp_dir().join(format!("ec-disk-carry-expr-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let disk = DiskCache::open(&dir, 1 << 30).unwrap(); + let mut s = Session::default(); + let comp = |s: &mut Session, name: &str| { + ItemId(s.execute("comp.new", json!({"name": name, "width": 8, "height": 8, "duration": 1, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()) + }; + let solid = |s: &mut Session, c: ItemId| LayerId(s.execute("layer.newSolid", json!({"comp": c.0, "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap()); + let cid = comp(&mut s, "A"); + let (a, b) = (solid(&mut s, cid), solid(&mut s, cid)); + let other = comp(&mut s, "Other"); + let o = solid(&mut s, other); + let third = comp(&mut s, "Third"); + let t = solid(&mut s, third); + let fr = s.project.comp(cid).unwrap().frame_rate; + let mut p0 = (*s.project).clone(); + for (l, i, out) in [(a, 0, 15), (b, 15, 30)] { + let l = p0.comp_mut(cid).unwrap().layer_mut(l).unwrap(); + (l.start_time, l.in_point, l.out_point) = (fr.tick_of(i), fr.tick_of(i), fr.tick_of(out)); + } + p0.comp_mut(cid).unwrap().layer_mut(a).unwrap().props.prop_mut("transform/opacity").unwrap().expr = + Some(Expression { text: "comp(\"Other\").layer(1).transform.opacity".into(), enabled: true }); + let p0 = Arc::new(p0); + let opts = RenderOpts::default(); + let oh = opts_hash(&opts); + let src = |p: &Arc| RenderSource { + project: p.clone(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + let content0 = disk_cache::comp_content_key(&p0, cid); + for f in 0..30 { + disk.put_frame_now(disk_cache::frame_key(content0, f, 1000, oh), 1, 1, &[1, 2, 3, 255], &ReadFiles::default()); + } + let opacity = |p: &mut Project, c: ItemId, l: LayerId| { + p.comp_mut(c).unwrap().layer_mut(l).unwrap().props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(40.0) + }; + for (name, edited, layer, same_key, kept) in [("Third", third, t, true, (0..30).collect::>()), ("Other", other, o, false, (17..30).collect())] { + let f = Frames::default(); + let series = |p: &Arc, rev: u64| FrameKey { revision: rev, content: f.content_of(p, rev, cid), comp: cid.0, opts: oh, ..key(0, 0, 0) }; + let blue = |p: &Arc, rev: u64, want: &[i64]| { + let base = series(p, rev); + let mut got = vec![]; + for _ in 0..1000 { + got = f.disk_frames(&src(p), &base, 0..30, &opts); + if got == want { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + got + }; + assert_eq!(blue(&p0, 1, &(0..30).collect::>()), (0..30).collect::>(), "{name}: before"); + let mut p1 = p0.clone(); + opacity(Arc::make_mut(&mut p1), edited, layer); + assert_eq!(disk_cache::comp_content_key(&p1, cid) == content0, same_key, "{name}: the key"); + assert_eq!(blue(&p1, 2, &kept), kept, "{name}: after"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// A keyframe edit re-renders only around the keys it changed: two keys either side (auto-Bezier /// and continuous tangents reach the neighbouring segments), and from the layer's in point when /// the first key changes. A static value or an expression still reaches the whole layer. Every @@ -2041,7 +2239,7 @@ fn a_keyframe_edit_reaches_only_the_keys_around_it() { fn music_volume_keeps_the_frames_with_expressions_that_dont_read_audio() { use effectcraft_engine::Session; use effectcraft_engine::project::{Expression, Footage, FootageKind, LayerId, Value, build}; - use effectcraft_engine::render::disk_cache::comp_content_key_with; + use effectcraft_engine::render::disk_cache::comp_content_key; use serde_json::json; let mut s = Session::default(); let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); @@ -2069,7 +2267,7 @@ fn music_volume_keeps_the_frames_with_expressions_that_dont_read_audio() { let mut p1 = p0.clone(); Arc::make_mut(&mut p1).comp_mut(cid).unwrap().layer_mut(music_id).unwrap().props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-6.0, -6.0]); let id1 = f.content_of(&p1, 2, cid); - (f.cached_frames(&k(id1, 0)).len(), comp_content_key_with(&p0, cid, None) == comp_content_key_with(&p1, cid, None)) + (f.cached_frames(&k(id1, 0)).len(), comp_content_key(&p0, cid) == comp_content_key(&p1, cid)) }; assert_eq!(kept("wiggle(2, 20)"), (120, true), "a wiggle reads no audio"); let (frames, disk) = kept("value + thisComp.layer(\"Music\")(\"Audio\")(\"Audio Levels\")[0]"); @@ -2449,3 +2647,313 @@ fn a_keyframe_edit_beside_muted_keys_reaches_the_keys_that_play() { assert!(same(i), "kept frame {i} renders differently after the edit"); } } + +/// Disk-cache frame entries that keep the source files they were made of (#3184): a sequence +/// comp in a folder of its own, with a large unused sequence in the project. +mod frame_files { + use super::*; + use effectcraft_engine::project::{Footage, FootageKind, build}; + use effectcraft_engine::time::FrameRate; + use std::path::{Path, PathBuf}; + use std::sync::atomic::AtomicUsize; + + const RATE: FrameRate = FrameRate::FPS_30; + const FRAMES: i64 = 5; + + /// A fresh folder for test `name` (on the build drive, not the system temp folder), deleted + /// when the guard is dropped. + fn folder(name: &str) -> tempfile::TempDir { + let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target"); + std::fs::create_dir_all(&target).unwrap(); + tempfile::Builder::new().prefix(&format!("test-frame-files-{name}-")).tempdir_in(target).unwrap() + } + + /// A flat grey PNG of `level` at `path`. + fn png(path: &Path, level: u8) -> String { + image::RgbaImage::from_pixel(16, 8, image::Rgba([level, level, level, 255])).save(path).unwrap(); + path.to_string_lossy().to_string() + } + + /// The grey level of sequence frame `i` as first written. + fn level(i: i64) -> u8 { + 20 + i as u8 * 30 + } + + /// A project with a comp showing a sequence of [`FRAMES`] PNGs (in `dir/seq`) and, unused, a + /// sequence of 5000 frames that don't even exist. The files of the shown sequence. + fn project(dir: &Path) -> (Arc, ItemId, Vec) { + let seq_dir = dir.join("seq"); + std::fs::create_dir_all(&seq_dir).unwrap(); + let files: Vec = (0..FRAMES).map(|i| png(&seq_dir.join(format!("f_{i:04}.png")), level(i))).collect(); + let mut seq = Footage { kind: FootageKind::Sequence, sequence: files.clone(), frame_rate: RATE, ..effectcraft_media::probe(&files[0]).unwrap() }; + seq.sync_sequence_duration(); + let unused = Footage { + kind: FootageKind::Sequence, + sequence: (0..5000).map(|i| dir.join("unused").join(format!("big_{i:05}.exr")).to_string_lossy().to_string()).collect(), + frame_rate: RATE, + width: 16, + height: 8, + has_video: true, + ..Default::default() + }; + let mut p = Project::default(); + p.add_item("Unused", effectcraft_engine::color::Label::Aqua, None, ItemKind::Footage(unused)); + let item = p.add_item("Seq", effectcraft_engine::color::Label::Aqua, None, ItemKind::Footage(seq)); + let mut comp = Comp::new(16, 8, RATE, RATE.tick_of(FRAMES)); + comp.layers = vec![build::layer(&mut p, &comp, "Seq", LayerSource::Footage { item }, (16, 8), Some(comp.duration))].into(); + let cid = p.add_item("C", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into())); + (Arc::new(p), cid, files) + } + + /// A session over the disk cache in `dir`: a new preview cache, media pool, layer cache and + /// disk cache instance (as after reopening the project), counting the calls of the hook for + /// files served from memory. + struct Session { + frames: Frames, + src: RenderSource, + disk: Arc, + hook_calls: Arc, + /// The session's media pool (`src.footage`): what it decoded and opened. + pool: Arc, + } + + fn session(p: &Arc, dir: &Path) -> Session { + let disk = DiskCache::open(dir.join("cache"), 1 << 30).unwrap(); + let hook_calls = Arc::new(AtomicUsize::new(0)); + let mut frames = Frames::default(); + let calls = hook_calls.clone(); + frames.set_virtual_id(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + None + })); + let pool = Arc::new(effectcraft_media::MediaPool::new()); + let src = RenderSource { + project: p.clone(), + footage: pool.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + Session { frames, src, disk, hook_calls, pool } + } + + impl Session { + fn key(&self, cid: ItemId, frame: i64) -> FrameKey { + FrameKey { + revision: 1, + content: self.frames.content_of(&self.src.project, 1, cid), + comp: cid.0, + frame, + scale: 1000, + view: 0, + opts: opts_hash(&RenderOpts::default()), + } + } + + /// Show `frames` of `cid` (rendered or read from disk); the grey level of each, once + /// their disk writes are done. + fn show(&self, cid: ItemId, frames: &[i64]) -> Vec { + for &f in frames { + self.frames.request(&self.src, self.key(cid, f), cid, RATE.tick_of(f), RenderOpts::default()); + } + wait_for_renders(&self.frames); + // (the worker queues the write after it has released the frame) + for _ in 0..400 { + self.disk.flush(); + if frames.iter().all(|f| self.disk.contains(disk_cache::Kind::Frame, self.disk_key(cid, *f))) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + frames + .iter() + .map(|f| match self.frames.get(&self.key(cid, *f)) { + Some(FrameImage::Cpu(c)) => c.pixels[0].r(), + _ => panic!("frame {f}: an 8-bit CPU frame"), + }) + .collect() + } + + fn disk_key(&self, cid: ItemId, frame: i64) -> u128 { + frame_disk_key(&self.frames.content_keys, &self.src.project, &self.key(cid, frame), opts_hash(&RenderOpts::default())) + } + + /// (disk hits, disk misses) so far. + fn hits(&self) -> (u64, u64) { + let s = self.disk.stats(); + (s.hits, s.misses) + } + } + + fn close(a: u8, b: u8) -> bool { + a.abs_diff(b) <= 2 + } + + /// Give `path` a modification time `secs` later, contents and size unchanged. + fn touch(path: &str, secs: u64) { + let t = std::fs::metadata(path).unwrap().modified().unwrap() + std::time::Duration::from_secs(secs); + std::fs::File::options().append(true).open(path).unwrap().set_modified(t).unwrap(); + } + + /// Correct: a source file of the sequence changes (new contents and size; or only a new + /// time): exactly the frames that read it render again and their entries are replaced; the + /// others come from disk, also after reopening. + #[test] + fn a_changed_source_file_re_renders_exactly_its_frames() { + let tmp = folder("correct"); + let dir = tmp.path(); + let (p, cid, files) = project(dir); + let all: Vec = (0..FRAMES).collect(); + let first = session(&p, dir); + let shown = first.show(cid, &all); + assert!(all.iter().all(|f| close(shown[*f as usize], level(*f))), "{shown:?}"); + assert_eq!(first.hits(), (0, FRAMES as u64), "rendered, nothing on disk yet"); + assert_eq!(first.disk.stats().entries, FRAMES as usize, "every frame stored"); + drop(first); + // Frame 2's file gets new pixels (and size), frame 3's only a new time. + png(Path::new(&files[2]), 250); + touch(&files[3], 30); + let second = session(&p, dir); + let shown = second.show(cid, &all); + assert!(close(shown[2], 250), "frame 2 shows its new file: {shown:?}"); + assert!([0, 1, 3, 4].iter().all(|f| close(shown[*f as usize], level(*f))), "{shown:?}"); + assert_eq!(second.hits(), (3, 2), "frames 0, 1 and 4 from disk; 2 and 3 rendered again"); + assert_eq!(second.disk.file_checks(), FRAMES as u64, "one file checked per frame read"); + assert_eq!(second.disk.stats().entries, FRAMES as usize, "the entries of 2 and 3 were replaced"); + drop(second); + // Reopened: every frame from disk, the replaced ones with their new pixels. + let third = session(&p, dir); + let shown = third.show(cid, &all); + assert_eq!(third.hits(), (FRAMES as u64, 0)); + assert!(close(shown[2], 250) && close(shown[3], level(3)), "{shown:?}"); + } + + /// Reopened (a new preview cache, media pool, layer cache and disk cache on the same folder): + /// the frame comes from disk, its one source file is checked (a `metadata` call), and no + /// source file is opened or decoded for it. + #[test] + fn a_reopened_project_serves_its_frame_from_disk_without_opening_a_source_file() { + let tmp = folder("reopen"); + let dir = tmp.path(); + let (p, cid, _) = project(dir); + let first = session(&p, dir); + let shown = first.show(cid, &[1]); + assert!(close(shown[0], level(1)), "{shown:?}"); + assert!(first.pool.stats().misses > 0, "the first session decodes the frame's file: {:?}", first.pool.stats()); + assert!(first.disk.contains(disk_cache::Kind::Frame, first.disk_key(cid, 1))); + drop(first); + let again = session(&p, dir); + let shown = again.show(cid, &[1]); + assert!(close(shown[0], level(1)), "{shown:?}"); + assert_eq!(again.hits(), (1, 0), "served from disk"); + assert_eq!(again.disk.file_checks(), 1, "its one file checked"); + assert_eq!(again.pool.stats(), effectcraft_media::PoolStats::default(), "no source file opened or decoded"); + } + + /// No scan: opening a comp of a project that also holds a large sequence, and showing a + /// frame, looks only at the files that frame reads; the comp's key looks at no file (it + /// stays the same when its files change, and never asks for the identity of a path). + #[test] + fn showing_a_frame_checks_only_the_files_it_reads() { + let tmp = folder("no-scan"); + let dir = tmp.path(); + let (p, cid, files) = project(dir); + let key = disk_cache::comp_content_key(&p, cid); + png(Path::new(&files[1]), 99); + touch(&files[2], 5); + assert_eq!(disk_cache::comp_content_key(&p, cid), key, "the key looks at no file"); + let first = session(&p, dir); + first.show(cid, &[2]); + drop(first); + let again = session(&p, dir); + // (the indicator's key is computed off the UI thread first) + let mut blue = vec![]; + for _ in 0..400 { + blue = again.frames.disk_frames(&again.src, &again.key(cid, 0), 0..FRAMES, &RenderOpts::default()); + if !blue.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(blue, vec![2], "the blue bar: present entries, no file looked at"); + assert_eq!(again.disk.file_checks(), 0, "the indicator checks no file"); + let shown = again.show(cid, &[2]); + assert!(close(shown[0], level(2)), "{shown:?}"); + assert_eq!(again.hits(), (1, 0)); + assert_eq!(again.disk.file_checks(), 1, "only the file frame 2 read"); + assert_eq!(again.hook_calls.load(Ordering::Relaxed), 0, "no path of the unused sequence was asked about"); + } + + /// The frame entry files under the cache folder. + fn entries(dir: &Path) -> Vec { + let mut out = vec![]; + let mut stack = vec![dir.join("cache")]; + while let Some(d) = stack.pop() { + for e in std::fs::read_dir(&d).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else if p.extension().is_some_and(|x| x == "ecc") && p.to_string_lossy().contains("frames") { + out.push(p); + } + } + } + out + } + + /// Safe: a missing source file, an unreadable one (a folder in its place) and a damaged + /// cache entry never serve the frame from before; each is rendered again and stored anew. + #[test] + fn missing_or_unreadable_files_and_damaged_entries_never_serve_a_stale_frame() { + let tmp = folder("safe"); + let dir = tmp.path(); + let (p, cid, files) = project(dir); + let first = session(&p, dir); + first.show(cid, &[0]); + assert_eq!(first.disk.stats().entries, 1); + drop(first); + // Missing: not served; the frame is rendered without it. + std::fs::remove_file(&files[0]).unwrap(); + let missing = session(&p, dir); + let shown = missing.show(cid, &[0]); + assert!(!close(shown[0], level(0)), "the frame from before was served: {shown:?}"); + assert_eq!(missing.hits(), (0, 1)); + drop(missing); + // Back, with other pixels: the entry made while it was missing is not served either. + png(Path::new(&files[0]), 180); + let back = session(&p, dir); + let shown = back.show(cid, &[0]); + assert!(close(shown[0], 180), "{shown:?}"); + assert_eq!(back.hits(), (0, 1)); + drop(back); + // Unreadable (a folder in its place). + std::fs::remove_file(&files[0]).unwrap(); + std::fs::create_dir(&files[0]).unwrap(); + let unreadable = session(&p, dir); + let shown = unreadable.show(cid, &[0]); + assert!(!close(shown[0], 180), "the frame from before was served: {shown:?}"); + assert_eq!(unreadable.hits(), (0, 1)); + drop(unreadable); + std::fs::remove_dir(&files[0]).unwrap(); + png(Path::new(&files[0]), 120); + let fresh = session(&p, dir); + assert!(close(fresh.show(cid, &[0])[0], 120)); + drop(fresh); + // A damaged entry: a miss, rendered, stored again, then served. + let found = entries(dir); + assert_eq!(found.len(), 1, "{found:?}"); + let mut bytes = std::fs::read(&found[0]).unwrap(); + let n = bytes.len() / 2; + bytes[n] ^= 0x5a; + std::fs::write(&found[0], bytes).unwrap(); + let damaged = session(&p, dir); + assert!(close(damaged.show(cid, &[0])[0], 120)); + assert_eq!(damaged.hits(), (0, 1)); + drop(damaged); + let healed = session(&p, dir); + assert!(close(healed.show(cid, &[0])[0], 120)); + assert_eq!(healed.hits(), (1, 0), "the rewritten entry is used"); + } +} diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 0c7573c91..76a70426e 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -163,6 +163,7 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let (l2c, _) = crate::panels::viewer::l2c(&ectx, l); l2c.inverse().map_or(d, |inv| { diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 0857dfa3f..39fd4847a 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -186,6 +186,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim); diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index 8e4f3502f..e23e11cfb 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -113,6 +113,7 @@ fn render_tex( footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 25829be7d..4edcbb31a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -79,6 +79,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect); let rect = scroll.content; diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index 194bba26a..c01a8f07e 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -114,6 +114,7 @@ pub fn draw_overlay( footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 72564f112..cdeb85116 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -5,7 +5,8 @@ //! //! Automation ids: `settings.page.`, `settings.` (e.g. `settings.general.undoLevels`), //! `settings.labels..name` / `.color`, `settings.button.

- A CC Particle World burst with Glow, and the Effect Controls panel listing the particle system's parameters -

- ## 3D Classic 3D compositing: 3D layers with orientation and material options, diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 2981519e9..b8634178e 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -35,8 +35,8 @@ //! //! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty) //! Saving: --save (back to --project) | --save-as F.ecproj -//! GPU: --gpu renders on the GPU compositor (Mercury GPU Acceleration) when an adapter exists; -//! the default is the CPU (Mercury Software Only) +//! GPU: --gpu renders on the GPU compositor (GPU Acceleration) when an adapter exists; +//! the default is the CPU (Software Rendering) //! Bridge: --bridge PORT drives a running `effectcraft --control PORT` instead of a headless session //! Output: --json for one compact JSON document on stdout (errors: {"error": …}, exit 1) //! ``` @@ -466,7 +466,7 @@ fn backend(args: &Args, default_demo: bool) -> Result { } /// A wired session; `--gpu` attaches the GPU compositor (renders then follow the project's -/// renderer setting, Mercury GPU Acceleration by default). +/// renderer setting, GPU Acceleration by default). fn session(args: &Args) -> Result { let mut s = effectcraft_host::session(); if args.flag("--autosave") { @@ -1443,7 +1443,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option Result<(), Failure> { use effectcraft_render::Backend; diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs index d5de8f687..3cf8356fb 100644 --- a/crates/automation/tests/qa/compositing.rs +++ b/crates/automation/tests/qa/compositing.rs @@ -106,7 +106,7 @@ fn track_matte(qa: &mut Qa) { fn adjustment_layer(qa: &mut Qa, normal: &Img) { // Adjustment layer: Lumetri exposure, inside a feathered, expanded elliptical mask; then Glow. qa.exec("layer.newAdjustment", json!({"name": "Grade"})); - let fx = qa.exec("effect.apply", json!({"effect": "Lumetri Color", "layers": ["Grade"]})); + let fx = qa.exec("effect.apply", json!({"effect": "Color Grading", "layers": ["Grade"]})); assert_eq!(fx["paths"], json!(["effects/#1"]), "effect.apply answers with the instance path: {fx}"); qa.tool("set_property", json!({"layer": "Grade", "path": "effects/#1/basicCorrection/tone/exposure", "value": 2})); let before = qa.frame(None, 0.5); diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 929f41553..880ad16c5 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -1,6 +1,6 @@ //! Blur & Sharpen, batch 2: edge-preserving blurs (Bilateral, Smart), per-channel and map-driven //! blurs (Channel, Compound, CC Vector), bokeh (Camera Lens Blur), radial streaks and small -//! utilities (Reduce Interlace Flicker, CC Cross Blur). +//! utilities (Reduce Interlace Flicker, Cross Blur). use effectcraft_color::luminance; use effectcraft_keyframe::Value; @@ -507,9 +507,9 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { } // --------------------------------------------------------------------------------------------- -// CC Radial Fast Blur +// Fast Radial Blur -/// Transparent pixels CC Radial Fast Blur adds on every side of a `w` × `h` buffer with Center +/// Transparent pixels Fast Radial Blur adds on every side of a `w` × `h` buffer with Center /// at `c` (buffer pixels) and smear `s`: content `r` from Center streaks out to `r / (1 − s)`, /// so the farthest corner reaches `d · s / (1 − s)` past itself (at most 4096, as the other /// growing effects). Shared with the GPU path so both grow the same way. @@ -562,7 +562,7 @@ fn radial_fast_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { } // --------------------------------------------------------------------------------------------- -// CC Vector Blur +// Vector Blur fn vector_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { let amount = ctx.params.f("amount").max(0.0) * b.scale; @@ -616,7 +616,7 @@ fn vector_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { } // --------------------------------------------------------------------------------------------- -// Reduce Interlace Flicker / CC Cross Blur +// Reduce Interlace Flicker / Cross Blur fn reduce_flicker(ctx: &EffectCtx, mut b: Buf) -> Buf { let s = ctx.params.f("softness").max(0.0) * b.scale; @@ -735,7 +735,7 @@ pub fn specs() -> Vec { ), spec( "ec.blur.ccradialfast", - "CC Radial Fast Blur", + "Fast Radial Blur", vec![ p("center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point), p("amount", "Amount", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), @@ -745,7 +745,7 @@ pub fn specs() -> Vec { ), spec( "ec.blur.ccvector", - "CC Vector Blur", + "Vector Blur", vec![ p("type", "Type", Value::Enum(0), popup(&["Natural", "Constant Length", "Perpendicular", "Direction Center", "Direction Fading"])), p("amount", "Amount", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)), @@ -765,7 +765,7 @@ pub fn specs() -> Vec { }, spec( "ec.blur.cccross", - "CC Cross Blur", + "Cross Blur", vec![ p("radiusX", "Radius X", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)), p("radiusY", "Radius Y", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)), diff --git a/crates/effects/src/blur3.rs b/crates/effects/src/blur3.rs index aee2a2713..6369a8ced 100644 --- a/crates/effects/src/blur3.rs +++ b/crates/effects/src/blur3.rs @@ -1,6 +1,6 @@ -//! Blur & Sharpen: CC Radial Blur and Camera-Shake Deblur. +//! Blur & Sharpen: Variable Radial Blur and Camera-Shake Deblur. //! -//! * **CC Radial Blur** — zoom (straight, fading, centred both ways) and rotational (plain or +//! * **Variable Radial Blur** — zoom (straight, fading, centred both ways) and rotational (plain or //! fading) blurs around a centre point, sampled along the ray / arc through each pixel with a //! sample count set by Quality. //! * **Camera-Shake Deblur** — frame-based restoration in the spirit of published video deblurring @@ -22,7 +22,7 @@ fn spec(id: &'static str, name: &'static str, params: Vec, ren EffectSpec { id, name, category: "Blur & Sharpen", params, render, gpu: false, float: true } } -// ---------------------------------------------------------------- CC Radial Blur +// ---------------------------------------------------------------- Variable Radial Blur pub const RADIAL_TYPES: [&str; 5] = ["Straight Zoom", "Fading Zoom", "Centered", "Rotate", "Rotate Fading"]; @@ -246,7 +246,7 @@ pub fn specs() -> Vec { vec![ spec( "ec.blur.ccradial", - "CC Radial Blur", + "Variable Radial Blur", vec![ p("type", "Type", Value::Enum(1), popup(&RADIAL_TYPES)), p("amount", "Amount", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), diff --git a/crates/effects/src/channel2.rs b/crates/effects/src/channel2.rs index 5be3c94e7..ef391f6f1 100644 --- a/crates/effects/src/channel2.rs +++ b/crates/effects/src/channel2.rs @@ -1,5 +1,5 @@ //! Channel effects, batch 2: layer-to-layer combinations (Blend, Calculations, Compound -//! Arithmetic) and CC Composite. Second layers come from layer parameters through the effect +//! Arithmetic) and Source Composite. Second layers come from layer parameters through the effect //! host; with no layer chosen the effect uses the layer itself, as After Effects does. use effectcraft_color::{BlendMode, blend_pixel, hsl_to_rgb, luminance, rgb_to_hsl}; @@ -69,7 +69,7 @@ fn blend(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---------------------------------------------------------------- Calculations -/// Blending modes offered by Calculations / CC Composite (menu order). +/// Blending modes offered by Calculations / Source Composite (menu order). const CALC_MODES: [BlendMode; 22] = [ BlendMode::Normal, BlendMode::Darken, @@ -150,7 +150,7 @@ fn calculations(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- CC Composite +// ---------------------------------------------------------------- Source Composite const CC_COMPOSITE_MODES: [&str; 16] = [ "In Front", @@ -321,7 +321,7 @@ pub fn specs() -> Vec { ), spec( "ec.channel.cccomposite", - "CC Composite", + "Source Composite", vec![ p("opacity", "Opacity", num(100.0), pct()), p("compositeOriginal", "Composite Original", Value::Enum(0), popup(&CC_COMPOSITE_MODES)), diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 25203cd7a..6639e649e 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -878,7 +878,7 @@ fn broadcast_colors(ctx: &EffectCtx, mut b: Buf) -> Buf { } // --------------------------------------------------------------------------------------------- -// CC Toner, CC Color Offset, CC Kernel +// Multi-Tone Tint, Channel Offset, Convolution Kernel fn cc_toner(ctx: &EffectCtx, mut b: Buf) -> Buf { let g = |id: &str| { @@ -1101,7 +1101,7 @@ pub fn specs() -> Vec { ), spec( "ec.color.cctoner", - "CC Toner", + "Multi-Tone Tint", vec![ p("tones", "Tones", Value::Enum(1), popup(&["Duotone", "Tritone", "Pentone"])), p("highlights", "Highlights", col(1.0, 1.0, 1.0), ParamUi::Color), @@ -1115,7 +1115,7 @@ pub fn specs() -> Vec { ), spec( "ec.color.cccoloroffset", - "CC Color Offset", + "Channel Offset", vec![ p("redPhase", "Red Phase", num(0.0), ParamUi::Angle), p("greenPhase", "Green Phase", num(0.0), ParamUi::Angle), @@ -1124,7 +1124,7 @@ pub fn specs() -> Vec { ], cc_color_offset, ), - spec("ec.color.cckernel", "CC Kernel", k_params, cc_kernel), + spec("ec.color.cckernel", "Convolution Kernel", k_params, cc_kernel), ] } diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index d4cdedd98..d0d398100 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -1,5 +1,5 @@ -//! Color Correction effects, batch 3: Change Color, Video Limiter, CC Color Neutralizer, -//! PS Arbitrary Map and Lumetri Color (Basic Correction, Creative, Curves, Color Wheels, +//! Color Correction effects, batch 3: Change Color, Video Limiter, Color Neutralizer, +//! PS Arbitrary Map and Color Grading (Basic Correction, Creative, Curves, Color Wheels, //! Vignette). use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl}; @@ -154,7 +154,7 @@ fn video_limiter(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- CC Color Neutralizer +// ---------------------------------------------------------------- Color Neutralizer fn color_neutralizer(ctx: &EffectCtx, mut b: Buf) -> Buf { let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")]; @@ -225,7 +225,7 @@ fn arbitrary_map(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- Lumetri Color +// ---------------------------------------------------------------- Color Grading /// Smooth tone-curve adjustment: offsets for shadows/midtones/highlights (each -1..1), with the /// end points fixed. @@ -431,7 +431,7 @@ impl HslKey { } } -/// Lumetri Color: Basic Correction (input LUT, white balance, HDR range, tone, saturation), +/// Color Grading: Basic Correction (input LUT, white balance, HDR range, tone, saturation), /// Creative (look, adjustments), Curves (tone sliders, RGB curves, hue/saturation curves), /// Color Wheels, HSL Secondary (key, refine, correction), Vignette, in that order. fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { @@ -834,7 +834,7 @@ pub fn specs() -> Vec { ), spec( "ec.color.cccolorneutralizer", - "CC Color Neutralizer", + "Color Neutralizer", vec![ p("shadowsUnbalance", "Shadows Unbalance", col(0.0, 0.0, 0.0), ParamUi::Color), p("shadowsBalance", "Shadows Balance", col(0.0, 0.0, 0.0), ParamUi::Color), @@ -860,7 +860,7 @@ pub fn specs() -> Vec { ], arbitrary_map, ), - spec("ec.color.lumetri", "Lumetri Color", lum, lumetri), + spec("ec.color.lumetri", "Color Grading", lum, lumetri), ] } diff --git a/crates/effects/src/display_names.rs b/crates/effects/src/display_names.rs new file mode 100644 index 000000000..b30c00d46 --- /dev/null +++ b/crates/effects/src/display_names.rs @@ -0,0 +1,123 @@ +//! Display naming must not change file, command or script identities. +use crate::{find, instantiate, lookup, name_matches}; +use effectcraft_project::build::Ids; + +const RENAMED: &[(&str, &str, &str)] = &[ + ("ec.sim.ccballaction", "CC Ball Action", "Ball Grid"), + ("ec.distort.ccbendit", "CC Bend It", "Two-Point Bend"), + ("ec.distort.ccbender", "CC Bender", "Flexible Bend"), + ("ec.distort.ccblobbylize", "CC Blobbylize", "Organic Displacement"), + ("ec.stylize.ccblockload", "CC Block Load", "Block Reveal"), + ("ec.sim.ccbubbles", "CC Bubbles", "Bubbles"), + ("ec.stylize.ccburnfilm", "CC Burn Film", "Film Burn"), + ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "Color Neutralizer"), + ("ec.color.cccoloroffset", "CC Color Offset", "Channel Offset"), + ("ec.channel.cccomposite", "CC Composite", "Source Composite"), + ("ec.blur.cccross", "CC Cross Blur", "Cross Blur"), + ("ec.perspective.cccylinder", "CC Cylinder", "Cylinder Wrap"), + ("ec.sim.ccdrizzle", "CC Drizzle", "Water Ripples"), + ("ec.perspective.ccenvironment", "CC Environment", "Environment Map"), + ("ec.distort.ccflomotion", "CC Flo Motion", "Dual Lens Warp"), + ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "Temporal Motion Blur"), + ("ec.stylize.ccglass", "CC Glass", "Glass Relief"), + ("ec.transition.ccglasswipe", "CC Glass Wipe", "Glass Wipe"), + ("ec.generate.ccgluegun", "CC Glue Gun", "Paint Beads"), + ("ec.transition.ccgridwipe", "CC Grid Wipe", "Grid Wipe"), + ("ec.distort.ccgriddler", "CC Griddler", "Tile Grid"), + ("ec.sim.cchair", "CC Hair", "Surface Fibers"), + ("ec.stylize.cchextile", "CC HexTile", "Hexagonal Tiles"), + ("ec.transition.ccimagewipe", "CC Image Wipe", "Luminance Image Wipe"), + ("ec.transition.ccjaws", "CC Jaws", "Serrated Wipe"), + ("ec.stylize.cckaleida", "CC Kaleida", "Kaleidoscope"), + ("ec.color.cckernel", "CC Kernel", "Convolution Kernel"), + ("ec.distort.cclens", "CC Lens", "Lens Distortion"), + ("ec.generate.cclightburst", "CC Light Burst 2.5", "Radial Light Burst"), + ("ec.generate.cclightrays", "CC Light Rays", "Light Rays"), + ("ec.generate.cclightsweep", "CC Light Sweep", "Light Sweep"), + ("ec.transition.cclightwipe", "CC Light Wipe", "Light Wipe"), + ("ec.transition.cclinesweep", "CC Line Sweep", "Line Sweep"), + ("ec.sim.ccmrmercury", "CC Mr. Mercury", "Liquid Particles"), + ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "Palette Map"), + ("ec.utility.ccoverbrights", "CC Overbrights", "Out-of-Range Display"), + ("ec.distort.ccpageturn", "CC Page Turn", "Page Curl"), + ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "Particle Emitter 2D"), + ("ec.sim.ccparticleworld", "CC Particle World", "Particle Emitter 3D"), + ("ec.sim.ccpixelpolly", "CC Pixel Polly", "Pixel Shatter"), + ("ec.stylize.ccplastic", "CC Plastic", "Plastic Shading"), + ("ec.distort.ccpowerpin", "CC Power Pin", "Perspective Pin"), + ("ec.blur.ccradial", "CC Radial Blur", "Variable Radial Blur"), + ("ec.blur.ccradialfast", "CC Radial Fast Blur", "Fast Radial Blur"), + ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "Radial Scale Wipe"), + ("ec.sim.ccrainfall", "CC Rainfall", "Rainfall"), + ("ec.stylize.ccrepetile", "CC RepeTile", "Edge Tile Repeat"), + ("ec.distort.ccripplepulse", "CC Ripple Pulse", "Pulsed Ripple"), + ("ec.transition.ccscalewipe", "CC Scale Wipe", "Scale Wipe"), + ("ec.sim.ccscatterize", "CC Scatterize", "Pixel Scatter"), + ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "Line Removal"), + ("ec.distort.ccslant", "CC Slant", "Slant"), + ("ec.distort.ccsmear", "CC Smear", "Directional Smear"), + ("ec.sim.ccsnowfall", "CC Snowfall", "Snowfall"), + ("ec.perspective.ccsphere", "CC Sphere", "Sphere Wrap"), + ("ec.distort.ccsplit", "CC Split", "Split Warp"), + ("ec.distort.ccsplit2", "CC Split 2", "Dual Split Warp"), + ("ec.perspective.ccspotlight", "CC Spotlight", "Surface Spotlight"), + ("ec.sim.ccstarburst", "CC Star Burst", "Starfield"), + ("ec.generate.ccthreads", "CC Threads", "Woven Threads"), + ("ec.stylize.ccthreshold", "CC Threshold", "Channel Threshold"), + ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "RGB Threshold"), + ("ec.distort.cctiler", "CC Tiler", "Scaled Tiles"), + ("ec.color.cctoner", "CC Toner", "Multi-Tone Tint"), + ("ec.transition.cctwister", "CC Twister", "Twist Wipe"), + ("ec.blur.ccvector", "CC Vector Blur", "Vector Blur"), + ("ec.stylize.ccvignette", "CC Vignette", "Vignette"), + ("ec.transition.ccwarpomatic", "CC WarpoMatic", "Warp Dissolve"), + ("ec.time.ccwidetime", "CC Wide Time", "Temporal Blend"), + ("ec.color.lumetri", "Lumetri Color", "Color Grading"), + ("ec.obsolete.mochashape", "Mocha shape", "Tracked Shape Matte"), + ("ec.keying.keylight", "Key Light", "Screen Color Key"), +]; + +#[test] +fn descriptive_names_keep_every_legacy_lookup_and_search() { + for &(id, old, new) in RENAMED { + let spec = find(id).unwrap(); + assert_eq!(spec.name, new, "{id}"); + for query in [id.to_string(), old.to_string(), old.to_lowercase(), new.to_string()] { + assert_eq!(lookup(&query).map(|s| s.id), Some(id), "{query}"); + } + assert!(name_matches(spec, &old.to_lowercase()), "{old}"); + assert!(name_matches(spec, &new.to_lowercase()), "{new}"); + } + for alias in ["Keylight", "Keylight (1.2)", "Key Light"] { + assert_eq!(lookup(alias).map(|s| s.id), Some("ec.keying.keylight")); + } +} + +#[test] +fn descriptive_display_names_do_not_collide() { + let mut names = std::collections::HashSet::new(); + for spec in crate::registry() { + assert!(names.insert(spec.name.to_lowercase()), "duplicate name {}", spec.name); + let label = spec.name.to_lowercase(); + assert!(!label.starts_with("cc "), "{}", spec.name); + for mark in ["lumetri", "mercury", "mocha", "keylight"] { + assert!(!label.contains(mark), "{}", spec.name); + } + } +} + +#[test] +fn legacy_instance_names_and_parameter_data_survive_refresh() { + for &(id, old, _) in RENAMED { + let spec = find(id).unwrap(); + for stored in [old.to_string(), format!("{old} 2"), "My custom instance".to_string()] { + let mut next = 1; + let mut ids = Ids(&mut next); + let mut instance = instantiate(spec, &mut ids, &stored, [128.0, 72.0]); + let before = serde_json::to_value(&instance).unwrap(); + crate::migrate::upgrade_instance(spec, &mut instance, &mut ids, [128.0, 72.0]); + assert_eq!(instance.name, stored, "{id}: never rewrite a stored instance name"); + assert_eq!(serde_json::to_value(&instance).unwrap(), before, "{id}"); + } + } +} diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 7add5fc58..bbd8738a8 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -1,6 +1,6 @@ //! Distort effects, batch 2: Turbulent Displace, Displacement Map (self as map), Optics -//! Compensation, Magnify, Mesh Warp, Bezier Warp, CC Bend It, CC Lens, CC Page Turn, CC Tiler, -//! CC Griddler. +//! Compensation, Magnify, Mesh Warp, Bezier Warp, Two-Point Bend, Lens Distortion, Page Curl, Scaled Tiles, +//! Tile Grid. //! //! Mesh Warp and Bezier Warp share a grid rasteriser: a (nx+1)×(ny+1) grid of destination points //! and matching source points is split into triangles, bucketed per output row and filled @@ -550,7 +550,7 @@ pub fn bezier_grid(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> (usize, Vec (n, dest, srcp) } -// ---------------------------------------------------------------- CC Bend It +// ---------------------------------------------------------------- Two-Point Bend fn bend_it(ctx: &EffectCtx, mut b: Buf) -> Buf { let mut beta = ctx.params.f("bend").to_radians(); @@ -606,7 +606,7 @@ fn bend_it(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- CC Lens +// ---------------------------------------------------------------- Lens Distortion fn cc_lens(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = b.to_px(ctx.params.v2("center")); @@ -629,7 +629,7 @@ fn cc_lens(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- CC Page Turn +// ---------------------------------------------------------------- Page Curl fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf { let f = b.to_px(ctx.params.v2("foldPosition")); @@ -679,7 +679,7 @@ fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- CC Tiler / CC Griddler +// ---------------------------------------------------------------- Scaled Tiles / Tile Grid fn cc_tiler(ctx: &EffectCtx, mut b: Buf) -> Buf { let s = (ctx.params.f("scale") / 100.0).max(0.001); @@ -829,7 +829,7 @@ pub fn specs() -> Vec { ), spec( "ec.distort.ccbendit", - "CC Bend It", + "Two-Point Bend", vec![ p("bend", "Bend", num(0.0), ParamUi::Angle), p("start", "Start", pt(0.5, 0.2), ParamUi::Point), @@ -840,7 +840,7 @@ pub fn specs() -> Vec { ), spec( "ec.distort.cclens", - "CC Lens", + "Lens Distortion", vec![ p("center", "Center", pt(0.5, 0.5), ParamUi::Point), p("size", "Size", num(50.0), slider(0.0, 400.0, 0.0, 200.0, 1)), @@ -850,7 +850,7 @@ pub fn specs() -> Vec { ), spec( "ec.distort.ccpageturn", - "CC Page Turn", + "Page Curl", vec![ p("foldPosition", "Fold Position", pt(0.7, 0.7), ParamUi::Point), p("foldDirection", "Fold Direction", num(315.0), ParamUi::Angle), @@ -864,7 +864,7 @@ pub fn specs() -> Vec { ), spec( "ec.distort.cctiler", - "CC Tiler", + "Scaled Tiles", vec![ p("scale", "Scale", num(50.0), slider(1.0, 10000.0, 1.0, 200.0, 1)), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), @@ -874,7 +874,7 @@ pub fn specs() -> Vec { ), spec( "ec.distort.ccgriddler", - "CC Griddler", + "Tile Grid", vec![ p("horizontalScale", "Horizontal Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)), p("verticalScale", "Vertical Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)), diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index a02ab5743..34fc282ed 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -1,5 +1,5 @@ -//! Distort effects, batch 3: CC Bender, CC Blobbylize, CC Power Pin, CC Ripple Pulse, CC Slant, -//! CC Smear, CC Split, CC Split 2, Smear and Reshape (mask driven), Twirl (Legacy), Warp (the +//! Distort effects, batch 3: Flexible Bend, Organic Displacement, Perspective Pin, Pulsed Ripple, Slant, +//! Directional Smear, Split Warp, Dual Split Warp, Smear and Reshape (mask driven), Twirl (Legacy), Warp (the //! fifteen warp styles) and Detail-preserving Upscale. //! //! All warps are inverse mapped: every output pixel finds the source point that lands on it. @@ -59,7 +59,7 @@ fn elastic_exp(i: u32) -> f64 { [1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 3.5, 4.5][(i as usize).min(7)] } -// ---------------------------------------------------------------- CC Bender +// ---------------------------------------------------------------- Flexible Bend fn cc_bender(ctx: &EffectCtx, mut b: Buf) -> Buf { let amount = ctx.params.f("amount") / 100.0; @@ -107,7 +107,7 @@ fn norm3(v: [f64; 3]) -> [f64; 3] { [v[0] / l, v[1] / l, v[2] / l] } -/// f32 normal/dot helpers for the CC Blobbylize lighting pass. The GPU kernel runs in f32; +/// f32 normal/dot helpers for the Organic Displacement lighting pass. The GPU kernel runs in f32; /// matching its precision here keeps specular highlights within the CPU/GPU parity tolerance. fn norm3f(v: [f32; 3]) -> [f32; 3] { let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); @@ -118,7 +118,7 @@ fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } -// ---------------------------------------------------------------- CC Blobbylize +// ---------------------------------------------------------------- Organic Displacement fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf { let soft = ctx.params.f("softness").max(0.0) * b.scale; @@ -221,7 +221,7 @@ fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- CC Power Pin +// ---------------------------------------------------------------- Perspective Pin fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); @@ -304,7 +304,7 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- CC Ripple Pulse +// ---------------------------------------------------------------- Pulsed Ripple fn cc_ripple_pulse(ctx: &EffectCtx, mut b: Buf) -> Buf { let level = ctx.params.f("pulseLevel") / 100.0; @@ -350,7 +350,7 @@ fn cc_ripple_pulse(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- CC Slant +// ---------------------------------------------------------------- Slant fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf { let k = ctx.params.f("slant") / 100.0; @@ -375,7 +375,7 @@ fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- CC Smear +// ---------------------------------------------------------------- Directional Smear fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { let reach = ctx.params.f("reach") / 100.0; @@ -403,7 +403,7 @@ fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { } /// The transparent pixels to add on every side of a buffer `size` pixels big (with `offset` and -/// `scale`) for CC Smear: it moves pixels by up to Reach × From→To, and they may move out to the +/// `scale`) for Directional Smear: it moves pixels by up to Reach × From→To, and they may move out to the /// layer bounds. A text or shape layer's buffer ends at its content while its bounds are /// comp-sized, so the smear was cut off at the content's box (#659). pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 { @@ -417,7 +417,7 @@ pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f6 (moved.min(4096.0) as u32).min(crate::util::layer_cover_pad(ctx, size, offset, scale)) } -// ---------------------------------------------------------------- CC Split / CC Split 2 +// ---------------------------------------------------------------- Split Warp / Dual Split Warp fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf { if s1 == 0.0 && s2 == 0.0 { @@ -913,7 +913,7 @@ pub fn specs() -> Vec { vec![ spec( "ec.distort.ccbender", - "CC Bender", + "Flexible Bend", vec![ p("amount", "Amount", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)), p("style", "Style", Value::Enum(0), popup(&["Bend", "Marilyn", "Sharp", "Boxer"])), @@ -925,7 +925,7 @@ pub fn specs() -> Vec { ), spec( "ec.distort.ccblobbylize", - "CC Blobbylize", + "Organic Displacement", vec![ p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer), p("property", "Property", Value::Enum(0), popup(&crate::util::SRC_NAMES[..8])), @@ -947,7 +947,7 @@ pub fn specs() -> Vec { ), spec( "ec.distort.ccpowerpin", - "CC Power Pin", + "Perspective Pin", vec![ p("topLeft", "Top Left", pt(0.0, 0.0), ParamUi::Point), p("topRight", "Top Right", pt(1.0, 0.0), ParamUi::Point), @@ -964,7 +964,7 @@ pub fn specs() -> Vec { ), spec( "ec.distort.ccripplepulse", - "CC Ripple Pulse", + "Pulsed Ripple", vec![ p("center", "Center", pt(0.5, 0.5), ParamUi::Point), p("pulseLevel", "Pulse Level (animate)", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), @@ -976,7 +976,7 @@ pub fn specs() -> Vec { ), spec( "ec.distort.ccslant", - "CC Slant", + "Slant", vec![ p("slant", "Slant", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), p("stretching", "Stretching", Value::Bool(false), ParamUi::Checkbox), @@ -987,7 +987,7 @@ pub fn specs() -> Vec { ), spec( "ec.distort.ccsmear", - "CC Smear", + "Directional Smear", vec![ p("from", "From", pt(0.35, 0.5), ParamUi::Point), p("to", "To", pt(0.65, 0.5), ParamUi::Point), @@ -998,7 +998,7 @@ pub fn specs() -> Vec { ), spec( "ec.distort.ccsplit", - "CC Split", + "Split Warp", vec![ p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point), p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point), @@ -1008,7 +1008,7 @@ pub fn specs() -> Vec { ), spec( "ec.distort.ccsplit2", - "CC Split 2", + "Dual Split Warp", vec![ p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point), p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point), diff --git a/crates/effects/src/distort4.rs b/crates/effects/src/distort4.rs index b724a5830..9913c6ea5 100644 --- a/crates/effects/src/distort4.rs +++ b/crates/effects/src/distort4.rs @@ -1,6 +1,6 @@ -//! Distort: CC Flo Motion, Liquify and Rolling Shutter Repair. +//! Distort: Dual Lens Warp, Liquify and Rolling Shutter Repair. //! -//! * **CC Flo Motion** — two knots that pull the image toward themselves (positive amount) or +//! * **Dual Lens Warp** — two knots that pull the image toward themselves (positive amount) or //! push it away (negative), with a falloff exponent, optional tiling and supersampled //! antialiasing. //! * **Liquify** — brush strokes build a displacement mesh (a grid of layer-space offsets). The @@ -32,7 +32,7 @@ fn spec(id: &'static str, name: &'static str, params: Vec, ren EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true } } -// ---------------------------------------------------------------- CC Flo Motion +// ---------------------------------------------------------------- Dual Lens Warp fn flo_motion(ctx: &EffectCtx, mut b: Buf) -> Buf { let knots = [(ctx.params.v2("knot1"), ctx.params.f("amount1") / 100.0), (ctx.params.v2("knot2"), ctx.params.f("amount2") / 100.0)]; @@ -560,7 +560,7 @@ pub fn specs() -> Vec { vec![ spec( "ec.distort.ccflomotion", - "CC Flo Motion", + "Dual Lens Warp", vec![ p("knot1", "Knot 1", Value::Vec2([0.25, 0.5]), ParamUi::Point), p("amount1", "Amount 1", num(50.0), slider(-400.0, 100.0, -100.0, 100.0, 1)), diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index b4781b793..ad80cb9cd 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -1,5 +1,5 @@ //! Generate effects, batch 2: Cell Pattern (Worley), Ellipse, Lens Flare, Beam, Radio Waves, -//! Advanced Lightning, CC Light Rays, CC Light Burst 2.5, CC Light Sweep. +//! Advanced Lightning, Light Rays, Radial Light Burst, Light Sweep. //! //! All designs are original procedural renderings written from the public descriptions of what //! each effect produces. @@ -897,7 +897,7 @@ fn advanced_lightning(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- CC Light Rays / Burst +// ---------------------------------------------------------------- Light Rays / Burst const RAY_STEPS: usize = 32; @@ -1007,7 +1007,7 @@ fn light_burst(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- CC Light Sweep +// ---------------------------------------------------------------- Light Sweep fn light_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = b.to_px(ctx.params.v2("center")); @@ -1192,7 +1192,7 @@ pub fn specs() -> Vec { ), spec( "ec.generate.cclightrays", - "CC Light Rays", + "Light Rays", vec![ p("intensity", "Intensity", num(100.0), slider(0.0, 600.0, 0.0, 200.0, 1)), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), @@ -1207,7 +1207,7 @@ pub fn specs() -> Vec { ), spec( "ec.generate.cclightburst", - "CC Light Burst 2.5", + "Radial Light Burst", vec![ p("center", "Center", pt(0.5, 0.5), ParamUi::Point), p("intensity", "Intensity", num(100.0), slider(0.0, 600.0, 0.0, 200.0, 1)), @@ -1220,7 +1220,7 @@ pub fn specs() -> Vec { ), spec( "ec.generate.cclightsweep", - "CC Light Sweep", + "Light Sweep", vec![ p("center", "Center", pt(0.5, 0.5), ParamUi::Point), p("direction", "Direction", num(-30.0), ang()), diff --git a/crates/effects/src/generate3/registry.rs b/crates/effects/src/generate3/registry.rs index f3e08c2d5..baa493110 100644 --- a/crates/effects/src/generate3/registry.rs +++ b/crates/effects/src/generate3/registry.rs @@ -180,7 +180,7 @@ pub fn specs() -> Vec { ), spec( "ec.generate.ccgluegun", - "CC Glue Gun", + "Paint Beads", vec![ p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), p("strokeWidth", "Stroke Width", num(25.0), slider(0.0, 500.0, 0.0, 100.0, 1)), @@ -194,7 +194,7 @@ pub fn specs() -> Vec { ), spec( "ec.generate.ccthreads", - "CC Threads", + "Woven Threads", vec![ p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), p("height", "Height", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), diff --git a/crates/effects/src/keying2.rs b/crates/effects/src/keying2.rs index a03fe804d..5512d1820 100644 --- a/crates/effects/src/keying2.rs +++ b/crates/effects/src/keying2.rs @@ -317,7 +317,7 @@ fn unmult(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---------------------------------------------------------------- CC Simple Wire Removal +// ---------------------------------------------------------------- Line Removal fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf { let pa = ctx.params.v2("pointA"); @@ -464,7 +464,7 @@ pub fn specs() -> Vec { ), spec( "ec.key.ccsimplewireremoval", - "CC Simple Wire Removal", + "Line Removal", vec![ p("pointA", "Point A", Value::Vec2([0.25, 0.25]), ParamUi::Point), p("pointB", "Point B", Value::Vec2([0.75, 0.75]), ParamUi::Point), diff --git a/crates/effects/src/keylight.rs b/crates/effects/src/keylight.rs index 84085f4e3..fb36075cb 100644 --- a/crates/effects/src/keylight.rs +++ b/crates/effects/src/keylight.rs @@ -1,6 +1,6 @@ -//! Key Light — a full screen-colour keyer with the controls of After Effects' bundled +//! Screen Color Key — a full screen-colour keyer with the controls of After Effects' bundled //! "Keylight (1.2)" (that name is a trademark of its vendor, so the effect is registered as -//! **Key Light**; `lookup("Keylight (1.2)")` finds it through [`KEYLIGHT_ALIASES`]). +//! **Screen Color Key**; `lookup("Keylight (1.2)")` finds it through [`KEYLIGHT_ALIASES`]). //! //! The method is the textbook *screen difference* key, written from the public description of //! the controls: @@ -31,7 +31,7 @@ use crate::util::{Plane, gauss_plane, morph_frac, point_in_poly, premul, unpremu use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; /// Other names the effect answers to in [`crate::lookup`]. -pub const KEYLIGHT_ALIASES: &[&str] = &["Keylight (1.2)", "Keylight"]; +pub const KEYLIGHT_ALIASES: &[&str] = &["Keylight (1.2)", "Keylight", "Key Light"]; pub const VIEWS: [&str; 11] = [ "Source", @@ -463,7 +463,7 @@ pub fn specs() -> Vec { let cc = |id: &'static str, name: &'static str| p(id, name, num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)); vec![EffectSpec { id: "ec.keying.keylight", - name: "Key Light", + name: "Screen Color Key", category: "Keying", params: vec![ p("view", "View", Value::Enum(10), popup(&VIEWS)), @@ -622,7 +622,7 @@ mod tests { let spec = crate::find("ec.keying.keylight").unwrap(); let mut next = 1; let mut ids = Ids(&mut next); - let mut g = crate::instantiate(spec, &mut ids, "Key Light", [10.0, 10.0]); + let mut g = crate::instantiate(spec, &mut ids, "Screen Color Key", [10.0, 10.0]); // Flatten it the way older versions saved it. let mut flat = vec![]; g.walk_mut(&mut |p| flat.push(p.clone())); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index bd239aa6f..0d78c3619 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -23,6 +23,8 @@ mod color3; mod color_fx; pub mod controls; pub mod crypto_select; +#[cfg(test)] +mod display_names; mod distort; mod distort2; mod distort3; @@ -36,6 +38,7 @@ mod keying; mod keying2; pub mod keylight; mod matte; + pub mod migrate; mod misc; pub mod mocha_shape; @@ -560,7 +563,7 @@ pub trait EffectHost: Sync { } /// Parameter `id` (a [`flatten_params`] id such as `birthRate`) of effect `effect_index` in /// the layer's stack (the running effect's [`EffectEnv::effect_index`]) at another **layer - /// time**: CC Particle World samples its Birth Rate at every step it simulates (#1373). + /// time**: Particle Emitter 3D samples its Birth Rate at every step it simulates (#1373). /// `None` when unavailable. fn param_at(&self, _effect_index: usize, _id: &str, _layer_time: f64) -> Option { None @@ -776,8 +779,82 @@ pub fn categories() -> Vec<&'static str> { v } -/// After Effects' third-party display names of effects we register under a generic name. -const ALIASES: &[(&str, &[&str])] = &[("ec.keying.keylight", keylight::KEYLIGHT_ALIASES)]; +/// Legacy display names accepted for saved scripts and searches. Never display these as +/// native feature names; stable effect IDs and external format match names stay unchanged. +const ALIASES: &[(&str, &[&str])] = &[ + ("ec.sim.ccballaction", &["CC Ball Action"]), + ("ec.distort.ccbendit", &["CC Bend It"]), + ("ec.distort.ccbender", &["CC Bender"]), + ("ec.distort.ccblobbylize", &["CC Blobbylize"]), + ("ec.stylize.ccblockload", &["CC Block Load"]), + ("ec.sim.ccbubbles", &["CC Bubbles"]), + ("ec.stylize.ccburnfilm", &["CC Burn Film"]), + ("ec.color.cccolorneutralizer", &["CC Color Neutralizer"]), + ("ec.color.cccoloroffset", &["CC Color Offset"]), + ("ec.channel.cccomposite", &["CC Composite"]), + ("ec.blur.cccross", &["CC Cross Blur"]), + ("ec.perspective.cccylinder", &["CC Cylinder"]), + ("ec.sim.ccdrizzle", &["CC Drizzle"]), + ("ec.perspective.ccenvironment", &["CC Environment"]), + ("ec.distort.ccflomotion", &["CC Flo Motion"]), + ("ec.time.ccforcemotionblur", &["CC Force Motion Blur"]), + ("ec.stylize.ccglass", &["CC Glass"]), + ("ec.transition.ccglasswipe", &["CC Glass Wipe"]), + ("ec.generate.ccgluegun", &["CC Glue Gun"]), + ("ec.transition.ccgridwipe", &["CC Grid Wipe"]), + ("ec.distort.ccgriddler", &["CC Griddler"]), + ("ec.sim.cchair", &["CC Hair"]), + ("ec.stylize.cchextile", &["CC HexTile"]), + ("ec.transition.ccimagewipe", &["CC Image Wipe"]), + ("ec.transition.ccjaws", &["CC Jaws"]), + ("ec.stylize.cckaleida", &["CC Kaleida"]), + ("ec.color.cckernel", &["CC Kernel"]), + ("ec.distort.cclens", &["CC Lens"]), + ("ec.generate.cclightburst", &["CC Light Burst 2.5"]), + ("ec.generate.cclightrays", &["CC Light Rays"]), + ("ec.generate.cclightsweep", &["CC Light Sweep"]), + ("ec.transition.cclightwipe", &["CC Light Wipe"]), + ("ec.transition.cclinesweep", &["CC Line Sweep"]), + ("ec.sim.ccmrmercury", &["CC Mr. Mercury"]), + ("ec.stylize.ccmrsmoothie", &["CC Mr. Smoothie"]), + ("ec.utility.ccoverbrights", &["CC Overbrights"]), + ("ec.distort.ccpageturn", &["CC Page Turn"]), + ("ec.sim.ccparticlesystems2", &["CC Particle Systems II"]), + ("ec.sim.ccparticleworld", &["CC Particle World"]), + ("ec.sim.ccpixelpolly", &["CC Pixel Polly"]), + ("ec.stylize.ccplastic", &["CC Plastic"]), + ("ec.distort.ccpowerpin", &["CC Power Pin"]), + ("ec.blur.ccradial", &["CC Radial Blur"]), + ("ec.blur.ccradialfast", &["CC Radial Fast Blur"]), + ("ec.transition.ccradialscalewipe", &["CC Radial ScaleWipe"]), + ("ec.sim.ccrainfall", &["CC Rainfall"]), + ("ec.stylize.ccrepetile", &["CC RepeTile"]), + ("ec.distort.ccripplepulse", &["CC Ripple Pulse"]), + ("ec.transition.ccscalewipe", &["CC Scale Wipe"]), + ("ec.sim.ccscatterize", &["CC Scatterize"]), + ("ec.key.ccsimplewireremoval", &["CC Simple Wire Removal"]), + ("ec.distort.ccslant", &["CC Slant"]), + ("ec.distort.ccsmear", &["CC Smear"]), + ("ec.sim.ccsnowfall", &["CC Snowfall"]), + ("ec.perspective.ccsphere", &["CC Sphere"]), + ("ec.distort.ccsplit", &["CC Split"]), + ("ec.distort.ccsplit2", &["CC Split 2"]), + ("ec.perspective.ccspotlight", &["CC Spotlight"]), + ("ec.sim.ccstarburst", &["CC Star Burst"]), + ("ec.generate.ccthreads", &["CC Threads"]), + ("ec.stylize.ccthreshold", &["CC Threshold"]), + ("ec.stylize.ccthresholdrgb", &["CC Threshold RGB"]), + ("ec.distort.cctiler", &["CC Tiler"]), + ("ec.color.cctoner", &["CC Toner"]), + ("ec.transition.cctwister", &["CC Twister"]), + ("ec.blur.ccvector", &["CC Vector Blur"]), + ("ec.stylize.ccvignette", &["CC Vignette"]), + ("ec.transition.ccwarpomatic", &["CC WarpoMatic"]), + ("ec.time.ccwidetime", &["CC Wide Time"]), + ("ec.color.lumetri", &["Lumetri Color"]), + ("ec.obsolete.mochashape", &["Mocha shape", "mocha shape"]), + ("ec.keying.keylight", keylight::KEYLIGHT_ALIASES), +]; /// Other names effect `id` answers to (in [`lookup`] and searches). pub fn aliases(id: &str) -> &'static [&'static str] { @@ -861,7 +938,7 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_ /// effects without their own table. pub const PARAM_GROUPS: &[(&str, &str)] = &[ // Color Correction: Exposure, Levels (Individual Controls), Change to Color, Colorama, - // Shadow/Highlight, Lumetri Color. + // Shadow/Highlight, Color Grading. ("master", "Master"), ("rgb", "RGB"), ("red", "Red"), @@ -990,7 +1067,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("particleExploder", "Particle Exploder"), ("affects", "Affects"), ("options", "Options"), - // Keying, Matte, Obsolete (Key Light's groups are in `keylight::GROUPS`). + // Keying, Matte, Obsolete (Screen Color Key's groups are in `keylight::GROUPS`). ("ultraSettings", "Ultra Settings"), ("additionalForeground", "Additional Foreground"), ("additionalBackground", "Additional Background"), @@ -1075,7 +1152,7 @@ fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> Option<& } /// Effects the GPU compositor (`effectcraft-gpu`) implements with the CPU effect's semantics -/// (Mercury GPU Acceleration; the Effects & Presets GPU badge). Expression controls are +/// (GPU Acceleration; the Effects & Presets GPU badge). Expression controls are /// pass-throughs and count as GPU effects too. pub const GPU_EFFECTS: &[&str] = &[ "ec.blur.gaussian", @@ -1495,7 +1572,7 @@ mod tests { assert_eq!(grow_sides(lo, hi, 20, 10), Some([0, 0, 30, 20])); } - /// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx; + /// #2261: a 20 × 20 layer with Fast Radial Blur's Center far away grows to at most 16 Mpx; /// big layers may still grow to four times their area, and small pads are untouched. #[test] fn padded_buffers_stay_within_the_budget() { diff --git a/crates/effects/src/mocha_shape.rs b/crates/effects/src/mocha_shape.rs index cf9e2bd88..c669580c6 100644 --- a/crates/effects/src/mocha_shape.rs +++ b/crates/effects/src/mocha_shape.rs @@ -1,4 +1,4 @@ -//! Matte ▸ Mocha shape: renders planar-tracked roto shapes as a matte. +//! Matte ▸ Tracked Shape Matte: renders planar-tracked roto shapes as a matte. //! //! Mocha's own clipboard / export formats are not publicly specified, so the effect reads shape //! data in a small documented JSON format of ours (in the hidden `shapeData` parameter, either the @@ -184,7 +184,7 @@ fn mocha_shape(ctx: &EffectCtx, mut b: Buf) -> Buf { pub fn specs() -> Vec { vec![EffectSpec { id: "ec.obsolete.mochashape", - name: "Mocha shape", + name: "Tracked Shape Matte", category: "Matte", params: vec![ p("blendMode", "Blend mode", Value::Enum(0), popup(&["Add", "Subtract", "Intersect", "Difference"])), diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 24c561ecd..69c63caf9 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -1,5 +1,5 @@ -//! Perspective effects (batch 2): Bevel Alpha, Bevel Edges, Radial Shadow, CC Cylinder, -//! CC Sphere and CC Spotlight. +//! Perspective effects (batch 2): Bevel Alpha, Bevel Edges, Radial Shadow, Cylinder Wrap, +//! Sphere Wrap and Surface Spotlight. //! //! Angles follow the convention used by Drop Shadow: measured clockwise from "up", so a light //! angle `a` sits in the direction `(sin a, -cos a)` (y grows downwards). 3D vectors use x right, @@ -158,7 +158,7 @@ fn radial_shadow(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -/// Phong-style shading shared by CC Cylinder and CC Sphere. +/// Phong-style shading shared by Cylinder Wrap and Sphere Wrap. struct Shading { l: [f64; 3], color: [f32; 4], @@ -451,11 +451,11 @@ pub fn specs() -> Vec { ], radial_shadow, ), - spec("ec.perspective.cccylinder", "CC Cylinder", cyl, cylinder), - spec("ec.perspective.ccsphere", "CC Sphere", sph, sphere), + spec("ec.perspective.cccylinder", "Cylinder Wrap", cyl, cylinder), + spec("ec.perspective.ccsphere", "Sphere Wrap", sph, sphere), spec( "ec.perspective.ccspotlight", - "CC Spotlight", + "Surface Spotlight", vec![ p("from", "From", pt(0.3, 0.2), ParamUi::Point), p("to", "To", pt(0.5, 0.5), ParamUi::Point), diff --git a/crates/effects/src/perspective2.rs b/crates/effects/src/perspective2.rs index 13846607f..2bc7e3e7e 100644 --- a/crates/effects/src/perspective2.rs +++ b/crates/effects/src/perspective2.rs @@ -1,4 +1,4 @@ -//! Perspective effects, batch 2: CC Environment (reflection mapping of an environment layer +//! Perspective effects, batch 2: Environment Map (reflection mapping of an environment layer //! onto the layer's luminance/alpha relief) and 3D Glasses (stereo pairs and anaglyphs from a //! left and right view layer). @@ -17,7 +17,7 @@ fn spec(id: &'static str, name: &'static str, params: Vec, ren EffectSpec { id, name, category: "Perspective", params, render, gpu: false, float: true } } -// ---------------------------------------------------------------- CC Environment +// ---------------------------------------------------------------- Environment Map /// Environment direction (x right, y down, z towards the viewer) → normalised map coordinates. fn env_uv(mapping: u32, r: [f64; 3]) -> (f64, f64) { @@ -194,7 +194,7 @@ pub fn specs() -> Vec { vec![ spec( "ec.perspective.ccenvironment", - "CC Environment", + "Environment Map", vec![ p("environment", "Environment", Value::Layer(None), ParamUi::Layer), p("mapping", "Mapping", Value::Enum(0), popup(&["Spherical", "Probe", "Vertical Cross"])), diff --git a/crates/effects/src/sim/registry.rs b/crates/effects/src/sim/registry.rs index f55b99e27..4df07c50a 100644 --- a/crates/effects/src/sim/registry.rs +++ b/crates/effects/src/sim/registry.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { vec![ spec( "ec.sim.ccrainfall", - "CC Rainfall", + "Rainfall", vec![ p("drops", "Drops", num(5000.0), slider(0.0, 100_000.0, 0.0, 20_000.0, 0)), p("size", "Size", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 2)), @@ -53,7 +53,7 @@ pub fn specs() -> Vec { ), spec( "ec.sim.ccsnowfall", - "CC Snowfall", + "Snowfall", vec![ p("flakes", "Flakes", num(10_000.0), slider(0.0, 200_000.0, 0.0, 50_000.0, 0)), p("size", "Size", num(3.0), slider(0.0, 50.0, 0.0, 20.0, 2)), @@ -79,7 +79,7 @@ pub fn specs() -> Vec { ), spec( "ec.sim.ccbubbles", - "CC Bubbles", + "Bubbles", vec![ p("bubbleAmount", "Bubble Amount", num(100.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), p("bubbleSpeed", "Bubble Speed", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 2)), @@ -93,7 +93,7 @@ pub fn specs() -> Vec { ), spec( "ec.sim.ccdrizzle", - "CC Drizzle", + "Water Ripples", vec![ p("dripRate", "Drip Rate", num(10.0), slider(0.0, 200.0, 0.0, 50.0, 2)), p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 2)), @@ -114,7 +114,7 @@ pub fn specs() -> Vec { ), spec( "ec.sim.ccstarburst", - "CC Star Burst", + "Starfield", vec![ p("scatter", "Scatter", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), p("speed", "Speed", num(1.0), slider(-10.0, 10.0, -4.0, 4.0, 2)), @@ -127,7 +127,7 @@ pub fn specs() -> Vec { ), spec( "ec.sim.ccparticleworld", - "CC Particle World", + "Particle Emitter 3D", vec![ p("birthRate", "Birth Rate", num(0.5), slider(0.0, 100.0, 0.0, 10.0, 2)), p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), @@ -170,7 +170,7 @@ pub fn specs() -> Vec { ), spec( "ec.sim.ccparticlesystems2", - "CC Particle Systems II", + "Particle Emitter 2D", vec![ p("birthRate", "Birth Rate", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), @@ -198,7 +198,7 @@ pub fn specs() -> Vec { ), spec( "ec.sim.ccmrmercury", - "CC Mr. Mercury", + "Liquid Particles", vec![ p("radiusX", "Radius X", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), p("radiusY", "Radius Y", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), diff --git a/crates/effects/src/sim2/registry.rs b/crates/effects/src/sim2/registry.rs index 333916a16..dc64b7097 100644 --- a/crates/effects/src/sim2/registry.rs +++ b/crates/effects/src/sim2/registry.rs @@ -79,7 +79,7 @@ pub fn specs() -> Vec { vec![ spec( "ec.sim.ccballaction", - "CC Ball Action", + "Ball Grid", vec![ p("scatter", "Scatter", num(0.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), p("rotationAxis", "Rotation Axis", Value::Enum(0), popup(&["X Axis", "Y Axis", "Z Axis", "XY Axis", "XZ Axis", "YZ Axis", "XYZ Axis"])), @@ -94,7 +94,7 @@ pub fn specs() -> Vec { ), spec( "ec.sim.ccpixelpolly", - "CC Pixel Polly", + "Pixel Shatter", vec![ p("force", "Force", num(100.0), slider(-1000.0, 1000.0, -300.0, 300.0, 1)), p("gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), @@ -111,7 +111,7 @@ pub fn specs() -> Vec { ), spec( "ec.sim.ccscatterize", - "CC Scatterize", + "Pixel Scatter", vec![ p("scatter", "Scatter", num(0.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), p("rightTwist", "Right Twist", num(0.0), ParamUi::Angle), diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index 88a16d981..c2a286390 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -1,4 +1,4 @@ -//! Simulation: Particle Playground and CC Hair. +//! Simulation: Particle Playground and Surface Fibers. //! //! **Particle Playground** is a deterministic 2D particle system stepped from layer time 0 at //! [`SPS`] steps per second through [`SimCache`] (seeking anywhere gives the same frame as @@ -17,7 +17,7 @@ //! being rendered (their pixels are part of the cache key). Particles replace the layer's own //! pixels, as in After Effects. //! -//! **CC Hair** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a +//! **Surface Fibers** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a //! quadratic curve of Length that droops with Weight, optionally steered and scaled by a hairfall //! map layer (luminance → length, horizontal/vertical gradient → lean), and shades them with a //! Kajiya–Kay-style strand model (diffuse ∝ sin(tangent, light), specular from the half vector). @@ -934,7 +934,7 @@ impl PgPlan { } } -// ---------------------------------------------------------------- CC Hair +// ---------------------------------------------------------------- Surface Fibers fn cc_hair(ctx: &EffectCtx, b: Buf) -> Buf { let Some(mut plan) = hair_plan(ctx, &b) else { return b }; @@ -942,7 +942,7 @@ fn cc_hair(ctx: &EffectCtx, b: Buf) -> Buf { plan.finish(b) } -/// CC Hair's strands as line sprites (each coloured from its root, see [`Tint::Root`]); +/// Surface Fibers's strands as line sprites (each coloured from its root, see [`Tint::Root`]); /// `None` = no hair (the layer passes through). pub(crate) fn hair_plan(ctx: &EffectCtx, b: &Buf) -> Option { let pr = ctx.params; @@ -1144,7 +1144,7 @@ pub fn specs() -> Vec { spec("ec.sim.particleplayground", "Particle Playground", pg, particle_playground), spec( "ec.sim.cchair", - "CC Hair", + "Surface Fibers", vec![ sp("length", "Length", px(30.0, 200.0)), p("thickness", "Thickness", num(1.0), slider(0.05, 20.0, 0.1, 5.0, 2)), diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index 94eda6370..247854352 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -343,7 +343,7 @@ fn brush_strokes(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---- CC Kaleida ---- +// ---- Kaleidoscope ---- fn kaleida(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = b.to_px(ctx.params.v2("center")); @@ -368,7 +368,7 @@ fn kaleida(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---- CC RepeTile ---- +// ---- Edge Tile Repeat ---- #[inline] fn tile_coord(v: f64, size: f64) -> (i64, f64) { @@ -491,7 +491,7 @@ fn color_emboss(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---- CC Vignette ---- +// ---- Vignette ---- fn vignette(ctx: &EffectCtx, mut b: Buf) -> Buf { let amt = ctx.params.f("amount") as f32 / 100.0; @@ -523,7 +523,7 @@ fn vignette(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ---- CC Threshold / CC Threshold RGB ---- +// ---- Channel Threshold / RGB Threshold ---- fn cc_threshold(ctx: &EffectCtx, mut b: Buf) -> Buf { let t = ctx.params.f("threshold") as f32 / 255.0; @@ -669,7 +669,7 @@ pub fn specs() -> Vec { ), spec( "ec.stylize.cckaleida", - "CC Kaleida", + "Kaleidoscope", vec![ p("center", "Center", pt(0.5, 0.5), ParamUi::Point), p("size", "Size", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)), @@ -680,7 +680,7 @@ pub fn specs() -> Vec { ), spec( "ec.stylize.ccrepetile", - "CC RepeTile", + "Edge Tile Repeat", vec![ p("expandRight", "Expand Right", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0)), p("expandLeft", "Expand Left", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0)), @@ -718,7 +718,7 @@ pub fn specs() -> Vec { ), spec( "ec.stylize.ccvignette", - "CC Vignette", + "Vignette", vec![ p("amount", "Amount", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), p("angleOfView", "Angle of View", num(60.0), slider(0.0, 179.0, 0.0, 179.0, 1)), @@ -729,7 +729,7 @@ pub fn specs() -> Vec { ), spec( "ec.stylize.ccthreshold", - "CC Threshold", + "Channel Threshold", vec![ p("threshold", "Threshold", num(127.0), thr()), p("channel", "Channel", Value::Enum(0), popup(&["Luminance", "RGB", "Saturation", "Alpha"])), @@ -740,7 +740,7 @@ pub fn specs() -> Vec { ), spec( "ec.stylize.ccthresholdrgb", - "CC Threshold RGB", + "RGB Threshold", vec![ p("redThreshold", "Red Threshold", num(127.0), thr()), p("greenThreshold", "Green Threshold", num(127.0), thr()), diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs index e8a141cf4..1121f737b 100644 --- a/crates/effects/src/stylize3.rs +++ b/crates/effects/src/stylize3.rs @@ -1,5 +1,5 @@ -//! Stylize effects, batch 3: CC Block Load, CC Burn Film, CC Glass, CC HexTile, CC Mr. Smoothie -//! and CC Plastic. +//! Stylize effects, batch 3: Block Reveal, Film Burn, Glass Relief, Hexagonal Tiles, Palette Map +//! and Plastic Shading. //! //! Written from the public descriptions of what these effects do (progressive block loading, //! burning film, bump-mapped glass / plastic surfaces lit by an effect light, hexagonal tiling, @@ -27,7 +27,7 @@ fn pt(x: f64, y: f64) -> Value { Value::Vec2([x, y]) } -// ------------------------------------------------------------------ CC Block Load +// ------------------------------------------------------------------ Block Reveal /// Mean colour of every `s`×`s` block (grid of `ceil(w/s)`×`ceil(h/s)`). fn block_means(img: &Image, s: usize) -> (usize, usize, Vec) { @@ -135,7 +135,7 @@ fn block_load(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ------------------------------------------------------------------ CC Burn Film +// ------------------------------------------------------------------ Film Burn fn burn_film(ctx: &EffectCtx, mut b: Buf) -> Buf { let burn = (ctx.params.f("burn") / 100.0).clamp(0.0, 1.0) as f32; @@ -180,7 +180,7 @@ fn burn_film(ctx: &EffectCtx, mut b: Buf) -> Buf { const BUMP_PROPS: [&str; 6] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness"]; -/// The bump-map layer parameter: CC Glass's Bump Map, CC Plastic's Bump Layer. +/// The bump-map layer parameter: Glass Relief's Bump Map, Plastic Shading's Bump Layer. pub fn bump_layer_id(ctx: &EffectCtx) -> &'static str { if ctx.params.get("bumpLayer").is_some() { "bumpLayer" } else { "bumpMap" } } @@ -208,7 +208,7 @@ fn height_field(ctx: &EffectCtx, b: &Buf) -> Plane { pl } -/// The effect light and surface of CC Glass / CC Plastic (shared with the GPU compositor). +/// The effect light and surface of Glass Relief / Plastic Shading (shared with the GPU compositor). pub struct BumpLight { pub intensity: f32, pub color: [f32; 3], @@ -342,7 +342,7 @@ fn plastic(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ------------------------------------------------------------------ CC HexTile +// ------------------------------------------------------------------ Hexagonal Tiles /// Axial hex cell (pointy-top) containing (x, y) for circumradius `r`; returns the cell centre /// and integer cell coordinates. @@ -418,7 +418,7 @@ fn hextile(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ------------------------------------------------------------------ CC Mr. Smoothie +// ------------------------------------------------------------------ Palette Map const SMOOTHIE_PROPS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness", "Hue", "Saturation"]; @@ -518,7 +518,7 @@ pub fn specs() -> Vec { vec![ spec( "ec.stylize.ccblockload", - "CC Block Load", + "Block Reveal", vec![ p("completion", "Completion", num(0.0), pct()), p("scanlines", "Scanlines", Value::Bool(true), ParamUi::Checkbox), @@ -530,7 +530,7 @@ pub fn specs() -> Vec { ), spec( "ec.stylize.ccburnfilm", - "CC Burn Film", + "Film Burn", vec![ p("burn", "Burn", num(0.0), pct()), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), @@ -538,10 +538,10 @@ pub fn specs() -> Vec { ], burn_film, ), - spec("ec.stylize.ccglass", "CC Glass", glass_p, glass), + spec("ec.stylize.ccglass", "Glass Relief", glass_p, glass), spec( "ec.stylize.cchextile", - "CC HexTile", + "Hexagonal Tiles", vec![ p("render", "Render", Value::Enum(0), popup(&["Normal", "Mirrored", "Rotated"])), p("radius", "Radius", num(50.0), slider(1.0, 4000.0, 2.0, 500.0, 1)), @@ -553,7 +553,7 @@ pub fn specs() -> Vec { ), spec( "ec.stylize.ccmrsmoothie", - "CC Mr. Smoothie", + "Palette Map", vec![ p("flowLayer", "Flow Layer", Value::Layer(None), ParamUi::Layer), p("property", "Property", Value::Enum(4), popup(&SMOOTHIE_PROPS)), @@ -565,7 +565,7 @@ pub fn specs() -> Vec { ], smoothie, ), - spec("ec.stylize.ccplastic", "CC Plastic", plastic_p, plastic), + spec("ec.stylize.ccplastic", "Plastic Shading", plastic_p, plastic), ] } diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index 79b0ce479..b9d20e2a7 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -1,9 +1,9 @@ //! Time effects (Effect > Time): Echo, Posterize Time, Time Difference, Time Displacement, -//! Timewarp, CC Force Motion Blur, CC Wide Time and Pixel Motion Blur. +//! Timewarp, Temporal Motion Blur, Temporal Blend and Pixel Motion Blur. //! //! These read the layer at *other* layer times through [`EffectHost::self_at`]: its source with //! its masks and the effects applied before them, as After Effects renders them (SceneYard: -//! Echo after CC Particle Systems II echoes the fireworks, not the black solid under them; +//! Echo after Particle Emitter 2D echoes the fireworks, not the black solid under them; //! Posterize Time after Turbulent Displace steps the displacement). Timewarp is the exception: //! it retimes the layer's source with its masks and ignores the effects above it (After //! Effects 2026: a red solid with a Gradient Ramp and then Timewarp shows the red solid). @@ -123,7 +123,7 @@ pub fn specs() -> Vec { ), spec( "ec.time.ccforcemotionblur", - "CC Force Motion Blur", + "Temporal Motion Blur", vec![ p("motionBlurLevels", "Motion Blur Levels", num(8.0), slider(2.0, 64.0, 2.0, 32.0, 0)), p("overrideShutterAngle", "Override Shutter Angle", Value::Bool(true), ParamUi::Checkbox), @@ -134,7 +134,7 @@ pub fn specs() -> Vec { ), spec( "ec.time.ccwidetime", - "CC Wide Time", + "Temporal Blend", vec![ p("forwardSteps", "Forward Steps", num(3.0), slider(0.0, 45.0, 0.0, 15.0, 0)), p("backwardSteps", "Backward Steps", num(3.0), slider(0.0, 45.0, 0.0, 15.0, 0)), @@ -994,7 +994,7 @@ fn matte_image(m: &[f32], like: &Image) -> Image { out } -// ---------------------------------------------------------------- CC Force Motion Blur / CC Wide Time / Pixel Motion Blur +// ---------------------------------------------------------------- Temporal Motion Blur / Temporal Blend / Pixel Motion Blur fn force_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf { let n = ctx.params.f("motionBlurLevels").round().clamp(2.0, 64.0) as usize; diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index e465ffd1b..da3e85694 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -1,5 +1,5 @@ //! Transition effects (batch 2): Block Dissolve, Gradient Wipe (gradient layer or self), Iris Wipe, Card -//! Wipe (3D cards with camera, lighting and jitter), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe. +//! Wipe (3D cards with camera, lighting and jitter), Grid Wipe, Radial Scale Wipe, Scale Wipe and Light Wipe. //! //! Every transition is the identity at 0 % completion. Angles are clockwise from "up". @@ -595,7 +595,7 @@ pub fn specs() -> Vec { ), spec( "ec.transition.ccgridwipe", - "CC Grid Wipe", + "Grid Wipe", vec![ cc_done(), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), @@ -609,13 +609,13 @@ pub fn specs() -> Vec { ), spec( "ec.transition.ccradialscalewipe", - "CC Radial ScaleWipe", + "Radial Scale Wipe", vec![cc_done(), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), p("reverse", "Reverse Transition", Value::Bool(false), ParamUi::Checkbox)], radial_scale_wipe, ), spec( "ec.transition.ccscalewipe", - "CC Scale Wipe", + "Scale Wipe", vec![ p("stretch", "Stretch", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), @@ -625,7 +625,7 @@ pub fn specs() -> Vec { ), spec( "ec.transition.cclightwipe", - "CC Light Wipe", + "Light Wipe", vec![ cc_done(), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), diff --git a/crates/effects/src/transition2.rs b/crates/effects/src/transition2.rs index 2e1738b28..6f19a660b 100644 --- a/crates/effects/src/transition2.rs +++ b/crates/effects/src/transition2.rs @@ -1,5 +1,5 @@ -//! Transition effects, batch 2: CC Glass Wipe, CC Image Wipe, CC Jaws, CC Line Sweep, -//! CC Twister and CC WarpoMatic. +//! Transition effects, batch 2: Glass Wipe, Luminance Image Wipe, Serrated Wipe, Line Sweep, +//! Twist Wipe and Warp Dissolve. //! //! Written from the public descriptions of these transitions (gradient-driven wipes with glass //! refraction or warping, jagged "jaws" opening, staggered line sweeps, a ribbon twist that @@ -68,7 +68,7 @@ fn grad(pl: &Plane, x: usize, y: usize) -> (f32, f32) { ((pl.get_clamped(xi + 1, yi) - pl.get_clamped(xi - 1, yi)) * 0.5, (pl.get_clamped(xi, yi + 1) - pl.get_clamped(xi, yi - 1)) * 0.5) } -// ------------------------------------------------------------------ CC Glass Wipe +// ------------------------------------------------------------------ Glass Wipe fn glass_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = completion(ctx); @@ -105,7 +105,7 @@ fn glass_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ------------------------------------------------------------------ CC Image Wipe +// ------------------------------------------------------------------ Luminance Image Wipe fn image_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = completion(ctx); @@ -132,7 +132,7 @@ fn image_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ------------------------------------------------------------------ CC Jaws +// ------------------------------------------------------------------ Serrated Wipe fn jaws(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = completion(ctx) as f64; @@ -193,7 +193,7 @@ fn jaws(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ------------------------------------------------------------------ CC Line Sweep +// ------------------------------------------------------------------ Line Sweep fn line_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = completion(ctx) as f64; @@ -235,7 +235,7 @@ fn line_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ------------------------------------------------------------------ CC Twister +// ------------------------------------------------------------------ Twist Wipe fn twister(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = completion(ctx) as f64; @@ -283,7 +283,7 @@ fn twister(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -// ------------------------------------------------------------------ CC WarpoMatic +// ------------------------------------------------------------------ Warp Dissolve fn warpomatic(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = completion(ctx); @@ -350,7 +350,7 @@ pub fn specs() -> Vec { vec![ spec( "ec.transition.ccglasswipe", - "CC Glass Wipe", + "Glass Wipe", vec![ done(), p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer), @@ -362,7 +362,7 @@ pub fn specs() -> Vec { ), spec( "ec.transition.ccimagewipe", - "CC Image Wipe", + "Luminance Image Wipe", vec![ done(), p("borderSoftness", "Border Softness", num(0.0), pct()), @@ -376,7 +376,7 @@ pub fn specs() -> Vec { ), spec( "ec.transition.ccjaws", - "CC Jaws", + "Serrated Wipe", vec![ done(), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), @@ -389,7 +389,7 @@ pub fn specs() -> Vec { ), spec( "ec.transition.cclinesweep", - "CC Line Sweep", + "Line Sweep", vec![ done(), p("direction", "Direction", num(0.0), ParamUi::Angle), @@ -401,7 +401,7 @@ pub fn specs() -> Vec { ), spec( "ec.transition.cctwister", - "CC Twister", + "Twist Wipe", vec![ done(), p("backside", "Backside", Value::Layer(None), ParamUi::Layer), @@ -413,7 +413,7 @@ pub fn specs() -> Vec { ), spec( "ec.transition.ccwarpomatic", - "CC WarpoMatic", + "Warp Dissolve", vec![ done(), p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer), diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index e87c6d3ff..fca9d607e 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -365,7 +365,7 @@ pub fn specs() -> Vec { spec("ec.utility.growbounds", "Grow Bounds", vec![p("pixels", "Pixels", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0))], grow_bounds), spec( "ec.utility.ccoverbrights", - "CC Overbrights", + "Out-of-Range Display", vec![ p("channel", "Channel", Value::Enum(0), popup(&["Max RGB", "Red", "Green", "Blue", "Alpha", "Luminance"])), p("highlightColor", "Highlight Color", col(1.0, 0.0, 0.0), ParamUi::Color), diff --git a/crates/engine/src/background/offload.rs b/crates/engine/src/background/offload.rs index f9038accb..b28527f02 100644 --- a/crates/engine/src/background/offload.rs +++ b/crates/engine/src/background/offload.rs @@ -375,7 +375,7 @@ pub(crate) fn job_accel() -> Option> { } /// Render options of an analysis's input frames: 100 %; with an accelerator, the project's -/// renderer (Mercury GPU Acceleration: its GPU effects on the device; Software Only: the CPU). +/// renderer (GPU Acceleration: its GPU effects on the device; Software Only: the CPU). pub(crate) fn analysis_opts(accel: Option<&dyn Accelerator>) -> RenderOpts { RenderOpts { scale: 1.0, backend: if accel.is_some() { Backend::Auto } else { Backend::Cpu }, ..Default::default() } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f5bae86d0..8129759c5 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -719,8 +719,8 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { Ok(serde_json::to_value(&s.project.settings).unwrap_or_default()) } -/// Video Rendering and Effects: report or set the renderer (Mercury GPU Acceleration when a GPU -/// adapter exists, else Mercury Software Only). +/// Video Rendering and Effects: report or set the renderer (GPU Acceleration when a GPU +/// adapter exists, else Software Rendering). fn render_backend(s: &mut Session, p: &Value) -> Result { if let Some(b) = p.get("backend").or_else(|| p.get("renderer")) { let gpu = match b { @@ -748,14 +748,14 @@ pub fn backend_status(s: &Session) -> Value { let gpu = s.project.settings.gpu_acceleration; // Why it renders on the CPU (#262). let why = if !gpu { - Some("the project's renderer is Mercury Software Only".to_string()) + Some("the project's renderer is Software Rendering".to_string()) } else if adapter.is_none() { Some(s.accel_note.clone().unwrap_or_else(|| "no GPU compositor is attached".into())) } else { None }; json!({ - "renderer": if gpu { "Mercury GPU Acceleration" } else { "Mercury Software Only" }, + "renderer": if gpu { "GPU Acceleration" } else { "Software Rendering" }, "gpuAcceleration": gpu, "adapter": adapter, "active": if gpu && adapter.is_some() { "gpu" } else { "cpu" }, diff --git a/crates/engine/src/cmd/panels_cmds.rs b/crates/engine/src/cmd/panels_cmds.rs index 40241e6f4..95bc57c3c 100644 --- a/crates/engine/src/cmd/panels_cmds.rs +++ b/crates/engine/src/cmd/panels_cmds.rs @@ -1,4 +1,4 @@ -//! Commands behind the Progress, Media Browser, Metadata and Lumetri Scopes panels. +//! Commands behind the Progress, Media Browser, Metadata and Video Scopes panels. use effectcraft_project::ItemId; use effectcraft_raster::Image; @@ -159,7 +159,7 @@ fn project_comment(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -// ---------------------------------------------------------------- Lumetri Scopes +// ---------------------------------------------------------------- Video Scopes /// The comp frame the scopes read (the viewer's image: over the comp background), at most /// `max_side` pixels on the long side. @@ -257,7 +257,7 @@ pub fn specs() -> Vec { cmd!("project.setProjectComment", "Project Comment", [], None, "{comment}", always, project_comment), query!( "scopes.analyze", - "Lumetri Scopes", + "Video Scopes", "{comp?, time?|frame?, scope?: waveformRgb|waveformLuma|waveformYc|vectorscopeYuv|vectorscopeHls|histogram|paradeRgb|paradeYuv, standard?: rec601|rec709|rec2020, float?, clamp?, size?}", scopes_analyze ), diff --git a/crates/engine/src/home/demo.rs b/crates/engine/src/home/demo.rs index 7503178fa..ec1c353bc 100644 --- a/crates/engine/src/home/demo.rs +++ b/crates/engine/src/home/demo.rs @@ -238,7 +238,7 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp { anim(&mut cam, "cameraOptions/focusDistance", keys(&[(0.0, Value::Scalar(1700.0)), (5.0, Value::Scalar(1900.0)), (10.0, Value::Scalar(1650.0))])); // Key light: warm spot casting soft shadows. Fill: cool ambient. - let mut key = build::layer(p, &c, "Key Light", LayerSource::Light { kind: effectcraft_project::LightKind::Spot }, (1920, 1080), None); + let mut key = build::layer(p, &c, "Screen Color Key", LayerSource::Light { kind: effectcraft_project::LightKind::Spot }, (1920, 1080), None); set(&mut key, "transform/position", Value::Vec3([500.0, -500.0, -700.0])); set(&mut key, "transform/poi", Value::Vec3([960.0, 600.0, 300.0])); set(&mut key, "lightOptions/intensity", Value::Scalar(150.0)); diff --git a/crates/engine/src/home/learn.rs b/crates/engine/src/home/learn.rs index cea729368..9c89b2dbf 100644 --- a/crates/engine/src/home/learn.rs +++ b/crates/engine/src/home/learn.rs @@ -259,7 +259,7 @@ pub fn tutorials() -> Vec { target: Some("menu.Layer"), accepts: &["layer.newLight"], when: Value::Null, - show: vec![("layer.newLight", json!({"kind": "Spot", "name": "Key Light"}))], + show: vec![("layer.newLight", json!({"kind": "Spot", "name": "Screen Color Key"}))], }), step(S { title: "Add a camera", diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index ed52d6e03..70acbd1fb 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -1263,7 +1263,7 @@ Window Essential Graphics | window.panel {"panel":"essentialGraphics"} Info | window.panel {"panel":"info"} | Cmd+2 Learn | help.inAppTutorials - Lumetri Scopes | window.panel {"panel":"lumetriScopes"} + Video Scopes | window.panel {"panel":"lumetriScopes"} Mask Interpolation | window.panel {"panel":"maskInterpolation"} Media Browser | window.panel {"panel":"mediaBrowser"} Metadata | window.panel {"panel":"metadata"} diff --git a/crates/engine/src/menus/tests.rs b/crates/engine/src/menus/tests.rs index 913a0e92e..8de3cc6dc 100644 --- a/crates/engine/src/menus/tests.rs +++ b/crates/engine/src/menus/tests.rs @@ -23,6 +23,25 @@ fn platform_entries(mac: bool) -> Vec<(Vec, MenuEntry)> { out } +/// Guard the generated display tree, not command IDs, format tokens or dynamic user names. +#[test] +fn builtin_menu_labels_do_not_reintroduce_vendor_feature_names() { + for mac in [true, false] { + let entries = platform_entries(mac); + for (path, entry) in &entries { + for label in path.iter().chain(std::iter::once(&entry.label)) { + let folded = label.to_lowercase(); + assert!(!folded.starts_with("cc "), "{label}"); + for mark in ["lumetri", "mercury", "mocha", "keylight"] { + assert!(!folded.contains(mark), "{label}"); + } + } + } + let scopes = entries.iter().find(|(_, e)| e.command == "window.panel" && e.params["panel"] == "lumetriScopes").unwrap(); + assert_eq!(scopes.1.label, "Video Scopes"); + } +} + #[test] fn top_level_order_matches_after_effects() { let mac: Vec = diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index 634d84e02..2c1557c91 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -439,7 +439,7 @@ const RIGGED: &str = r#"{ ], "cameras": [{"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.6, "znear": 0.1}}], "extensions": {"KHR_lights_punctual": {"lights": [ - {"type": "spot", "name": "Key Light", "color": [1, 0.8, 0.6], "intensity": 1.5, "spot": {"innerConeAngle": 0.25, "outerConeAngle": 0.5}}, + {"type": "spot", "name": "Screen Color Key", "color": [1, 0.8, 0.6], "intensity": 1.5, "spot": {"innerConeAngle": 0.25, "outerConeAngle": 0.5}}, {"type": "point", "name": "Fill Light", "intensity": 0.5, "range": 4} ]}} }"#; @@ -478,7 +478,7 @@ fn cameras_and_lights_from_model() { let r = s.execute("light.fromModel", json!({})).unwrap(); let ls: Vec = r["layers"].as_array().unwrap().iter().map(|v| layer_in(&s, cid(&s), v.as_u64().unwrap())).collect(); assert_eq!(ls.len(), 2); - let key = ls.iter().find(|l| l.name == "Key Light").unwrap(); + let key = ls.iter().find(|l| l.name == "Screen Color Key").unwrap(); assert_eq!(key.source, LayerSource::Light { kind: LightKind::Spot }); assert!((key.props.prop("lightOptions/coneAngle").unwrap().value.as_f64() - 1.0f64.to_degrees()).abs() < 1e-6); assert!((key.props.prop("lightOptions/coneFeather").unwrap().value.as_f64() - 50.0).abs() < 1e-6); diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 7d65061ea..b37c5ca3e 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -1221,7 +1221,35 @@ fn render_backend_says_why_it_renders_on_the_cpu() { assert_eq!(why(&mut s), json!("no GPU compositor is attached")); s.accel_note = Some("headless: renders on the CPU unless started with --gpu".into()); assert_eq!(why(&mut s), json!("headless: renders on the CPU unless started with --gpu")); - assert_eq!(s.execute("render.backend", json!({"backend": "cpu"})).unwrap()["why"], json!("the project's renderer is Mercury Software Only")); + assert_eq!(s.execute("render.backend", json!({"backend": "cpu"})).unwrap()["why"], json!("the project's renderer is Software Rendering")); +} + +/// Display labels are descriptive; machine-readable settings remain the same. +#[test] +fn renderer_labels_keep_their_stable_machine_values() { + let mut s = Session::default(); + for (backend, label, gpu) in [("cpu", "Software Rendering", false), ("gpu", "GPU Acceleration", true)] { + let status = s.execute("render.backend", json!({"backend": backend})).unwrap(); + assert_eq!(status["renderer"], json!(label)); + assert_eq!(status["gpuAcceleration"], json!(gpu)); + assert_eq!(status["active"], json!("cpu"), "the headless test has no GPU adapter"); + } +} + +/// The label `render.backend` reports is one it accepts back, so a caller can round-trip the +/// status instead of guessing the command's own values. +#[test] +fn renderer_status_labels_round_trip_through_the_command() { + let mut s = Session::default(); + for (backend, label, gpu) in [("cpu", "Software Rendering", false), ("gpu", "GPU Acceleration", true)] { + let reported = s.execute("render.backend", json!({"backend": backend})).unwrap()["renderer"].clone(); + assert_eq!(reported, json!(label)); + let parsed = effectcraft_render::Backend::parse(reported.as_str().unwrap()).expect("the reported renderer parses back"); + assert_eq!(parsed == effectcraft_render::Backend::Cpu, !gpu, "{reported}"); + let again = s.execute("render.backend", json!({"backend": reported})).unwrap(); + assert_eq!(again["renderer"], reported); + assert_eq!(again["gpuAcceleration"], json!(gpu)); + } } /// Settings ▸ GPU Information names what the GPU compositor runs on, or why it is off, and the diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index 0f271c59d..1eb8b546f 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -21,10 +21,13 @@ pub enum Backend { } impl Backend { + /// The backend `s` names, whatever case it is written in: the project's own values (`cpu`, + /// `gpu`, `auto`), the labels Project Settings ▸ Video Rendering and Effects draws + /// (`GPU Acceleration`, `Software Rendering`) and the older After Effects wording. pub fn parse(s: &str) -> Option { match s.to_ascii_lowercase().as_str() { - "cpu" | "software" | "mercury software only" => Some(Backend::Cpu), - "gpu" | "mercury gpu acceleration" => Some(Backend::Gpu), + "cpu" | "software" | "mercury software only" | "software rendering" => Some(Backend::Cpu), + "gpu" | "mercury gpu acceleration" | "gpu acceleration" => Some(Backend::Gpu), "auto" => Some(Backend::Auto), _ => None, } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 50ed65c73..59fa0fadc 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -329,7 +329,7 @@ fn card_dance_comp_camera_matches_the_default_view() { assert!(img.get(10, 50)[3] < 0.01); } -/// A black 2D solid with CC Particle World, under a camera layer panned right by `pan` px. +/// A black 2D solid with Particle Emitter 3D, under a camera layer panned right by `pan` px. fn particle_world_scene(pan: f64) -> (Project, ItemId) { let (mut p, cid, comp) = setup(); let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100); @@ -359,7 +359,7 @@ fn particles_x(img: &crate::Image) -> f32 { sx / m } -/// #397: CC Particle World on a 2D layer is seen through the comp's camera layer, and the layer +/// #397: Particle Emitter 3D on a 2D layer is seen through the comp's camera layer, and the layer /// cache notices the camera moving. #[test] fn particle_world_follows_the_comp_camera() { @@ -374,7 +374,7 @@ fn particle_world_follows_the_comp_camera() { assert!(b < a - 20.0, "panning the camera right moves the particles left: {a} -> {b}"); } -/// A black solid with CC Particle World whose Birth Rate is 3 until layer time `cut`, then 0 a +/// A black solid with Particle Emitter 3D whose Birth Rate is 3 until layer time `cut`, then 0 a /// frame later (`None`: 3 throughout); the layer starts at comp time `start`, stretched `stretch` %. fn burst_scene(cut: Option, start: f64, stretch: f64) -> (Project, ItemId) { burst_scene_keys(cut, start, stretch, false) @@ -624,7 +624,7 @@ fn a_text_expression_selector_is_not_served_from_the_disk_cache() { /// A Birth Rate expression reading another layer: changing that layer changes the history but /// not the current rate (0) nor this layer's properties, which is all the layer cache key sees. -/// CC Particle World layers with expressions therefore aren't layer cached. +/// Particle Emitter 3D layers with expressions therefore aren't layer cached. #[test] fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cache() { let scene = |opacity: f64| { @@ -657,8 +657,8 @@ fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cach assert!(light(&b) > 1.0 && light(&b) < light(&a), "fewer births: {} vs {}", light(&b), light(&a)); } -/// A grey solid (CC Mr. Mercury shades its blobs from it) with effect `id` (CC Particle Systems -/// II, CC Mr. Mercury): Birth Rate `rate`, +/// A grey solid (Liquid Particles shades its blobs from it) with effect `id` (CC Particle Systems +/// II, Liquid Particles): Birth Rate `rate`, /// or keyed 3 until 0.5 s and 0 a frame later (`None`); Longevity 1 s. fn system_scene(id: &str, rate: Option) -> (Project, ItemId) { let (mut p, cid, comp) = setup(); @@ -677,7 +677,7 @@ fn system_scene(id: &str, rate: Option) -> (Project, ItemId) { (p, cid) } -/// #1373 for CC Particle Systems II and CC Mr. Mercury through the renderer: a keyed burst +/// #1373 for Particle Emitter 2D and Liquid Particles through the renderer: a keyed burst /// draws as the constant rate during it, still draws after the rate drops to 0 (unlike a rate /// of 0 throughout), and draws as nothing born once Longevity has run out. #[test] @@ -1057,6 +1057,27 @@ fn comp_frame_ends_the_frame_when_the_render_panics() { assert_eq!(a.ends.load(Ordering::SeqCst), 1); } +/// The renderer labels Project Settings ▸ Video Rendering and Effects draws parse back as the +/// backend they name (in any case), and the older spellings still work. +#[test] +fn renderer_labels_parse_back_and_keep_the_older_spellings() { + use crate::Backend; + for (label, backend) in [ + ("GPU Acceleration", Backend::Gpu), + ("gpu acceleration", Backend::Gpu), + ("Mercury GPU Acceleration", Backend::Gpu), + ("gpu", Backend::Gpu), + ("Software Rendering", Backend::Cpu), + ("software rendering", Backend::Cpu), + ("Mercury Software Only", Backend::Cpu), + ("cpu", Backend::Cpu), + ("Auto", Backend::Auto), + ] { + assert_eq!(Backend::parse(label), Some(backend), "{label}"); + } + assert_eq!(Backend::parse("Vulkan"), None); +} + #[test] fn auto_backend_sends_2d_and_3d_comps_to_the_gpu() { let (mut p, cid, comp) = setup(); @@ -1223,11 +1244,11 @@ fn effects_on_text_reach_past_the_text_bounds() { } assert!( painted > 50 && hi.saturating_sub(lo) > 100, - "CC Tiler tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}" + "Scaled Tiles tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}" ); } -/// #658: CC Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past +/// #658: Directional Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past /// the painted rectangle used to fall off its edge, so the liquid trail never appeared. #[test] fn cc_smear_trails_text_past_its_bounds() { @@ -1246,7 +1267,7 @@ fn cc_smear_trails_text_past_its_bounds() { ); p.comp_mut(cid).unwrap().layers = vec![l].into(); let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0)); - assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}"); + assert!(plain > 40 && smeared > plain + 25, "Directional Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}"); } /// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the @@ -1502,7 +1523,7 @@ fn solo_excludes_other_layers_outside_the_soloed_layers_range() { assert_eq!(second.get(100, 50), [0.0, 0.0, 1.0, 1.0], "only B inside its range"); } -/// #1252: a layer blended with a CC Particle World layer, under a camera panned by `pan` px. +/// #1252: a layer blended with a Particle Emitter 3D layer, under a camera panned by `pan` px. fn scene_reader_dependency(pan: f64) -> (Project, ItemId) { let (mut p, cid, comp) = setup(); let mut source = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index c8ada9bf8..f126db257 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -124,7 +124,7 @@ impl PanelKind { PanelKind::EssentialGraphics => "Essential Graphics", PanelKind::ScriptPanel(_) => "ScriptUI Panel", PanelKind::Viewer(_) => "Composition", - PanelKind::LumetriScopes => "Lumetri Scopes", + PanelKind::LumetriScopes => "Video Scopes", PanelKind::Footage => "Footage", PanelKind::MediaBrowser => "Media Browser", PanelKind::Metadata => "Metadata", @@ -709,6 +709,16 @@ mod tests { w.apply(action, permissions).unwrap(); } + /// Only the title changes: existing saved workspace layouts still deserialize. + #[test] + fn scopes_label_is_descriptive_and_its_saved_identity_stays_compatible() { + let panel: PanelKind = serde_json::from_str("\"LumetriScopes\"").unwrap(); + assert_eq!(panel, LumetriScopes); + assert_eq!(panel.title(), "Video Scopes"); + assert_eq!(panel.id(), "LumetriScopes"); + assert_eq!(serde_json::to_string(&panel).unwrap(), "\"LumetriScopes\""); + } + #[test] fn after_effects_workspaces_exist_with_their_core_panels() { for name in WORKSPACES { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 68e58f842..c998538d4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1054,7 +1054,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "基本图形"), ("window.panel", "Info", "信息"), ("help.inAppTutorials", "Learn", "学习"), - ("window.panel", "Lumetri Scopes", "Lumetri 范围"), + ("window.panel", "Video Scopes", "视频示波器"), ("window.panel", "Mask Interpolation", "蒙版插值"), ("window.panel", "Media Browser", "媒体浏览器"), ("window.panel", "Metadata", "元数据"), @@ -1690,7 +1690,7 @@ const SWEDISH: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Grundläggande grafik"), ("window.panel", "Info", "Info"), ("help.inAppTutorials", "Learn", "Lär dig"), - ("window.panel", "Lumetri Scopes", "Lumetri-scope"), + ("window.panel", "Video Scopes", "Videoskop"), ("window.panel", "Mask Interpolation", "Maskinterpolering"), ("window.panel", "Media Browser", "Mediebläddrare"), ("window.panel", "Metadata", "Metadata"), @@ -2336,7 +2336,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "基本圖形"), ("window.panel", "Info", "資訊"), ("help.inAppTutorials", "Learn", "學習"), - ("window.panel", "Lumetri Scopes", "Lumetri 示波器"), + ("window.panel", "Video Scopes", "視訊示波器"), ("window.panel", "Mask Interpolation", "遮色片插補"), ("window.panel", "Media Browser", "媒體瀏覽器"), ("window.panel", "Metadata", "中繼資料"), @@ -2975,7 +2975,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "エッセンシャルグラフィックス"), ("window.panel", "Info", "情報"), ("help.inAppTutorials", "Learn", "学習"), - ("window.panel", "Lumetri Scopes", "Lumetriスコープ"), + ("window.panel", "Video Scopes", "ビデオスコープ"), ("window.panel", "Mask Interpolation", "マスク補間"), ("window.panel", "Media Browser", "メディアブラウザー"), ("window.panel", "Metadata", "メタデータ"), @@ -3617,7 +3617,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Основна графіка"), ("window.panel", "Info", "Інформація"), ("help.inAppTutorials", "Learn", "Навчання"), - ("window.panel", "Lumetri Scopes", "Вимірювачі Lumetri"), + ("window.panel", "Video Scopes", "Відеоскопи"), ("window.panel", "Mask Interpolation", "Інтерполяція маски"), ("window.panel", "Media Browser", "Браузер медіа"), ("window.panel", "Metadata", "Метадані"), @@ -4260,7 +4260,7 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Gráficos esenciales"), ("window.panel", "Info", "Información"), ("help.inAppTutorials", "Learn", "Aprender"), - ("window.panel", "Lumetri Scopes", "Ámbitos de Lumetri"), + ("window.panel", "Video Scopes", "Monitores de vídeo"), ("window.panel", "Mask Interpolation", "Interpolación de máscara"), ("window.panel", "Media Browser", "Navegador de medios"), ("window.panel", "Metadata", "Metadatos"), @@ -4901,7 +4901,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Gráficos essenciais"), ("window.panel", "Info", "Informações"), ("help.inAppTutorials", "Learn", "Aprender"), - ("window.panel", "Lumetri Scopes", "Escopos Lumetri"), + ("window.panel", "Video Scopes", "Escopos de vídeo"), ("window.panel", "Mask Interpolation", "Interpolação de máscara"), ("window.panel", "Media Browser", "Navegador de mídia"), ("window.panel", "Metadata", "Metadados"), @@ -5544,7 +5544,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Основная графика"), ("window.panel", "Info", "Информация"), ("help.inAppTutorials", "Learn", "Обучение"), - ("window.panel", "Lumetri Scopes", "Измерители Lumetri"), + ("window.panel", "Video Scopes", "Видеоскопы"), ("window.panel", "Mask Interpolation", "Интерполяция маски"), ("window.panel", "Media Browser", "Обозреватель медиа"), ("window.panel", "Metadata", "Метаданные"), @@ -6185,7 +6185,7 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Graphiques essentiels"), ("window.panel", "Info", "Informations"), ("help.inAppTutorials", "Learn", "Apprentissage"), - ("window.panel", "Lumetri Scopes", "Oscilloscopes Lumetri"), + ("window.panel", "Video Scopes", "Oscilloscopes vidéo"), ("window.panel", "Mask Interpolation", "Interpolation de masque"), ("window.panel", "Media Browser", "Navigateur multimédia"), ("window.panel", "Metadata", "Métadonnées"), @@ -6826,7 +6826,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Essential-Grafik"), ("window.panel", "Info", "Info"), ("help.inAppTutorials", "Learn", "Lernen"), - ("window.panel", "Lumetri Scopes", "Lumetri-Scopes"), + ("window.panel", "Video Scopes", "Videoskope"), ("window.panel", "Mask Interpolation", "Maskeninterpolation"), ("window.panel", "Media Browser", "Medienbrowser"), ("window.panel", "Metadata", "Metadaten"), @@ -7464,7 +7464,7 @@ const POLISH: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Essential Graphics"), ("window.panel", "Info", "Informacje"), ("help.inAppTutorials", "Learn", "Nauka"), - ("window.panel", "Lumetri Scopes", "Zakresy Lumetri"), + ("window.panel", "Video Scopes", "Wskaźniki wideo"), ("window.panel", "Mask Interpolation", "Interpolacja maski"), ("window.panel", "Media Browser", "Przeglądarka multimediów"), ("window.panel", "Metadata", "Metadane"), @@ -8106,7 +8106,7 @@ const CZECH: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Základní grafika"), ("window.panel", "Info", "Informace"), ("help.inAppTutorials", "Learn", "Výuka"), - ("window.panel", "Lumetri Scopes", "Měřicí přístroje Lumetri"), + ("window.panel", "Video Scopes", "Videoskopy"), ("window.panel", "Mask Interpolation", "Interpolace masek"), ("window.panel", "Media Browser", "Prohlížeč médií"), ("window.panel", "Metadata", "Metadata"), @@ -8748,7 +8748,7 @@ const GEORGIAN: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "ძირითადი გრაფიკა"), ("window.panel", "Info", "ინფორმაცია"), ("help.inAppTutorials", "Learn", "სწავლა"), - ("window.panel", "Lumetri Scopes", "Lumetri Scopes"), + ("window.panel", "Video Scopes", "ვიდეოსკოპები"), ("window.panel", "Mask Interpolation", "ნიღბის ინტერპოლაცია"), ("window.panel", "Media Browser", "მედიის ბრაუზერი"), ("window.panel", "Metadata", "მეტამონაცემები"), @@ -8799,6 +8799,21 @@ mod tests { use serde_json::json; use std::collections::BTreeSet; + #[test] + fn renamed_menu_translations_do_not_reintroduce_vendor_names() { + for code in CATALOG_CODES { + set_current(code); + for (id, source) in [("window.panel", "Video Scopes")] { + let rendered = command_label(id, source); + let label = rendered.to_lowercase(); + for mark in ["lumetri", "mercury"] { + assert!(!label.contains(mark), "{code}: {id} -> {rendered}"); + } + } + } + set_current("en"); + } + /// The four options at the foot of the header's workspace menu say what After Effects says, /// read from its workspace button on 2026-10-31: reset, save over this one, save a copy, then /// edit the list. diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index ec952ccb0..55893115a 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -18,14 +18,19 @@ //! another effect. An effect with no row draws the registry's English name; the //! `missing_translations_report` test (run by `cargo xtask ci`) lists those per language. //! -//! A language decides for itself which names stay English: After Effects' Japanese interface -//! leaves the `CC` series in English and gives the `VR` and `OCIO` sets a Japanese tail -//! (`VR ブラー`, `OCIO CDL 変換`), while its Simplified Chinese one translates them (the `CC` -//! names come from Adobe's Chinese *documentation*, which names 68 of the 69). Repeating the -//! English name is therefore a language-level choice, not a missing row. +//! Own feature names are descriptive. The renamed effects use original translations in every +//! existing effect catalog; legacy vendor names remain search aliases in the effects crate. +//! External format tokens and stored instance names are not localized or rewritten. //! -//! The Simplified Chinese names come from the installed After Effects 2026 (26.5.0.89) -//! Simplified Chinese interface, its 21 category folder names included; `Cryptomatte`, +//! The rows of the effects this cleanup did not rename keep the provenance they had. A language +//! decides for itself which names stay English: After Effects' Japanese interface leaves the `CC` +//! series in English and gives the `VR` and `OCIO` sets a Japanese tail (`VR ブラー`, +//! `OCIO CDL 変換`), while its Simplified Chinese one translates them. Repeating the English name +//! is therefore a language-level choice, not a missing row. +//! +//! The Simplified Chinese names of unchanged effects come from the installed After Effects 2026 +//! (26.5.0.89) Simplified Chinese interface, its 21 category folder names included (the `CC` names +//! came from Adobe's Chinese *documentation*, which names 68 of the 69); `Cryptomatte`, //! `EXtractoR`, `IDentifier`, `Key Light` and `Unmult` stay in English as brand names, and //! `Face Measurements` / `Face Track Points` are EffectCraft's own effects, so their wording is //! ours. @@ -33,16 +38,15 @@ //! The Traditional Chinese names for Taiwan are written from the meaning of the English ones, with //! the terms Taiwan's creative software uses (`圖層`, `遮罩`, `筆刷`, `雜訊`, `發光`, `漸層`); they //! are not converted from the Simplified table. Brand and coined names (`Cryptomatte`, -//! `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`, `Colorama`, `Minimax`, -//! `Vegas`, `Mocha`) stay in English; the `CC` series keeps its prefix and translates the -//! descriptive tail, and the `VR` and `OCIO` sets keep their prefix in Chinese word order. +//! `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Colorama`, `Minimax`, `Vegas`) stay in +//! English, and the `VR` and `OCIO` sets keep their prefix in Chinese word order. //! //! The French names are written from the meaning of the English ones, with the vocabulary of the //! French menus; they come from no other application's interface. Brand and coined names -//! (`Cryptomatte`, `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`, `Colorama`, -//! `Minimax`, `Tritone`, `Vibrance`, `Vegas`) and the whole `CC` series stay in English; a series -//! tag that prefixes a descriptive name (`VR`, `OCIO`, `PS`, `HDR`) stays and follows the -//! French word order (`Flou VR`, `Transformation CDL OCIO`). +//! (`Cryptomatte`, `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Colorama`, `Minimax`, +//! `Tritone`, `Vibrance`, `Vegas`) stay in English; a series tag that prefixes a descriptive name +//! (`VR`, `OCIO`, `PS`, `HDR`) stays and follows the French word order (`Flou VR`, +//! `Transformation CDL OCIO`). use std::collections::HashMap; use std::sync::OnceLock; @@ -122,19 +126,21 @@ pub(super) fn translated_rows(language: &str) -> usize { /// The name to draw for an effect group the property tree stores as `stored`: the catalog name, /// plus the instance index the engine appends to a repeat (`Gaussian Blur 2` → `高斯模糊 2`). /// -/// A name that is neither the registry's nor the registry's plus an index was typed by the user -/// (Effect Controls ▸ Rename), so it stays verbatim — as does the whole string in a language -/// with no row. +/// Current and legacy default names (including numeric instance suffixes) draw the current +/// name. Other names were supplied by the user and stay verbatim. Stored data is never changed. pub(crate) fn group(id: &str, stored: &str) -> String { - let Some((english, translated)) = catalog(current()).and_then(|c| c.row(id)) else { return stored.to_string() }; - if stored == english { - return translated.to_string(); - } - if let Some(index) = stored.strip_prefix(english).and_then(|rest| rest.strip_prefix(' ')) - && !index.is_empty() - && index.bytes().all(|b| b.is_ascii_digit()) - { - return format!("{translated} {index}"); + let Some(spec) = effectcraft_engine::effects::find(id) else { return stored.to_string() }; + let translated = name(id, spec.name); + for source in std::iter::once(spec.name).chain(effectcraft_engine::effects::aliases(id).iter().copied()) { + if stored == source { + return translated; + } + if let Some(index) = stored.strip_prefix(source).and_then(|rest| rest.strip_prefix(' ')) + && !index.is_empty() + && index.bytes().all(|b| b.is_ascii_digit()) + { + return format!("{translated} {index}"); + } } stored.to_string() } @@ -149,6 +155,7 @@ pub(crate) fn category(source: &str) -> String { /// `query` (lowercase)? Both names match, so a translated interface can be searched either way. pub(crate) fn matches(id: &str, english: &str, query: &str) -> bool { english.to_lowercase().contains(query) + || effectcraft_engine::effects::find(id).is_some_and(|spec| effectcraft_engine::effects::name_matches(spec, query)) || catalog(current()).and_then(|c| c.row(id)).is_some_and(|(_, translated)| translated.to_lowercase().contains(query)) } @@ -192,10 +199,10 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "双向模糊"), ("ec.blur.cameralens", "Camera Lens Blur", "摄像机镜头模糊"), ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "摄像机抖动去模糊"), - ("ec.blur.cccross", "CC Cross Blur", "CC 交叉模糊"), - ("ec.blur.ccradial", "CC Radial Blur", "CC 径向模糊"), - ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC 径向快速模糊"), - ("ec.blur.ccvector", "CC Vector Blur", "CC 矢量模糊"), + ("ec.blur.cccross", "Cross Blur", "十字模糊"), + ("ec.blur.ccradial", "Variable Radial Blur", "可变径向模糊"), + ("ec.blur.ccradialfast", "Fast Radial Blur", "快速径向模糊"), + ("ec.blur.ccvector", "Vector Blur", "矢量模糊"), ("ec.blur.channel", "Channel Blur", "通道模糊"), ("ec.blur.compound", "Compound Blur", "复合模糊"), ("ec.blur.directional", "Directional Blur", "定向模糊"), @@ -209,7 +216,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.channel.arithmetic", "Arithmetic", "算术"), ("ec.channel.blend", "Blend", "混合"), ("ec.channel.calculations", "Calculations", "计算"), - ("ec.channel.cccomposite", "CC Composite", "CC 合成"), + ("ec.channel.cccomposite", "Source Composite", "源图合成"), ("ec.channel.combiner", "Channel Combiner", "通道合成器"), ("ec.channel.compoundarithmetic", "Compound Arithmetic", "复合运算"), ("ec.channel.invert", "Invert", "反转"), @@ -225,10 +232,10 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.color.blackwhite", "Black & White", "黑色和白色"), ("ec.color.brightnesscontrast", "Brightness & Contrast", "亮度和对比度"), ("ec.color.broadcast", "Broadcast Colors", "广播颜色"), - ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC 颜色中和器"), - ("ec.color.cccoloroffset", "CC Color Offset", "CC 颜色偏移"), - ("ec.color.cckernel", "CC Kernel", "CC 内核"), - ("ec.color.cctoner", "CC Toner", "CC 调色器"), + ("ec.color.cccolorneutralizer", "Color Neutralizer", "色偏校正"), + ("ec.color.cccoloroffset", "Channel Offset", "通道偏移"), + ("ec.color.cckernel", "Convolution Kernel", "卷积核"), + ("ec.color.cctoner", "Multi-Tone Tint", "多色调着色"), ("ec.color.changecolor", "Change Color", "更改颜色"), ("ec.color.changetocolor", "Change to Color", "更改为颜色"), ("ec.color.channelmixer", "Channel Mixer", "通道混合器"), @@ -245,7 +252,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.color.leavecolor", "Leave Color", "保留颜色"), ("ec.color.levels", "Levels", "色阶"), ("ec.color.levelsic", "Levels (Individual Controls)", "色阶 (单独控件)"), - ("ec.color.lumetri", "Lumetri Color", "Lumetri 颜色"), + ("ec.color.lumetri", "Color Grading", "色彩分级"), ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL 变换"), ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO 颜色空间变换"), ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO 显示变换"), @@ -270,20 +277,20 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.control.stereo3d", "Stereo 3D Controls", "立体 3D 控制"), ("ec.distort.bezierwarp", "Bezier Warp", "贝塞尔曲线变形"), ("ec.distort.bulge", "Bulge", "凸出"), - ("ec.distort.ccbender", "CC Bender", "CC 弯曲器"), - ("ec.distort.ccbendit", "CC Bend It", "CC 弯曲"), - ("ec.distort.ccblobbylize", "CC Blobbylize", "CC 融化"), - ("ec.distort.ccflomotion", "CC Flo Motion", "CC 流动运动"), - ("ec.distort.ccgriddler", "CC Griddler", "CC 网格生成器"), - ("ec.distort.cclens", "CC Lens", "CC 镜头"), - ("ec.distort.ccpageturn", "CC Page Turn", "CC 翻页"), - ("ec.distort.ccpowerpin", "CC Power Pin", "CC 强力边角定位"), - ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC 波纹脉冲"), - ("ec.distort.ccslant", "CC Slant", "CC 倾斜"), - ("ec.distort.ccsmear", "CC Smear", "CC 漩涡条纹"), - ("ec.distort.ccsplit", "CC Split", "CC 拆分"), - ("ec.distort.ccsplit2", "CC Split 2", "CC 拆分 2"), - ("ec.distort.cctiler", "CC Tiler", "CC 拼贴器"), + ("ec.distort.ccbender", "Flexible Bend", "柔性弯曲"), + ("ec.distort.ccbendit", "Two-Point Bend", "两点弯曲"), + ("ec.distort.ccblobbylize", "Organic Displacement", "有机置换"), + ("ec.distort.ccflomotion", "Dual Lens Warp", "双镜头变形"), + ("ec.distort.ccgriddler", "Tile Grid", "平铺网格"), + ("ec.distort.cclens", "Lens Distortion", "镜头畸变"), + ("ec.distort.ccpageturn", "Page Curl", "页面卷曲"), + ("ec.distort.ccpowerpin", "Perspective Pin", "透视定位"), + ("ec.distort.ccripplepulse", "Pulsed Ripple", "脉冲涟漪"), + ("ec.distort.ccslant", "Slant", "倾斜"), + ("ec.distort.ccsmear", "Directional Smear", "定向涂抹"), + ("ec.distort.ccsplit", "Split Warp", "分割变形"), + ("ec.distort.ccsplit2", "Dual Split Warp", "双重分割变形"), + ("ec.distort.cctiler", "Scaled Tiles", "缩放平铺"), ("ec.distort.cornerpin", "Corner Pin", "边角定位"), ("ec.distort.displacementmap", "Displacement Map", "置换图"), ("ec.distort.liquify", "Liquify", "液化"), @@ -311,11 +318,11 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.generate.audiospectrum", "Audio Spectrum", "音频频谱"), ("ec.generate.audiowaveform", "Audio Waveform", "音频波形"), ("ec.generate.beam", "Beam", "光束"), - ("ec.generate.ccgluegun", "CC Glue Gun", "CC 胶水枪"), - ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC 光束放射 2.5"), - ("ec.generate.cclightrays", "CC Light Rays", "CC 光线"), - ("ec.generate.cclightsweep", "CC Light Sweep", "CC 光线扫描"), - ("ec.generate.ccthreads", "CC Threads", "CC 编织条"), + ("ec.generate.ccgluegun", "Paint Beads", "颜料珠"), + ("ec.generate.cclightburst", "Radial Light Burst", "径向光芒"), + ("ec.generate.cclightrays", "Light Rays", "光线"), + ("ec.generate.cclightsweep", "Light Sweep", "扫光"), + ("ec.generate.ccthreads", "Woven Threads", "编织线"), ("ec.generate.cellpattern", "Cell Pattern", "单元格图案"), ("ec.generate.checkerboard", "Checkerboard", "棋盘"), ("ec.generate.circle", "Circle", "圆形"), @@ -334,7 +341,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.generate.vegas", "Vegas", "勾画"), ("ec.generate.writeon", "Write-on", "写入"), ("ec.key.advancedspill", "Advanced Spill Suppressor", "高级溢出抑制器"), - ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC 简单丝线移除"), + ("ec.key.ccsimplewireremoval", "Line Removal", "线条移除"), ("ec.key.colordifference", "Color Difference Key", "颜色差值键"), ("ec.key.colorkey", "Color Key", "颜色键"), ("ec.key.colorrange", "Color Range", "颜色范围"), @@ -347,7 +354,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.key.screen", "Screen Key", "屏幕键"), ("ec.key.spill", "Spill Suppressor", "溢出抑制"), ("ec.key.unmult", "Unmult", "Unmult"), - ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.keying.keylight", "Screen Color Key", "屏幕色键"), ("ec.matte.mattechoker", "Matte Choker", "遮罩阻塞工具"), ("ec.matte.objectmatte", "Object Matte", "对象遮罩"), ("ec.matte.refinehard", "Refine Hard Matte", "调整实边遮罩"), @@ -371,50 +378,50 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "基本文字"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "高斯模糊 (旧版)"), ("ec.obsolete.lightning", "Lightning", "闪电"), - ("ec.obsolete.mochashape", "Mocha shape", "Mocha 形状"), + ("ec.obsolete.mochashape", "Tracked Shape Matte", "跟踪形状遮罩"), ("ec.obsolete.pathtext", "Path Text", "路径文本"), ("ec.paint.paint", "Paint", "绘画"), ("ec.perspective.3dglasses", "3D Glasses", "3D 眼镜"), ("ec.perspective.bevelalpha", "Bevel Alpha", "斜面 Alpha"), ("ec.perspective.beveledges", "Bevel Edges", "边缘斜面"), ("ec.perspective.cameratracker", "3D Camera Tracker", "3D 摄像机跟踪器"), - ("ec.perspective.cccylinder", "CC Cylinder", "CC 圆柱体"), - ("ec.perspective.ccenvironment", "CC Environment", "CC 环境"), - ("ec.perspective.ccsphere", "CC Sphere", "CC 球体"), - ("ec.perspective.ccspotlight", "CC Spotlight", "CC 聚光"), + ("ec.perspective.cccylinder", "Cylinder Wrap", "圆柱包裹"), + ("ec.perspective.ccenvironment", "Environment Map", "环境映射"), + ("ec.perspective.ccsphere", "Sphere Wrap", "球面包裹"), + ("ec.perspective.ccspotlight", "Surface Spotlight", "表面聚光灯"), ("ec.perspective.dropshadow", "Drop Shadow", "投影"), ("ec.perspective.radialshadow", "Radial Shadow", "径向阴影"), ("ec.sim.carddance", "Card Dance", "卡片动画"), ("ec.sim.caustics", "Caustics", "焦散"), - ("ec.sim.ccballaction", "CC Ball Action", "CC 球动作"), - ("ec.sim.ccbubbles", "CC Bubbles", "CC 气泡"), - ("ec.sim.ccdrizzle", "CC Drizzle", "CC 细雨波纹"), - ("ec.sim.cchair", "CC Hair", "CC 毛发"), - ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC 水银流体"), - ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC 粒子系统 II"), - ("ec.sim.ccparticleworld", "CC Particle World", "CC 粒子世界"), - ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC 像素多边形"), - ("ec.sim.ccrainfall", "CC Rainfall", "CC 降雨"), - ("ec.sim.ccscatterize", "CC Scatterize", "CC 散射"), - ("ec.sim.ccsnowfall", "CC Snowfall", "CC 降雪"), - ("ec.sim.ccstarburst", "CC Star Burst", "CC 星爆"), + ("ec.sim.ccballaction", "Ball Grid", "球体网格"), + ("ec.sim.ccbubbles", "Bubbles", "气泡"), + ("ec.sim.ccdrizzle", "Water Ripples", "水面涟漪"), + ("ec.sim.cchair", "Surface Fibers", "表面纤维"), + ("ec.sim.ccmrmercury", "Liquid Particles", "液体粒子"), + ("ec.sim.ccparticlesystems2", "Particle Emitter 2D", "二维粒子发射器"), + ("ec.sim.ccparticleworld", "Particle Emitter 3D", "三维粒子发射器"), + ("ec.sim.ccpixelpolly", "Pixel Shatter", "像素破碎"), + ("ec.sim.ccrainfall", "Rainfall", "降雨"), + ("ec.sim.ccscatterize", "Pixel Scatter", "像素散射"), + ("ec.sim.ccsnowfall", "Snowfall", "降雪"), + ("ec.sim.ccstarburst", "Starfield", "星空"), ("ec.sim.foam", "Foam", "泡沫"), ("ec.sim.particleplayground", "Particle Playground", "粒子运动场"), ("ec.sim.shatter", "Shatter", "碎片"), ("ec.sim.waveworld", "Wave World", "波形环境"), ("ec.stylize.brushstrokes", "Brush Strokes", "画笔描边"), ("ec.stylize.cartoon", "Cartoon", "卡通"), - ("ec.stylize.ccblockload", "CC Block Load", "CC 块加载"), - ("ec.stylize.ccburnfilm", "CC Burn Film", "CC 烧毁胶片"), - ("ec.stylize.ccglass", "CC Glass", "CC 玻璃"), - ("ec.stylize.cchextile", "CC HexTile", "CC 六边形拼贴"), - ("ec.stylize.cckaleida", "CC Kaleida", "CC 万花筒"), - ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC 像素溶解"), - ("ec.stylize.ccplastic", "CC Plastic", "CC 塑料"), - ("ec.stylize.ccrepetile", "CC RepeTile", "CC 重复拼贴"), - ("ec.stylize.ccthreshold", "CC Threshold", "CC 阈值"), - ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC 阈值 RGB"), - ("ec.stylize.ccvignette", "CC Vignette", "CC 暗角"), + ("ec.stylize.ccblockload", "Block Reveal", "块状显现"), + ("ec.stylize.ccburnfilm", "Film Burn", "胶片灼烧"), + ("ec.stylize.ccglass", "Glass Relief", "玻璃浮雕"), + ("ec.stylize.cchextile", "Hexagonal Tiles", "六边形平铺"), + ("ec.stylize.cckaleida", "Kaleidoscope", "万花筒"), + ("ec.stylize.ccmrsmoothie", "Palette Map", "调色板映射"), + ("ec.stylize.ccplastic", "Plastic Shading", "塑料着色"), + ("ec.stylize.ccrepetile", "Edge Tile Repeat", "边缘平铺重复"), + ("ec.stylize.ccthreshold", "Channel Threshold", "通道阈值"), + ("ec.stylize.ccthresholdrgb", "RGB Threshold", "RGB 阈值"), + ("ec.stylize.ccvignette", "Vignette", "晕影"), ("ec.stylize.coloremboss", "Color Emboss", "彩色浮雕"), ("ec.stylize.emboss", "Emboss", "浮雕"), ("ec.stylize.findedges", "Find Edges", "查找边缘"), @@ -429,8 +436,8 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.stylize.threshold", "Threshold", "阈值"), ("ec.text.numbers", "Numbers", "编号"), ("ec.text.timecode", "Timecode", "时间码"), - ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC 强制运动模糊"), - ("ec.time.ccwidetime", "CC Wide Time", "CC 宽泛时间"), + ("ec.time.ccforcemotionblur", "Temporal Motion Blur", "时间运动模糊"), + ("ec.time.ccwidetime", "Temporal Blend", "时间混合"), ("ec.time.echo", "Echo", "残影"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "像素运动模糊"), ("ec.time.posterizetime", "Posterize Time", "抽帧"), @@ -439,23 +446,23 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.time.timewarp", "Timewarp", "时间扭曲"), ("ec.transition.blockdissolve", "Block Dissolve", "块溶解"), ("ec.transition.cardwipe", "Card Wipe", "卡片擦除"), - ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC 玻璃擦除"), - ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC 网格擦除"), - ("ec.transition.ccimagewipe", "CC Image Wipe", "CC 图像擦除"), - ("ec.transition.ccjaws", "CC Jaws", "CC 锯齿"), - ("ec.transition.cclightwipe", "CC Light Wipe", "CC 光线擦除"), - ("ec.transition.cclinesweep", "CC Line Sweep", "CC 线条扫掠"), - ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC 径向缩放擦除"), - ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC 缩放擦除"), - ("ec.transition.cctwister", "CC Twister", "CC 扭曲器"), - ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC 自动扭曲"), + ("ec.transition.ccglasswipe", "Glass Wipe", "玻璃擦除"), + ("ec.transition.ccgridwipe", "Grid Wipe", "网格擦除"), + ("ec.transition.ccimagewipe", "Luminance Image Wipe", "亮度图像擦除"), + ("ec.transition.ccjaws", "Serrated Wipe", "锯齿擦除"), + ("ec.transition.cclightwipe", "Light Wipe", "光线擦除"), + ("ec.transition.cclinesweep", "Line Sweep", "线条扫过"), + ("ec.transition.ccradialscalewipe", "Radial Scale Wipe", "径向缩放擦除"), + ("ec.transition.ccscalewipe", "Scale Wipe", "缩放擦除"), + ("ec.transition.cctwister", "Twist Wipe", "扭转擦除"), + ("ec.transition.ccwarpomatic", "Warp Dissolve", "变形溶解"), ("ec.transition.gradientwipe", "Gradient Wipe", "渐变擦除"), ("ec.transition.iriswipe", "Iris Wipe", "光圈擦除"), ("ec.transition.linearwipe", "Linear Wipe", "线性擦除"), ("ec.transition.radialwipe", "Radial Wipe", "径向擦除"), ("ec.transition.venetian", "Venetian Blinds", "百叶窗"), ("ec.utility.applylut", "Apply Color LUT", "应用颜色 LUT"), - ("ec.utility.ccoverbrights", "CC Overbrights", "CC 高光溢出"), + ("ec.utility.ccoverbrights", "Out-of-Range Display", "超出范围显示"), ("ec.utility.cineon", "Cineon Converter", "Cineon 转换器"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "颜色配置文件转换器"), ("ec.utility.facemeasurements", "Face Measurements", "面部测量"), @@ -480,9 +487,9 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ /// [`SIMPLIFIED_CHINESE`]. Written from the meaning of the English names with the vocabulary /// Taiwan's creative software uses (圖層, 遮罩, 筆刷, 雜訊, 發光, 漸層), not converted from the /// Simplified table. Brand and coined names (`Cryptomatte`, `EXtractoR`, `IDentifier`, `Key Light`, -/// `Unmult`, `Lumetri Color`, `Colorama`, `Minimax`, `Vegas`, `Mocha`) stay in English; the `CC` -/// series keeps its prefix and translates the descriptive tail, and the `VR` and `OCIO` sets keep -/// their prefix in Chinese word order. +/// `Unmult`, `Colorama`, `Minimax`, `Vegas`) stay in English; the `VR` and `OCIO` sets keep their +/// prefix in Chinese word order. The effects this cleanup renamed are translated from their new +/// descriptive names. const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.3d.channelextract", "3D Channel Extract", "3D 通道擷取"), ("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"), @@ -509,10 +516,10 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "雙邊模糊"), ("ec.blur.cameralens", "Camera Lens Blur", "相機鏡頭模糊"), ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "相機晃動去模糊"), - ("ec.blur.cccross", "CC Cross Blur", "CC 十字模糊"), - ("ec.blur.ccradial", "CC Radial Blur", "CC 徑向模糊"), - ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC 徑向快速模糊"), - ("ec.blur.ccvector", "CC Vector Blur", "CC 向量模糊"), + ("ec.blur.cccross", "Cross Blur", "十字模糊"), + ("ec.blur.ccradial", "Variable Radial Blur", "可變放射狀模糊"), + ("ec.blur.ccradialfast", "Fast Radial Blur", "快速放射狀模糊"), + ("ec.blur.ccvector", "Vector Blur", "向量模糊"), ("ec.blur.channel", "Channel Blur", "通道模糊"), ("ec.blur.compound", "Compound Blur", "複合模糊"), ("ec.blur.directional", "Directional Blur", "方向模糊"), @@ -526,7 +533,7 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.channel.arithmetic", "Arithmetic", "算術"), ("ec.channel.blend", "Blend", "混合"), ("ec.channel.calculations", "Calculations", "運算"), - ("ec.channel.cccomposite", "CC Composite", "CC 合成"), + ("ec.channel.cccomposite", "Source Composite", "來源影像合成"), ("ec.channel.combiner", "Channel Combiner", "通道合成器"), ("ec.channel.compoundarithmetic", "Compound Arithmetic", "複合算術"), ("ec.channel.invert", "Invert", "反轉"), @@ -542,10 +549,10 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.color.blackwhite", "Black & White", "黑白"), ("ec.color.brightnesscontrast", "Brightness & Contrast", "亮度與對比"), ("ec.color.broadcast", "Broadcast Colors", "廣播色彩"), - ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC 色彩中和器"), - ("ec.color.cccoloroffset", "CC Color Offset", "CC 色彩偏移"), - ("ec.color.cckernel", "CC Kernel", "CC 核心"), - ("ec.color.cctoner", "CC Toner", "CC 色調"), + ("ec.color.cccolorneutralizer", "Color Neutralizer", "色偏校正"), + ("ec.color.cccoloroffset", "Channel Offset", "色版偏移"), + ("ec.color.cckernel", "Convolution Kernel", "卷積核心"), + ("ec.color.cctoner", "Multi-Tone Tint", "多色調著色"), ("ec.color.changecolor", "Change Color", "變更色彩"), ("ec.color.changetocolor", "Change to Color", "變更為色彩"), ("ec.color.channelmixer", "Channel Mixer", "通道混合器"), @@ -562,7 +569,7 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.color.leavecolor", "Leave Color", "保留色彩"), ("ec.color.levels", "Levels", "色階"), ("ec.color.levelsic", "Levels (Individual Controls)", "色階(個別控制項)"), - ("ec.color.lumetri", "Lumetri Color", "Lumetri Color"), + ("ec.color.lumetri", "Color Grading", "色彩分級"), ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL 轉換"), ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO 色彩空間轉換"), ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO 顯示轉換"), @@ -587,20 +594,20 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.control.stereo3d", "Stereo 3D Controls", "立體 3D 控制"), ("ec.distort.bezierwarp", "Bezier Warp", "貝茲彎曲"), ("ec.distort.bulge", "Bulge", "凸出"), - ("ec.distort.ccbender", "CC Bender", "CC 彎曲"), - ("ec.distort.ccbendit", "CC Bend It", "CC 彎折"), - ("ec.distort.ccblobbylize", "CC Blobbylize", "CC 團塊化"), - ("ec.distort.ccflomotion", "CC Flo Motion", "CC 流動"), - ("ec.distort.ccgriddler", "CC Griddler", "CC 格線"), - ("ec.distort.cclens", "CC Lens", "CC 鏡頭"), - ("ec.distort.ccpageturn", "CC Page Turn", "CC 翻頁"), - ("ec.distort.ccpowerpin", "CC Power Pin", "CC 強力釘選"), - ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC 漣漪脈衝"), - ("ec.distort.ccslant", "CC Slant", "CC 傾斜"), - ("ec.distort.ccsmear", "CC Smear", "CC 塗抹"), - ("ec.distort.ccsplit", "CC Split", "CC 分割"), - ("ec.distort.ccsplit2", "CC Split 2", "CC 分割 2"), - ("ec.distort.cctiler", "CC Tiler", "CC 並排"), + ("ec.distort.ccbender", "Flexible Bend", "柔性彎曲"), + ("ec.distort.ccbendit", "Two-Point Bend", "兩點彎曲"), + ("ec.distort.ccblobbylize", "Organic Displacement", "有機置換"), + ("ec.distort.ccflomotion", "Dual Lens Warp", "雙鏡頭變形"), + ("ec.distort.ccgriddler", "Tile Grid", "拼貼網格"), + ("ec.distort.cclens", "Lens Distortion", "鏡頭畸變"), + ("ec.distort.ccpageturn", "Page Curl", "頁面捲曲"), + ("ec.distort.ccpowerpin", "Perspective Pin", "透視定位"), + ("ec.distort.ccripplepulse", "Pulsed Ripple", "脈衝漣漪"), + ("ec.distort.ccslant", "Slant", "傾斜"), + ("ec.distort.ccsmear", "Directional Smear", "定向塗抹"), + ("ec.distort.ccsplit", "Split Warp", "分割變形"), + ("ec.distort.ccsplit2", "Dual Split Warp", "雙重分割變形"), + ("ec.distort.cctiler", "Scaled Tiles", "縮放平鋪"), ("ec.distort.cornerpin", "Corner Pin", "邊角釘選"), ("ec.distort.displacementmap", "Displacement Map", "位移映射"), ("ec.distort.liquify", "Liquify", "液化"), @@ -628,11 +635,11 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.generate.audiospectrum", "Audio Spectrum", "音訊頻譜"), ("ec.generate.audiowaveform", "Audio Waveform", "音訊波形"), ("ec.generate.beam", "Beam", "光束"), - ("ec.generate.ccgluegun", "CC Glue Gun", "CC 膠水槍"), - ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC 光爆 2.5"), - ("ec.generate.cclightrays", "CC Light Rays", "CC 光線"), - ("ec.generate.cclightsweep", "CC Light Sweep", "CC 光線掃掠"), - ("ec.generate.ccthreads", "CC Threads", "CC 絲線"), + ("ec.generate.ccgluegun", "Paint Beads", "顏料珠"), + ("ec.generate.cclightburst", "Radial Light Burst", "放射狀光芒"), + ("ec.generate.cclightrays", "Light Rays", "光線"), + ("ec.generate.cclightsweep", "Light Sweep", "掃光"), + ("ec.generate.ccthreads", "Woven Threads", "編織線"), ("ec.generate.cellpattern", "Cell Pattern", "細胞圖樣"), ("ec.generate.checkerboard", "Checkerboard", "棋盤格"), ("ec.generate.circle", "Circle", "圓形"), @@ -651,7 +658,7 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.generate.vegas", "Vegas", "Vegas"), ("ec.generate.writeon", "Write-on", "書寫"), ("ec.key.advancedspill", "Advanced Spill Suppressor", "進階溢色抑制"), - ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC 簡易鋼絲移除"), + ("ec.key.ccsimplewireremoval", "Line Removal", "線條移除"), ("ec.key.colordifference", "Color Difference Key", "色彩差異去背"), ("ec.key.colorkey", "Color Key", "色彩去背"), ("ec.key.colorrange", "Color Range", "色彩範圍"), @@ -664,7 +671,7 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.key.screen", "Screen Key", "螢幕去背"), ("ec.key.spill", "Spill Suppressor", "溢色抑制"), ("ec.key.unmult", "Unmult", "Unmult"), - ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.keying.keylight", "Screen Color Key", "螢幕色鍵"), ("ec.matte.mattechoker", "Matte Choker", "遮罩收縮器"), ("ec.matte.objectmatte", "Object Matte", "物件遮罩"), ("ec.matte.refinehard", "Refine Hard Matte", "調整硬邊遮罩"), @@ -688,50 +695,50 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "基礎文字"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "高斯模糊(舊版)"), ("ec.obsolete.lightning", "Lightning", "閃電"), - ("ec.obsolete.mochashape", "Mocha shape", "Mocha 形狀"), + ("ec.obsolete.mochashape", "Tracked Shape Matte", "追蹤形狀遮罩"), ("ec.obsolete.pathtext", "Path Text", "路徑文字"), ("ec.paint.paint", "Paint", "繪畫"), ("ec.perspective.3dglasses", "3D Glasses", "3D 眼鏡"), ("ec.perspective.bevelalpha", "Bevel Alpha", "Alpha 斜角"), ("ec.perspective.beveledges", "Bevel Edges", "邊緣斜角"), ("ec.perspective.cameratracker", "3D Camera Tracker", "3D 攝影機追蹤"), - ("ec.perspective.cccylinder", "CC Cylinder", "CC 圓柱"), - ("ec.perspective.ccenvironment", "CC Environment", "CC 環境"), - ("ec.perspective.ccsphere", "CC Sphere", "CC 球體"), - ("ec.perspective.ccspotlight", "CC Spotlight", "CC 聚光燈"), + ("ec.perspective.cccylinder", "Cylinder Wrap", "圓柱包覆"), + ("ec.perspective.ccenvironment", "Environment Map", "環境貼圖"), + ("ec.perspective.ccsphere", "Sphere Wrap", "球面包覆"), + ("ec.perspective.ccspotlight", "Surface Spotlight", "表面聚光燈"), ("ec.perspective.dropshadow", "Drop Shadow", "陰影"), ("ec.perspective.radialshadow", "Radial Shadow", "徑向陰影"), ("ec.sim.carddance", "Card Dance", "卡片舞蹈"), ("ec.sim.caustics", "Caustics", "焦散"), - ("ec.sim.ccballaction", "CC Ball Action", "CC 球狀動作"), - ("ec.sim.ccbubbles", "CC Bubbles", "CC 泡泡"), - ("ec.sim.ccdrizzle", "CC Drizzle", "CC 細雨"), - ("ec.sim.cchair", "CC Hair", "CC 毛髮"), - ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC 水銀先生"), - ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC 粒子系統 II"), - ("ec.sim.ccparticleworld", "CC Particle World", "CC 粒子世界"), - ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC 像素破碎"), - ("ec.sim.ccrainfall", "CC Rainfall", "CC 下雨"), - ("ec.sim.ccscatterize", "CC Scatterize", "CC 散開"), - ("ec.sim.ccsnowfall", "CC Snowfall", "CC 下雪"), - ("ec.sim.ccstarburst", "CC Star Burst", "CC 星爆"), + ("ec.sim.ccballaction", "Ball Grid", "球體網格"), + ("ec.sim.ccbubbles", "Bubbles", "氣泡"), + ("ec.sim.ccdrizzle", "Water Ripples", "水面漣漪"), + ("ec.sim.cchair", "Surface Fibers", "表面纖維"), + ("ec.sim.ccmrmercury", "Liquid Particles", "液體粒子"), + ("ec.sim.ccparticlesystems2", "Particle Emitter 2D", "二維粒子發射器"), + ("ec.sim.ccparticleworld", "Particle Emitter 3D", "三維粒子發射器"), + ("ec.sim.ccpixelpolly", "Pixel Shatter", "像素破碎"), + ("ec.sim.ccrainfall", "Rainfall", "降雨"), + ("ec.sim.ccscatterize", "Pixel Scatter", "像素散射"), + ("ec.sim.ccsnowfall", "Snowfall", "降雪"), + ("ec.sim.ccstarburst", "Starfield", "星空"), ("ec.sim.foam", "Foam", "泡沫"), ("ec.sim.particleplayground", "Particle Playground", "粒子遊樂場"), ("ec.sim.shatter", "Shatter", "粉碎"), ("ec.sim.waveworld", "Wave World", "波浪世界"), ("ec.stylize.brushstrokes", "Brush Strokes", "筆觸"), ("ec.stylize.cartoon", "Cartoon", "卡通"), - ("ec.stylize.ccblockload", "CC Block Load", "CC 區塊載入"), - ("ec.stylize.ccburnfilm", "CC Burn Film", "CC 燒灼底片"), - ("ec.stylize.ccglass", "CC Glass", "CC 玻璃"), - ("ec.stylize.cchextile", "CC HexTile", "CC 六角並排"), - ("ec.stylize.cckaleida", "CC Kaleida", "CC 萬花筒"), - ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC 平滑先生"), - ("ec.stylize.ccplastic", "CC Plastic", "CC 塑膠"), - ("ec.stylize.ccrepetile", "CC RepeTile", "CC 重複並排"), - ("ec.stylize.ccthreshold", "CC Threshold", "CC 臨界值"), - ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC 臨界值 RGB"), - ("ec.stylize.ccvignette", "CC Vignette", "CC 暗角"), + ("ec.stylize.ccblockload", "Block Reveal", "區塊顯現"), + ("ec.stylize.ccburnfilm", "Film Burn", "膠片灼燒"), + ("ec.stylize.ccglass", "Glass Relief", "玻璃浮雕"), + ("ec.stylize.cchextile", "Hexagonal Tiles", "六角形拼貼"), + ("ec.stylize.cckaleida", "Kaleidoscope", "萬花筒"), + ("ec.stylize.ccmrsmoothie", "Palette Map", "調色盤對應"), + ("ec.stylize.ccplastic", "Plastic Shading", "塑膠著色"), + ("ec.stylize.ccrepetile", "Edge Tile Repeat", "邊緣拼貼重複"), + ("ec.stylize.ccthreshold", "Channel Threshold", "色版臨界值"), + ("ec.stylize.ccthresholdrgb", "RGB Threshold", "RGB 臨界值"), + ("ec.stylize.ccvignette", "Vignette", "暗角"), ("ec.stylize.coloremboss", "Color Emboss", "彩色浮雕"), ("ec.stylize.emboss", "Emboss", "浮雕"), ("ec.stylize.findedges", "Find Edges", "尋找邊緣"), @@ -746,8 +753,8 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.stylize.threshold", "Threshold", "臨界值"), ("ec.text.numbers", "Numbers", "數字"), ("ec.text.timecode", "Timecode", "時間碼"), - ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC 強制動態模糊"), - ("ec.time.ccwidetime", "CC Wide Time", "CC 寬廣時間"), + ("ec.time.ccforcemotionblur", "Temporal Motion Blur", "時間動態模糊"), + ("ec.time.ccwidetime", "Temporal Blend", "時間混合"), ("ec.time.echo", "Echo", "回音"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "像素動態模糊"), ("ec.time.posterizetime", "Posterize Time", "色調分離時間"), @@ -756,23 +763,23 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.time.timewarp", "Timewarp", "時間扭曲"), ("ec.transition.blockdissolve", "Block Dissolve", "區塊溶解"), ("ec.transition.cardwipe", "Card Wipe", "卡片擦拭"), - ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC 玻璃擦拭"), - ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC 格線擦拭"), - ("ec.transition.ccimagewipe", "CC Image Wipe", "CC 影像擦拭"), - ("ec.transition.ccjaws", "CC Jaws", "CC 巨顎"), - ("ec.transition.cclightwipe", "CC Light Wipe", "CC 光線擦拭"), - ("ec.transition.cclinesweep", "CC Line Sweep", "CC 線條掃掠"), - ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC 徑向縮放擦拭"), - ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC 縮放擦拭"), - ("ec.transition.cctwister", "CC Twister", "CC 扭轉"), - ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC 自動彎曲"), + ("ec.transition.ccglasswipe", "Glass Wipe", "玻璃擦拭"), + ("ec.transition.ccgridwipe", "Grid Wipe", "網格擦拭"), + ("ec.transition.ccimagewipe", "Luminance Image Wipe", "亮度影像擦拭"), + ("ec.transition.ccjaws", "Serrated Wipe", "鋸齒擦拭"), + ("ec.transition.cclightwipe", "Light Wipe", "光線擦拭"), + ("ec.transition.cclinesweep", "Line Sweep", "線條掃過"), + ("ec.transition.ccradialscalewipe", "Radial Scale Wipe", "放射狀縮放擦拭"), + ("ec.transition.ccscalewipe", "Scale Wipe", "縮放擦拭"), + ("ec.transition.cctwister", "Twist Wipe", "扭轉擦拭"), + ("ec.transition.ccwarpomatic", "Warp Dissolve", "變形溶解"), ("ec.transition.gradientwipe", "Gradient Wipe", "漸層擦拭"), ("ec.transition.iriswipe", "Iris Wipe", "光圈擦拭"), ("ec.transition.linearwipe", "Linear Wipe", "線性擦拭"), ("ec.transition.radialwipe", "Radial Wipe", "徑向擦拭"), ("ec.transition.venetian", "Venetian Blinds", "百葉窗"), ("ec.utility.applylut", "Apply Color LUT", "套用色彩 LUT"), - ("ec.utility.ccoverbrights", "CC Overbrights", "CC 過亮"), + ("ec.utility.ccoverbrights", "Out-of-Range Display", "超出範圍顯示"), ("ec.utility.cineon", "Cineon Converter", "Cineon 轉換器"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "色彩設定檔轉換器"), ("ec.utility.facemeasurements", "Face Measurements", "臉部測量"), @@ -796,18 +803,18 @@ const TRADITIONAL_CHINESE: &[Row] = &[ /// Japanese (`ja`): reserved for the Japanese data point on #821, so both languages share this /// one catalog instead of inventing a second mechanism. A translator fills one row per effect, -/// ids from [`SIMPLIFIED_CHINESE`]; the names Adobe's Japanese interface leaves in English (the -/// `CC` series, `Cryptomatte`, `EXtractoR`, `IDentifier` and `Key Light`) repeat that English name -/// here rather than being left out; the `VR` and `OCIO` sets take a Japanese tail instead, and -/// `Unmult` keeps its English name in both catalogs. An effect with no row draws its English name -/// anyway, which is what `Twirl (Legacy)` and the two EffectCraft-only effects would do without -/// their rows. +/// ids from [`SIMPLIFIED_CHINESE`]; the names Adobe's Japanese interface leaves in English +/// (`Cryptomatte`, `EXtractoR`, `IDentifier` and `Key Light`) repeat that English name here rather +/// than being left out; the `VR` and `OCIO` sets take a Japanese tail instead, and `Unmult` keeps +/// its English name in both catalogs. An effect with no row draws its English name anyway, which +/// is what `Twirl (Legacy)` and the two EffectCraft-only effects would do without their rows. /// -/// The rows below come from AE 2026's Japanese interface where it was read directly (its Effect menu -/// and its Effects & Presets panel) and from Adobe's own Japanese help where the panel was not to -/// hand. A published glossary is used to cross-check, never as the only source: it normalises the -/// typography (half-width parentheses where AE draws full-width) and still carries CS6-era names for -/// the `CC` series, which AE 2026 leaves in English. +/// The rows of the effects this cleanup did not rename come from AE 2026's Japanese interface +/// where it was read directly (its Effect menu and its Effects & Presets panel) and from Adobe's +/// own Japanese help where the panel was not to hand. A published glossary is used to cross-check, +/// never as the only source: it normalises the typography (half-width parentheses where AE draws +/// full-width) and still carries CS6-era names for the `CC` series. The renamed effects are +/// translated from their new descriptive names. const JAPANESE_EFFECTS: &[Row] = &[ // Effects this application adds itself have no interface to read a name from, so they follow // the Chinese catalog's choice to translate them (顔の測定, 顔のトラッキングポイント) rather than @@ -847,10 +854,10 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "ブラー(バイラテラル)"), ("ec.blur.cameralens", "Camera Lens Blur", "ブラー(カメラレンズ)"), ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "カメラぶれ除去"), - ("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"), - ("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"), - ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"), - ("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"), + ("ec.blur.cccross", "Cross Blur", "十字ぼかし"), + ("ec.blur.ccradial", "Variable Radial Blur", "可変放射状ぼかし"), + ("ec.blur.ccradialfast", "Fast Radial Blur", "高速放射状ぼかし"), + ("ec.blur.ccvector", "Vector Blur", "ベクトルぼかし"), ("ec.blur.channel", "Channel Blur", "ブラー(チャンネル)"), ("ec.blur.compound", "Compound Blur", "ブラー(合成)"), ("ec.blur.directional", "Directional Blur", "ブラー(方向)"), @@ -864,7 +871,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.channel.arithmetic", "Arithmetic", "アリスマチック"), ("ec.channel.blend", "Blend", "ブレンド"), ("ec.channel.calculations", "Calculations", "計算"), - ("ec.channel.cccomposite", "CC Composite", "CC Composite"), + ("ec.channel.cccomposite", "Source Composite", "元画像合成"), ("ec.channel.combiner", "Channel Combiner", "チャンネルコンバイナー"), ("ec.channel.compoundarithmetic", "Compound Arithmetic", "合成アリスマチック"), ("ec.channel.invert", "Invert", "反転"), @@ -879,10 +886,10 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.color.autolevels", "Auto Levels", "自動レベル補正"), ("ec.color.blackwhite", "Black & White", "白黒"), ("ec.color.brightnesscontrast", "Brightness & Contrast", "輝度&コントラスト"), - ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"), - ("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"), - ("ec.color.cckernel", "CC Kernel", "CC Kernel"), - ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.cccolorneutralizer", "Color Neutralizer", "色かぶり補正"), + ("ec.color.cccoloroffset", "Channel Offset", "チャンネルオフセット"), + ("ec.color.cckernel", "Convolution Kernel", "畳み込みカーネル"), + ("ec.color.cctoner", "Multi-Tone Tint", "多色調ティント"), ("ec.color.changecolor", "Change Color", "色を変更"), ("ec.color.changetocolor", "Change to Color", "他のカラーへ変更"), ("ec.color.channelmixer", "Channel Mixer", "チャンネルミキサー"), @@ -899,7 +906,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.color.leavecolor", "Leave Color", "色抜き"), ("ec.color.levels", "Levels", "レベル補正"), ("ec.color.levelsic", "Levels (Individual Controls)", "レベル(個々の制御)"), - ("ec.color.lumetri", "Lumetri Color", "Lumetri カラー"), + ("ec.color.lumetri", "Color Grading", "カラーグレーディング"), ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL 変換"), ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO 色空間変換"), ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO 表示変換"), @@ -909,7 +916,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS不定マップ"), ("ec.color.selectivecolor", "Selective Color", "特定色域の選択"), ("ec.color.shadowhighlight", "Shadow/Highlight", "シャドウ・ハイライト"), - ("ec.color.tint", "Tint", "色かぶり補正"), + ("ec.color.tint", "Tint", "着色"), ("ec.color.tritone", "Tritone", "トライトーン"), ("ec.color.vibrance", "Vibrance", "自然な彩度"), ("ec.color.videolimiter", "Video Limiter", "ビデオリミッター"), @@ -923,20 +930,20 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.control.slider", "Slider Control", "スライダー制御"), ("ec.distort.bezierwarp", "Bezier Warp", "ベジェワープ"), ("ec.distort.bulge", "Bulge", "バルジ"), - ("ec.distort.ccbender", "CC Bender", "CC Bender"), - ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), - ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), - ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), - ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), - ("ec.distort.cclens", "CC Lens", "CC Lens"), - ("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"), - ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), - ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"), - ("ec.distort.ccslant", "CC Slant", "CC Slant"), - ("ec.distort.ccsmear", "CC Smear", "CC Smear"), - ("ec.distort.ccsplit", "CC Split", "CC Split"), - ("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"), - ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.ccbender", "Flexible Bend", "柔軟ベンド"), + ("ec.distort.ccbendit", "Two-Point Bend", "2点ベンド"), + ("ec.distort.ccblobbylize", "Organic Displacement", "有機変位"), + ("ec.distort.ccflomotion", "Dual Lens Warp", "ダブルレンズ変形"), + ("ec.distort.ccgriddler", "Tile Grid", "タイルグリッド"), + ("ec.distort.cclens", "Lens Distortion", "レンズ歪み"), + ("ec.distort.ccpageturn", "Page Curl", "ページカール"), + ("ec.distort.ccpowerpin", "Perspective Pin", "遠近ピン"), + ("ec.distort.ccripplepulse", "Pulsed Ripple", "パルス波紋"), + ("ec.distort.ccslant", "Slant", "傾斜"), + ("ec.distort.ccsmear", "Directional Smear", "方向性にじみ"), + ("ec.distort.ccsplit", "Split Warp", "分割変形"), + ("ec.distort.ccsplit2", "Dual Split Warp", "二重分割変形"), + ("ec.distort.cctiler", "Scaled Tiles", "スケールタイル"), ("ec.distort.cornerpin", "Corner Pin", "コーナーピン"), ("ec.distort.displacementmap", "Displacement Map", "ディスプレイスメントマップ"), ("ec.distort.liquify", "Liquify", "ゆがみ"), @@ -963,11 +970,11 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.generate.audiospectrum", "Audio Spectrum", "オーディオスペクトラム"), ("ec.generate.audiowaveform", "Audio Waveform", "オーディオウェーブフォーム"), ("ec.generate.beam", "Beam", "レーザー"), - ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), - ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"), - ("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"), - ("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"), - ("ec.generate.ccthreads", "CC Threads", "CC Threads"), + ("ec.generate.ccgluegun", "Paint Beads", "絵の具の粒"), + ("ec.generate.cclightburst", "Radial Light Burst", "放射状の光"), + ("ec.generate.cclightrays", "Light Rays", "光線"), + ("ec.generate.cclightsweep", "Light Sweep", "光のスイープ"), + ("ec.generate.ccthreads", "Woven Threads", "織り糸"), ("ec.generate.cellpattern", "Cell Pattern", "セルパターン"), ("ec.generate.checkerboard", "Checkerboard", "チェッカーボード"), ("ec.generate.circle", "Circle", "円"), @@ -986,7 +993,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.generate.vegas", "Vegas", "ベガス"), ("ec.generate.writeon", "Write-on", "ブラシアニメーション"), ("ec.key.advancedspill", "Advanced Spill Suppressor", "Advanced Spill Suppressor"), - ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"), + ("ec.key.ccsimplewireremoval", "Line Removal", "ライン除去"), ("ec.key.colordifference", "Color Difference Key", "カラー差キー"), ("ec.key.colorkey", "Color Key", "カラーキー"), ("ec.key.colorrange", "Color Range", "色範囲"), @@ -997,7 +1004,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.key.linearcolor", "Linear Color Key", "リニアカラーキー"), ("ec.key.luma", "Luma Key", "ルミナンスキー"), ("ec.key.spill", "Spill Suppressor", "スピルサプレッション"), - ("ec.keying.keylight", "Key Light", "Keylight (1.2)"), + ("ec.keying.keylight", "Screen Color Key", "スクリーンカラーキー"), ("ec.matte.mattechoker", "Matte Choker", "マットチョーク"), ("ec.matte.refinehard", "Refine Hard Matte", "ハードマットを調整"), ("ec.matte.refinesoft", "Refine Soft Matte", "ソフトマットを調整"), @@ -1019,49 +1026,49 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "基本テキスト"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "ブラー(ガウス)(レガシー)"), ("ec.obsolete.lightning", "Lightning", "稲妻"), - ("ec.obsolete.mochashape", "Mocha shape", "mocha shape"), + ("ec.obsolete.mochashape", "Tracked Shape Matte", "トラッキングシェイプマット"), ("ec.obsolete.pathtext", "Path Text", "パステキスト"), ("ec.perspective.3dglasses", "3D Glasses", "3Dメガネ"), ("ec.perspective.bevelalpha", "Bevel Alpha", "ベベルアルファ"), ("ec.perspective.beveledges", "Bevel Edges", "ベベルエッジ"), ("ec.perspective.cameratracker", "3D Camera Tracker", "3D カメラトラッカー"), - ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), - ("ec.perspective.ccenvironment", "CC Environment", "CC Environment"), - ("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"), - ("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"), + ("ec.perspective.cccylinder", "Cylinder Wrap", "円筒ラップ"), + ("ec.perspective.ccenvironment", "Environment Map", "環境マップ"), + ("ec.perspective.ccsphere", "Sphere Wrap", "球面ラップ"), + ("ec.perspective.ccspotlight", "Surface Spotlight", "表面スポットライト"), ("ec.perspective.dropshadow", "Drop Shadow", "ドロップシャドウ"), ("ec.perspective.radialshadow", "Radial Shadow", "放射状シャドウ"), ("ec.sim.carddance", "Card Dance", "カードダンス"), ("ec.sim.caustics", "Caustics", "コースティック"), - ("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"), - ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"), - ("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"), - ("ec.sim.cchair", "CC Hair", "CC Hair"), - ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), - ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"), - ("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"), - ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), - ("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"), - ("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"), - ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"), - ("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"), + ("ec.sim.ccballaction", "Ball Grid", "球体グリッド"), + ("ec.sim.ccbubbles", "Bubbles", "気泡"), + ("ec.sim.ccdrizzle", "Water Ripples", "水面の波紋"), + ("ec.sim.cchair", "Surface Fibers", "表面の繊維"), + ("ec.sim.ccmrmercury", "Liquid Particles", "液体パーティクル"), + ("ec.sim.ccparticlesystems2", "Particle Emitter 2D", "2Dパーティクルエミッター"), + ("ec.sim.ccparticleworld", "Particle Emitter 3D", "3Dパーティクルエミッター"), + ("ec.sim.ccpixelpolly", "Pixel Shatter", "ピクセル破砕"), + ("ec.sim.ccrainfall", "Rainfall", "雨"), + ("ec.sim.ccscatterize", "Pixel Scatter", "ピクセル散乱"), + ("ec.sim.ccsnowfall", "Snowfall", "雪"), + ("ec.sim.ccstarburst", "Starfield", "星空"), ("ec.sim.foam", "Foam", "泡"), ("ec.sim.particleplayground", "Particle Playground", "パーティクルプレイグラウンド"), ("ec.sim.shatter", "Shatter", "シャター"), ("ec.sim.waveworld", "Wave World", "ウェーブワールド"), ("ec.stylize.brushstrokes", "Brush Strokes", "ブラシストローク"), ("ec.stylize.cartoon", "Cartoon", "カートゥーン"), - ("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"), - ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"), - ("ec.stylize.ccglass", "CC Glass", "CC Glass"), - ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), - ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), - ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), - ("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"), - ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), - ("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"), - ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"), - ("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"), + ("ec.stylize.ccblockload", "Block Reveal", "ブロック表示"), + ("ec.stylize.ccburnfilm", "Film Burn", "フィルム焼け"), + ("ec.stylize.ccglass", "Glass Relief", "ガラスレリーフ"), + ("ec.stylize.cchextile", "Hexagonal Tiles", "六角形タイル"), + ("ec.stylize.cckaleida", "Kaleidoscope", "万華鏡"), + ("ec.stylize.ccmrsmoothie", "Palette Map", "パレットマップ"), + ("ec.stylize.ccplastic", "Plastic Shading", "プラスチックシェーディング"), + ("ec.stylize.ccrepetile", "Edge Tile Repeat", "エッジタイル反復"), + ("ec.stylize.ccthreshold", "Channel Threshold", "チャンネルしきい値"), + ("ec.stylize.ccthresholdrgb", "RGB Threshold", "RGBしきい値"), + ("ec.stylize.ccvignette", "Vignette", "ビネット"), ("ec.stylize.coloremboss", "Color Emboss", "カラーエンボス"), ("ec.stylize.emboss", "Emboss", "エンボス"), ("ec.stylize.findedges", "Find Edges", "輪郭検出"), @@ -1076,8 +1083,8 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.stylize.threshold", "Threshold", "しきい値"), ("ec.text.numbers", "Numbers", "番号"), ("ec.text.timecode", "Timecode", "タイムコード"), - ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"), - ("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"), + ("ec.time.ccforcemotionblur", "Temporal Motion Blur", "時間方向モーションブラー"), + ("ec.time.ccwidetime", "Temporal Blend", "時間ブレンド"), ("ec.time.echo", "Echo", "エコー"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "ピクセルモーションブラー"), ("ec.time.posterizetime", "Posterize Time", "ポスタリゼーション時間"), @@ -1086,23 +1093,23 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.time.timewarp", "Timewarp", "タイムワープ"), ("ec.transition.blockdissolve", "Block Dissolve", "ブロックディゾルブ"), ("ec.transition.cardwipe", "Card Wipe", "カードワイプ"), - ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"), - ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"), - ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"), - ("ec.transition.ccjaws", "CC Jaws", "CC Jaws"), - ("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"), - ("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"), - ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"), - ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"), - ("ec.transition.cctwister", "CC Twister", "CC Twister"), - ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.ccglasswipe", "Glass Wipe", "ガラスワイプ"), + ("ec.transition.ccgridwipe", "Grid Wipe", "グリッドワイプ"), + ("ec.transition.ccimagewipe", "Luminance Image Wipe", "輝度画像ワイプ"), + ("ec.transition.ccjaws", "Serrated Wipe", "鋸歯状ワイプ"), + ("ec.transition.cclightwipe", "Light Wipe", "ライトワイプ"), + ("ec.transition.cclinesweep", "Line Sweep", "ラインスイープ"), + ("ec.transition.ccradialscalewipe", "Radial Scale Wipe", "放射状スケールワイプ"), + ("ec.transition.ccscalewipe", "Scale Wipe", "スケールワイプ"), + ("ec.transition.cctwister", "Twist Wipe", "ツイストワイプ"), + ("ec.transition.ccwarpomatic", "Warp Dissolve", "変形ディゾルブ"), ("ec.transition.gradientwipe", "Gradient Wipe", "グラデーションワイプ"), ("ec.transition.iriswipe", "Iris Wipe", "アイリスワイプ"), ("ec.transition.linearwipe", "Linear Wipe", "リニアワイプ"), ("ec.transition.radialwipe", "Radial Wipe", "放射状ワイプ"), ("ec.transition.venetian", "Venetian Blinds", "ブラインド"), ("ec.utility.applylut", "Apply Color LUT", "カラー LUT を適用"), - ("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"), + ("ec.utility.ccoverbrights", "Out-of-Range Display", "範囲外表示"), ("ec.utility.cineon", "Cineon Converter", "Cineon コンバーター"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "カラープロファイルコンバーター"), ("ec.utility.growbounds", "Grow Bounds", "範囲拡張"), @@ -1176,9 +1183,9 @@ const JAPANESE_CATEGORIES: &[(&str, &str)] = &[ /// Georgian (`ka`): one row per effect, ids from [`SIMPLIFIED_CHINESE`]. Written from the meaning /// of the English names, with the terms the Georgian menus and panels use (FilmCraft's Georgian -/// for the effects the two apps share). The `CC` series and the brand names (`Cryptomatte`, -/// `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`) stay in English, as -/// Adobe's Japanese interface keeps the `CC` series; `VR` and `OCIO` keep their prefix. +/// for the effects the two apps share). The brand names (`Cryptomatte`, `EXtractoR`, `IDentifier`, +/// `Key Light`, `Unmult`) stay in English; `VR` and `OCIO` keep their prefix. The renamed effects +/// are translated from their new descriptive names. const GEORGIAN: &[Row] = &[ ("ec.3d.channelextract", "3D Channel Extract", "3D არხის ამოღება"), ("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"), @@ -1205,10 +1212,10 @@ const GEORGIAN: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "ბილატერალური გაბუნდოვნება"), ("ec.blur.cameralens", "Camera Lens Blur", "კამერის ობიექტივის გაბუნდოვნება"), ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "კამერის რყევით გაბუნდოვნების მოცილება"), - ("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"), - ("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"), - ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"), - ("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"), + ("ec.blur.cccross", "Cross Blur", "ჯვარედინი დაბინდვა"), + ("ec.blur.ccradial", "Variable Radial Blur", "ცვლადი რადიალური დაბინდვა"), + ("ec.blur.ccradialfast", "Fast Radial Blur", "სწრაფი რადიალური დაბინდვა"), + ("ec.blur.ccvector", "Vector Blur", "ვექტორული დაბინდვა"), ("ec.blur.channel", "Channel Blur", "არხის გაბუნდოვნება"), ("ec.blur.compound", "Compound Blur", "შედგენილი გაბუნდოვნება"), ("ec.blur.directional", "Directional Blur", "მიმართული გაბუნდოვნება"), @@ -1222,7 +1229,7 @@ const GEORGIAN: &[Row] = &[ ("ec.channel.arithmetic", "Arithmetic", "არითმეტიკა"), ("ec.channel.blend", "Blend", "შერევა"), ("ec.channel.calculations", "Calculations", "გამოთვლები"), - ("ec.channel.cccomposite", "CC Composite", "CC Composite"), + ("ec.channel.cccomposite", "Source Composite", "წყაროს კომპოზიცია"), ("ec.channel.combiner", "Channel Combiner", "არხების გამაერთიანებელი"), ("ec.channel.compoundarithmetic", "Compound Arithmetic", "შედგენილი არითმეტიკა"), ("ec.channel.invert", "Invert", "ინვერსია"), @@ -1238,10 +1245,10 @@ const GEORGIAN: &[Row] = &[ ("ec.color.blackwhite", "Black & White", "შავ-თეთრი"), ("ec.color.brightnesscontrast", "Brightness & Contrast", "სიკაშკაშე და კონტრასტი"), ("ec.color.broadcast", "Broadcast Colors", "სამაუწყებლო ფერები"), - ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"), - ("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"), - ("ec.color.cckernel", "CC Kernel", "CC Kernel"), - ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.cccolorneutralizer", "Color Neutralizer", "ფერის ნეიტრალიზაცია"), + ("ec.color.cccoloroffset", "Channel Offset", "არხების წანაცვლება"), + ("ec.color.cckernel", "Convolution Kernel", "კონვოლუციის ბირთვი"), + ("ec.color.cctoner", "Multi-Tone Tint", "მრავალტონიანი შეფერილობა"), ("ec.color.changecolor", "Change Color", "ფერის შეცვლა"), ("ec.color.changetocolor", "Change to Color", "ფერად შეცვლა"), ("ec.color.channelmixer", "Channel Mixer", "არხების მიქშერი"), @@ -1258,7 +1265,7 @@ const GEORGIAN: &[Row] = &[ ("ec.color.leavecolor", "Leave Color", "ფერის დატოვება"), ("ec.color.levels", "Levels", "დონეები"), ("ec.color.levelsic", "Levels (Individual Controls)", "დონეები (ცალკეული მართვა)"), - ("ec.color.lumetri", "Lumetri Color", "Lumetri Color"), + ("ec.color.lumetri", "Color Grading", "ფერთა კორექცია"), ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL გარდაქმნა"), ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO ფერთა სივრცის გარდაქმნა"), ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO ეკრანის გარდაქმნა"), @@ -1283,20 +1290,20 @@ const GEORGIAN: &[Row] = &[ ("ec.control.stereo3d", "Stereo 3D Controls", "სტერეო 3D მართვა"), ("ec.distort.bezierwarp", "Bezier Warp", "ბეზიეს დეფორმაცია"), ("ec.distort.bulge", "Bulge", "გამობერილობა"), - ("ec.distort.ccbender", "CC Bender", "CC Bender"), - ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), - ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), - ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), - ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), - ("ec.distort.cclens", "CC Lens", "CC Lens"), - ("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"), - ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), - ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"), - ("ec.distort.ccslant", "CC Slant", "CC Slant"), - ("ec.distort.ccsmear", "CC Smear", "CC Smear"), - ("ec.distort.ccsplit", "CC Split", "CC Split"), - ("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"), - ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.ccbender", "Flexible Bend", "მოქნილი მოღუნვა"), + ("ec.distort.ccbendit", "Two-Point Bend", "ორწერტილიანი მოღუნვა"), + ("ec.distort.ccblobbylize", "Organic Displacement", "ორგანული გადაადგილება"), + ("ec.distort.ccflomotion", "Dual Lens Warp", "ორმაგი ლინზით დეფორმაცია"), + ("ec.distort.ccgriddler", "Tile Grid", "ფილების ბადე"), + ("ec.distort.cclens", "Lens Distortion", "ლინზის დამახინჯება"), + ("ec.distort.ccpageturn", "Page Curl", "გვერდის დახვევა"), + ("ec.distort.ccpowerpin", "Perspective Pin", "პერსპექტიული დამაგრება"), + ("ec.distort.ccripplepulse", "Pulsed Ripple", "პულსური ტალღა"), + ("ec.distort.ccslant", "Slant", "დახრა"), + ("ec.distort.ccsmear", "Directional Smear", "მიმართულებითი გაწელვა"), + ("ec.distort.ccsplit", "Split Warp", "გაყოფილი დეფორმაცია"), + ("ec.distort.ccsplit2", "Dual Split Warp", "ორმაგად გაყოფილი დეფორმაცია"), + ("ec.distort.cctiler", "Scaled Tiles", "მასშტაბირებული ფილები"), ("ec.distort.cornerpin", "Corner Pin", "კუთხეების მიმაგრება"), ("ec.distort.displacementmap", "Displacement Map", "წანაცვლების რუკა"), ("ec.distort.liquify", "Liquify", "გათხევადება"), @@ -1324,11 +1331,11 @@ const GEORGIAN: &[Row] = &[ ("ec.generate.audiospectrum", "Audio Spectrum", "აუდიოს სპექტრი"), ("ec.generate.audiowaveform", "Audio Waveform", "აუდიოს ტალღის ფორმა"), ("ec.generate.beam", "Beam", "სხივი"), - ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), - ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"), - ("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"), - ("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"), - ("ec.generate.ccthreads", "CC Threads", "CC Threads"), + ("ec.generate.ccgluegun", "Paint Beads", "საღებავის მარცვლები"), + ("ec.generate.cclightburst", "Radial Light Burst", "რადიალური ნათება"), + ("ec.generate.cclightrays", "Light Rays", "სინათლის სხივები"), + ("ec.generate.cclightsweep", "Light Sweep", "სინათლის გავლა"), + ("ec.generate.ccthreads", "Woven Threads", "ნაქსოვი ძაფები"), ("ec.generate.cellpattern", "Cell Pattern", "უჯრედების პატერნი"), ("ec.generate.checkerboard", "Checkerboard", "ჭადრაკის დაფა"), ("ec.generate.circle", "Circle", "წრე"), @@ -1347,7 +1354,7 @@ const GEORGIAN: &[Row] = &[ ("ec.generate.vegas", "Vegas", "ვეგასი"), ("ec.generate.writeon", "Write-on", "თანდათანობითი წერა"), ("ec.key.advancedspill", "Advanced Spill Suppressor", "ფერის გადაღვრის გაფართოებული ჩამხშობი"), - ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"), + ("ec.key.ccsimplewireremoval", "Line Removal", "ხაზის მოცილება"), ("ec.key.colordifference", "Color Difference Key", "ფერთა სხვაობის გასაღები"), ("ec.key.colorkey", "Color Key", "ფერის გასაღები"), ("ec.key.colorrange", "Color Range", "ფერების დიაპაზონი"), @@ -1360,7 +1367,7 @@ const GEORGIAN: &[Row] = &[ ("ec.key.screen", "Screen Key", "ეკრანის გასაღები"), ("ec.key.spill", "Spill Suppressor", "ფერის გადაღვრის ჩამხშობი"), ("ec.key.unmult", "Unmult", "Unmult"), - ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.keying.keylight", "Screen Color Key", "ეკრანის ფერის გამოყოფა"), ("ec.matte.mattechoker", "Matte Choker", "მატის შემკუმშველი"), ("ec.matte.objectmatte", "Object Matte", "ობიექტის მატი"), ("ec.matte.refinehard", "Refine Hard Matte", "მკვეთრი მატის დახვეწა"), @@ -1384,50 +1391,50 @@ const GEORGIAN: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "საბაზო ტექსტი"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "გაუსის გაბუნდოვნება (მოძველებული)"), ("ec.obsolete.lightning", "Lightning", "ელვა"), - ("ec.obsolete.mochashape", "Mocha shape", "Mocha-ს ფიგურა"), + ("ec.obsolete.mochashape", "Tracked Shape Matte", "თვალთვალის ფორმის ნიღაბი"), ("ec.obsolete.pathtext", "Path Text", "ტექსტი ტრაექტორიაზე"), ("ec.paint.paint", "Paint", "ხატვა"), ("ec.perspective.3dglasses", "3D Glasses", "3D სათვალე"), ("ec.perspective.bevelalpha", "Bevel Alpha", "ალფას ფასკა"), ("ec.perspective.beveledges", "Bevel Edges", "კიდეების ფასკა"), ("ec.perspective.cameratracker", "3D Camera Tracker", "3D კამერის ტრეკერი"), - ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), - ("ec.perspective.ccenvironment", "CC Environment", "CC Environment"), - ("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"), - ("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"), + ("ec.perspective.cccylinder", "Cylinder Wrap", "ცილინდრზე შემოხვევა"), + ("ec.perspective.ccenvironment", "Environment Map", "გარემოს რუკა"), + ("ec.perspective.ccsphere", "Sphere Wrap", "სფეროზე შემოხვევა"), + ("ec.perspective.ccspotlight", "Surface Spotlight", "ზედაპირის პროჟექტორი"), ("ec.perspective.dropshadow", "Drop Shadow", "ჩრდილი"), ("ec.perspective.radialshadow", "Radial Shadow", "რადიალური ჩრდილი"), ("ec.sim.carddance", "Card Dance", "მოცეკვავე ბარათები"), ("ec.sim.caustics", "Caustics", "კაუსტიკა"), - ("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"), - ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"), - ("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"), - ("ec.sim.cchair", "CC Hair", "CC Hair"), - ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), - ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"), - ("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"), - ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), - ("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"), - ("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"), - ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"), - ("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"), + ("ec.sim.ccballaction", "Ball Grid", "ბურთულების ბადე"), + ("ec.sim.ccbubbles", "Bubbles", "ბუშტები"), + ("ec.sim.ccdrizzle", "Water Ripples", "წყლის ტალღები"), + ("ec.sim.cchair", "Surface Fibers", "ზედაპირის ბოჭკოები"), + ("ec.sim.ccmrmercury", "Liquid Particles", "თხევადი ნაწილაკები"), + ("ec.sim.ccparticlesystems2", "Particle Emitter 2D", "ნაწილაკების 2D წყარო"), + ("ec.sim.ccparticleworld", "Particle Emitter 3D", "ნაწილაკების 3D წყარო"), + ("ec.sim.ccpixelpolly", "Pixel Shatter", "პიქსელების დაშლა"), + ("ec.sim.ccrainfall", "Rainfall", "წვიმა"), + ("ec.sim.ccscatterize", "Pixel Scatter", "პიქსელების გაფანტვა"), + ("ec.sim.ccsnowfall", "Snowfall", "თოვა"), + ("ec.sim.ccstarburst", "Starfield", "ვარსკვლავების ველი"), ("ec.sim.foam", "Foam", "ქაფი"), ("ec.sim.particleplayground", "Particle Playground", "ნაწილაკების სიმულაცია"), ("ec.sim.shatter", "Shatter", "დამსხვრევა"), ("ec.sim.waveworld", "Wave World", "ტალღების სამყარო"), ("ec.stylize.brushstrokes", "Brush Strokes", "ფუნჯის შტრიხები"), ("ec.stylize.cartoon", "Cartoon", "მულტფილმის სტილი"), - ("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"), - ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"), - ("ec.stylize.ccglass", "CC Glass", "CC Glass"), - ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), - ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), - ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), - ("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"), - ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), - ("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"), - ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"), - ("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"), + ("ec.stylize.ccblockload", "Block Reveal", "ბლოკებით გამოჩენა"), + ("ec.stylize.ccburnfilm", "Film Burn", "ფირის დაწვა"), + ("ec.stylize.ccglass", "Glass Relief", "მინის რელიეფი"), + ("ec.stylize.cchextile", "Hexagonal Tiles", "ექვსკუთხა ფილები"), + ("ec.stylize.cckaleida", "Kaleidoscope", "კალეიდოსკოპი"), + ("ec.stylize.ccmrsmoothie", "Palette Map", "პალიტრის რუკა"), + ("ec.stylize.ccplastic", "Plastic Shading", "პლასტმასის დაჩრდილვა"), + ("ec.stylize.ccrepetile", "Edge Tile Repeat", "კიდეების განმეორება"), + ("ec.stylize.ccthreshold", "Channel Threshold", "არხის ზღვარი"), + ("ec.stylize.ccthresholdrgb", "RGB Threshold", "RGB ზღვარი"), + ("ec.stylize.ccvignette", "Vignette", "ვინიეტირება"), ("ec.stylize.coloremboss", "Color Emboss", "ფერადი რელიეფი"), ("ec.stylize.emboss", "Emboss", "რელიეფი"), ("ec.stylize.findedges", "Find Edges", "კიდეების პოვნა"), @@ -1442,8 +1449,8 @@ const GEORGIAN: &[Row] = &[ ("ec.stylize.threshold", "Threshold", "ზღვარი"), ("ec.text.numbers", "Numbers", "რიცხვები"), ("ec.text.timecode", "Timecode", "თაიმკოდი"), - ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"), - ("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"), + ("ec.time.ccforcemotionblur", "Temporal Motion Blur", "დროითი მოძრაობის დაბინდვა"), + ("ec.time.ccwidetime", "Temporal Blend", "დროითი შერევა"), ("ec.time.echo", "Echo", "ექო"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "პიქსელების მოძრაობის გაბუნდოვნება"), ("ec.time.posterizetime", "Posterize Time", "დროის პოსტერიზაცია"), @@ -1452,23 +1459,23 @@ const GEORGIAN: &[Row] = &[ ("ec.time.timewarp", "Timewarp", "Timewarp"), ("ec.transition.blockdissolve", "Block Dissolve", "ბლოკური გადადნობა"), ("ec.transition.cardwipe", "Card Wipe", "ბარათებით წმენდა"), - ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"), - ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"), - ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"), - ("ec.transition.ccjaws", "CC Jaws", "CC Jaws"), - ("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"), - ("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"), - ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"), - ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"), - ("ec.transition.cctwister", "CC Twister", "CC Twister"), - ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.ccglasswipe", "Glass Wipe", "მინის გადასვლა"), + ("ec.transition.ccgridwipe", "Grid Wipe", "ბადის გადასვლა"), + ("ec.transition.ccimagewipe", "Luminance Image Wipe", "სიკაშკაშის გამოსახულებით გადასვლა"), + ("ec.transition.ccjaws", "Serrated Wipe", "დაკბილული გადასვლა"), + ("ec.transition.cclightwipe", "Light Wipe", "სინათლის გადასვლა"), + ("ec.transition.cclinesweep", "Line Sweep", "ხაზების გავლა"), + ("ec.transition.ccradialscalewipe", "Radial Scale Wipe", "რადიალური მასშტაბის გადასვლა"), + ("ec.transition.ccscalewipe", "Scale Wipe", "მასშტაბის გადასვლა"), + ("ec.transition.cctwister", "Twist Wipe", "მოგრეხილი გადასვლა"), + ("ec.transition.ccwarpomatic", "Warp Dissolve", "დეფორმაციით გადასვლა"), ("ec.transition.gradientwipe", "Gradient Wipe", "გრადიენტული წმენდა"), ("ec.transition.iriswipe", "Iris Wipe", "დიაფრაგმული წმენდა"), ("ec.transition.linearwipe", "Linear Wipe", "წრფივი წმენდა"), ("ec.transition.radialwipe", "Radial Wipe", "რადიალური წმენდა"), ("ec.transition.venetian", "Venetian Blinds", "ჟალუზი"), ("ec.utility.applylut", "Apply Color LUT", "ფერის LUT-ის გამოყენება"), - ("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"), + ("ec.utility.ccoverbrights", "Out-of-Range Display", "დიაპაზონის გარეთ ჩვენება"), ("ec.utility.cineon", "Cineon Converter", "Cineon-ის გარდამქმნელი"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "ფერის პროფილის გარდამქმნელი"), ("ec.utility.facemeasurements", "Face Measurements", "სახის გაზომვები"), @@ -1517,8 +1524,8 @@ const GEORGIAN_CATEGORIES: &[(&str, &str)] = &[ /// French (`fr`): one row per effect, ids from [`SIMPLIFIED_CHINESE`]. Written from the meaning of the /// English names, with the terms the French menus use (calque, estampe for a matte, incrustation for -/// keying). The `CC` series and the brand names stay in English, `VR`, `OCIO`, `PS` and `HDR` keep -/// their tag after the translated name. +/// keying). The brand names stay in English, `VR`, `OCIO`, `PS` and `HDR` keep their tag after the +/// translated name. const FRENCH: &[Row] = &[ ("ec.3d.channelextract", "3D Channel Extract", "Extraction de canal 3D"), ("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"), @@ -1544,10 +1551,10 @@ const FRENCH: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "Flou bilatéral"), ("ec.blur.cameralens", "Camera Lens Blur", "Flou de l’objectif de la caméra"), ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "Correction du flou de bougé de caméra"), - ("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"), - ("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"), - ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"), - ("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"), + ("ec.blur.cccross", "Cross Blur", "Flou croisé"), + ("ec.blur.ccradial", "Variable Radial Blur", "Flou radial variable"), + ("ec.blur.ccradialfast", "Fast Radial Blur", "Flou radial rapide"), + ("ec.blur.ccvector", "Vector Blur", "Flou vectoriel"), ("ec.blur.channel", "Channel Blur", "Flou de canal"), ("ec.blur.compound", "Compound Blur", "Flou composé"), ("ec.blur.directional", "Directional Blur", "Flou directionnel"), @@ -1561,7 +1568,7 @@ const FRENCH: &[Row] = &[ ("ec.channel.arithmetic", "Arithmetic", "Arithmétique"), ("ec.channel.blend", "Blend", "Fusion"), ("ec.channel.calculations", "Calculations", "Calculs"), - ("ec.channel.cccomposite", "CC Composite", "CC Composite"), + ("ec.channel.cccomposite", "Source Composite", "Composition de la source"), ("ec.channel.combiner", "Channel Combiner", "Combinateur de canaux"), ("ec.channel.compoundarithmetic", "Compound Arithmetic", "Arithmétique composée"), ("ec.channel.invert", "Invert", "Inversion"), @@ -1577,10 +1584,10 @@ const FRENCH: &[Row] = &[ ("ec.color.blackwhite", "Black & White", "Noir et blanc"), ("ec.color.brightnesscontrast", "Brightness & Contrast", "Luminosité et contraste"), ("ec.color.broadcast", "Broadcast Colors", "Couleurs de diffusion"), - ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"), - ("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"), - ("ec.color.cckernel", "CC Kernel", "CC Kernel"), - ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.cccolorneutralizer", "Color Neutralizer", "Neutralisation des couleurs"), + ("ec.color.cccoloroffset", "Channel Offset", "Décalage des canaux"), + ("ec.color.cckernel", "Convolution Kernel", "Noyau de convolution"), + ("ec.color.cctoner", "Multi-Tone Tint", "Teinte multiton"), ("ec.color.changecolor", "Change Color", "Changer la couleur"), ("ec.color.changetocolor", "Change to Color", "Changer en couleur"), ("ec.color.channelmixer", "Channel Mixer", "Mélangeur de canaux"), @@ -1597,7 +1604,7 @@ const FRENCH: &[Row] = &[ ("ec.color.leavecolor", "Leave Color", "Conserver la couleur"), ("ec.color.levels", "Levels", "Niveaux"), ("ec.color.levelsic", "Levels (Individual Controls)", "Niveaux (contrôles individuels)"), - ("ec.color.lumetri", "Lumetri Color", "Lumetri Color"), + ("ec.color.lumetri", "Color Grading", "Étalonnage des couleurs"), ("ec.color.ociocdl", "OCIO CDL Transform", "Transformation CDL OCIO"), ("ec.color.ociocolorspace", "OCIO Color Space Transform", "Transformation d’espace colorimétrique OCIO"), ("ec.color.ociodisplay", "OCIO Display Transform", "Transformation d’affichage OCIO"), @@ -1622,20 +1629,20 @@ const FRENCH: &[Row] = &[ ("ec.control.stereo3d", "Stereo 3D Controls", "Contrôles stéréo 3D"), ("ec.distort.bezierwarp", "Bezier Warp", "Déformation Bézier"), ("ec.distort.bulge", "Bulge", "Renflement"), - ("ec.distort.ccbender", "CC Bender", "CC Bender"), - ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), - ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), - ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), - ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), - ("ec.distort.cclens", "CC Lens", "CC Lens"), - ("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"), - ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), - ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"), - ("ec.distort.ccslant", "CC Slant", "CC Slant"), - ("ec.distort.ccsmear", "CC Smear", "CC Smear"), - ("ec.distort.ccsplit", "CC Split", "CC Split"), - ("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"), - ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.ccbender", "Flexible Bend", "Courbure souple"), + ("ec.distort.ccbendit", "Two-Point Bend", "Courbure à deux points"), + ("ec.distort.ccblobbylize", "Organic Displacement", "Déplacement organique"), + ("ec.distort.ccflomotion", "Dual Lens Warp", "Déformation à deux lentilles"), + ("ec.distort.ccgriddler", "Tile Grid", "Grille de tuiles"), + ("ec.distort.cclens", "Lens Distortion", "Distorsion de lentille"), + ("ec.distort.ccpageturn", "Page Curl", "Page enroulée"), + ("ec.distort.ccpowerpin", "Perspective Pin", "Épinglage en perspective"), + ("ec.distort.ccripplepulse", "Pulsed Ripple", "Ondulation pulsée"), + ("ec.distort.ccslant", "Slant", "Inclinaison"), + ("ec.distort.ccsmear", "Directional Smear", "Étalement directionnel"), + ("ec.distort.ccsplit", "Split Warp", "Déformation divisée"), + ("ec.distort.ccsplit2", "Dual Split Warp", "Déformation à double division"), + ("ec.distort.cctiler", "Scaled Tiles", "Tuiles mises à l’échelle"), ("ec.distort.cornerpin", "Corner Pin", "Épinglage d’angles"), ("ec.distort.displacementmap", "Displacement Map", "Carte de déplacement"), ("ec.distort.liquify", "Liquify", "Liquéfier"), @@ -1663,11 +1670,11 @@ const FRENCH: &[Row] = &[ ("ec.generate.audiospectrum", "Audio Spectrum", "Spectre audio"), ("ec.generate.audiowaveform", "Audio Waveform", "Forme d’onde audio"), ("ec.generate.beam", "Beam", "Faisceau"), - ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), - ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"), - ("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"), - ("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"), - ("ec.generate.ccthreads", "CC Threads", "CC Threads"), + ("ec.generate.ccgluegun", "Paint Beads", "Perles de peinture"), + ("ec.generate.cclightburst", "Radial Light Burst", "Éclat lumineux radial"), + ("ec.generate.cclightrays", "Light Rays", "Rayons lumineux"), + ("ec.generate.cclightsweep", "Light Sweep", "Balayage lumineux"), + ("ec.generate.ccthreads", "Woven Threads", "Fils tissés"), ("ec.generate.cellpattern", "Cell Pattern", "Motif cellulaire"), ("ec.generate.checkerboard", "Checkerboard", "Damier"), ("ec.generate.circle", "Circle", "Cercle"), @@ -1686,7 +1693,7 @@ const FRENCH: &[Row] = &[ ("ec.generate.vegas", "Vegas", "Vegas"), ("ec.generate.writeon", "Write-on", "Écriture progressive"), ("ec.key.advancedspill", "Advanced Spill Suppressor", "Suppresseur de débordement avancé"), - ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"), + ("ec.key.ccsimplewireremoval", "Line Removal", "Suppression de ligne"), ("ec.key.colordifference", "Color Difference Key", "Incrustation par différence de couleur"), ("ec.key.colorkey", "Color Key", "Incrustation couleur"), ("ec.key.colorrange", "Color Range", "Plage de couleurs"), @@ -1699,7 +1706,7 @@ const FRENCH: &[Row] = &[ ("ec.key.screen", "Screen Key", "Incrustation d’écran"), ("ec.key.spill", "Spill Suppressor", "Suppresseur de débordement"), ("ec.key.unmult", "Unmult", "Unmult"), - ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.keying.keylight", "Screen Color Key", "Incrustation par couleur d’écran"), ("ec.matte.mattechoker", "Matte Choker", "Réducteur d’estampe"), ("ec.matte.objectmatte", "Object Matte", "Estampe d’objet"), ("ec.matte.refinehard", "Refine Hard Matte", "Affiner l’estampe nette"), @@ -1723,50 +1730,50 @@ const FRENCH: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "Texte de base"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "Flou gaussien (hérité)"), ("ec.obsolete.lightning", "Lightning", "Foudre"), - ("ec.obsolete.mochashape", "Mocha shape", "Forme Mocha"), + ("ec.obsolete.mochashape", "Tracked Shape Matte", "Cache de forme suivie"), ("ec.obsolete.pathtext", "Path Text", "Texte sur tracé"), ("ec.paint.paint", "Paint", "Peinture"), ("ec.perspective.3dglasses", "3D Glasses", "Lunettes 3D"), ("ec.perspective.bevelalpha", "Bevel Alpha", "Biseau alpha"), ("ec.perspective.beveledges", "Bevel Edges", "Biseau des bords"), ("ec.perspective.cameratracker", "3D Camera Tracker", "Suivi de caméra 3D"), - ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), - ("ec.perspective.ccenvironment", "CC Environment", "CC Environment"), - ("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"), - ("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"), + ("ec.perspective.cccylinder", "Cylinder Wrap", "Enroulement cylindrique"), + ("ec.perspective.ccenvironment", "Environment Map", "Carte d’environnement"), + ("ec.perspective.ccsphere", "Sphere Wrap", "Enroulement sphérique"), + ("ec.perspective.ccspotlight", "Surface Spotlight", "Projecteur de surface"), ("ec.perspective.dropshadow", "Drop Shadow", "Ombre portée"), ("ec.perspective.radialshadow", "Radial Shadow", "Ombre radiale"), ("ec.sim.carddance", "Card Dance", "Danse de cartes"), ("ec.sim.caustics", "Caustics", "Caustiques"), - ("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"), - ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"), - ("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"), - ("ec.sim.cchair", "CC Hair", "CC Hair"), - ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), - ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"), - ("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"), - ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), - ("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"), - ("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"), - ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"), - ("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"), + ("ec.sim.ccballaction", "Ball Grid", "Grille de sphères"), + ("ec.sim.ccbubbles", "Bubbles", "Bulles"), + ("ec.sim.ccdrizzle", "Water Ripples", "Ondulations de l’eau"), + ("ec.sim.cchair", "Surface Fibers", "Fibres de surface"), + ("ec.sim.ccmrmercury", "Liquid Particles", "Particules liquides"), + ("ec.sim.ccparticlesystems2", "Particle Emitter 2D", "Émetteur de particules 2D"), + ("ec.sim.ccparticleworld", "Particle Emitter 3D", "Émetteur de particules 3D"), + ("ec.sim.ccpixelpolly", "Pixel Shatter", "Éclatement des pixels"), + ("ec.sim.ccrainfall", "Rainfall", "Pluie"), + ("ec.sim.ccscatterize", "Pixel Scatter", "Dispersion des pixels"), + ("ec.sim.ccsnowfall", "Snowfall", "Neige"), + ("ec.sim.ccstarburst", "Starfield", "Champ d’étoiles"), ("ec.sim.foam", "Foam", "Mousse"), ("ec.sim.particleplayground", "Particle Playground", "Terrain de jeu de particules"), ("ec.sim.shatter", "Shatter", "Éclatement"), ("ec.sim.waveworld", "Wave World", "Monde des vagues"), ("ec.stylize.brushstrokes", "Brush Strokes", "Coups de pinceau"), ("ec.stylize.cartoon", "Cartoon", "Dessin animé"), - ("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"), - ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"), - ("ec.stylize.ccglass", "CC Glass", "CC Glass"), - ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), - ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), - ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), - ("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"), - ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), - ("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"), - ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"), - ("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"), + ("ec.stylize.ccblockload", "Block Reveal", "Révélation par blocs"), + ("ec.stylize.ccburnfilm", "Film Burn", "Brûlure de pellicule"), + ("ec.stylize.ccglass", "Glass Relief", "Relief de verre"), + ("ec.stylize.cchextile", "Hexagonal Tiles", "Tuiles hexagonales"), + ("ec.stylize.cckaleida", "Kaleidoscope", "Kaléidoscope"), + ("ec.stylize.ccmrsmoothie", "Palette Map", "Palettmappning"), + ("ec.stylize.ccplastic", "Plastic Shading", "Ombrage plastique"), + ("ec.stylize.ccrepetile", "Edge Tile Repeat", "Répétition des tuiles aux bords"), + ("ec.stylize.ccthreshold", "Channel Threshold", "Seuil de canal"), + ("ec.stylize.ccthresholdrgb", "RGB Threshold", "Seuil RVB"), + ("ec.stylize.ccvignette", "Vignette", "Vignettage"), ("ec.stylize.coloremboss", "Color Emboss", "Estampage couleur"), ("ec.stylize.emboss", "Emboss", "Estampage"), ("ec.stylize.findedges", "Find Edges", "Détection des contours"), @@ -1781,8 +1788,8 @@ const FRENCH: &[Row] = &[ ("ec.stylize.threshold", "Threshold", "Seuil"), ("ec.text.numbers", "Numbers", "Nombres"), ("ec.text.timecode", "Timecode", "Timecode"), - ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"), - ("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"), + ("ec.time.ccforcemotionblur", "Temporal Motion Blur", "Flou de mouvement temporel"), + ("ec.time.ccwidetime", "Temporal Blend", "Fusion temporelle"), ("ec.time.echo", "Echo", "Écho"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "Flou de mouvement des pixels"), ("ec.time.posterizetime", "Posterize Time", "Postérisation temporelle"), @@ -1791,23 +1798,23 @@ const FRENCH: &[Row] = &[ ("ec.time.timewarp", "Timewarp", "Déformation temporelle"), ("ec.transition.blockdissolve", "Block Dissolve", "Fondu par blocs"), ("ec.transition.cardwipe", "Card Wipe", "Volet de cartes"), - ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"), - ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"), - ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"), - ("ec.transition.ccjaws", "CC Jaws", "CC Jaws"), - ("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"), - ("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"), - ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"), - ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"), - ("ec.transition.cctwister", "CC Twister", "CC Twister"), - ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.ccglasswipe", "Glass Wipe", "Volet de verre"), + ("ec.transition.ccgridwipe", "Grid Wipe", "Volet en grille"), + ("ec.transition.ccimagewipe", "Luminance Image Wipe", "Volet par luminance d’image"), + ("ec.transition.ccjaws", "Serrated Wipe", "Volet dentelé"), + ("ec.transition.cclightwipe", "Light Wipe", "Volet lumineux"), + ("ec.transition.cclinesweep", "Line Sweep", "Balayage de lignes"), + ("ec.transition.ccradialscalewipe", "Radial Scale Wipe", "Volet d’échelle radial"), + ("ec.transition.ccscalewipe", "Scale Wipe", "Volet d’échelle"), + ("ec.transition.cctwister", "Twist Wipe", "Volet torsadé"), + ("ec.transition.ccwarpomatic", "Warp Dissolve", "Fondu déformant"), ("ec.transition.gradientwipe", "Gradient Wipe", "Volet en dégradé"), ("ec.transition.iriswipe", "Iris Wipe", "Volet en iris"), ("ec.transition.linearwipe", "Linear Wipe", "Volet linéaire"), ("ec.transition.radialwipe", "Radial Wipe", "Volet radial"), ("ec.transition.venetian", "Venetian Blinds", "Stores vénitiens"), ("ec.utility.applylut", "Apply Color LUT", "Appliquer une LUT de couleur"), - ("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"), + ("ec.utility.ccoverbrights", "Out-of-Range Display", "Affichage hors plage"), ("ec.utility.cineon", "Cineon Converter", "Convertisseur Cineon"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "Convertisseur de profil colorimétrique"), ("ec.utility.facemeasurements", "Face Measurements", "Mesures du visage"), @@ -1909,10 +1916,10 @@ const SWEDISH: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "Bilateral oskärpa"), ("ec.blur.cameralens", "Camera Lens Blur", "Kameralinsoskärpa"), ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "Ta bort oskärpa från kameraskakning"), - ("ec.blur.cccross", "CC Cross Blur", "CC Korsoskärpa"), - ("ec.blur.ccradial", "CC Radial Blur", "CC Radiell oskärpa"), - ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Snabb radiell oskärpa"), - ("ec.blur.ccvector", "CC Vector Blur", "CC Vektoroskärpa"), + ("ec.blur.cccross", "Cross Blur", "Korsoskärpa"), + ("ec.blur.ccradial", "Variable Radial Blur", "Variabel radiell oskärpa"), + ("ec.blur.ccradialfast", "Fast Radial Blur", "Snabb radiell oskärpa"), + ("ec.blur.ccvector", "Vector Blur", "Vektoroskärpa"), ("ec.blur.channel", "Channel Blur", "Kanaloskärpa"), ("ec.blur.compound", "Compound Blur", "Sammansatt oskärpa"), ("ec.blur.directional", "Directional Blur", "Riktad oskärpa"), @@ -1926,7 +1933,7 @@ const SWEDISH: &[Row] = &[ ("ec.channel.arithmetic", "Arithmetic", "Aritmetik"), ("ec.channel.blend", "Blend", "Blanda"), ("ec.channel.calculations", "Calculations", "Beräkningar"), - ("ec.channel.cccomposite", "CC Composite", "CC Komposit"), + ("ec.channel.cccomposite", "Source Composite", "Källkomposition"), ("ec.channel.combiner", "Channel Combiner", "Kanalkombinerare"), ("ec.channel.compoundarithmetic", "Compound Arithmetic", "Sammansatt aritmetik"), ("ec.channel.invert", "Invert", "Invertera"), @@ -1942,10 +1949,10 @@ const SWEDISH: &[Row] = &[ ("ec.color.blackwhite", "Black & White", "Svartvitt"), ("ec.color.brightnesscontrast", "Brightness & Contrast", "Ljusstyrka och kontrast"), ("ec.color.broadcast", "Broadcast Colors", "Broadcast-färger"), - ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Färgneutraliserare"), - ("ec.color.cccoloroffset", "CC Color Offset", "CC Färgförskjutning"), - ("ec.color.cckernel", "CC Kernel", "CC Kärna"), - ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.cccolorneutralizer", "Color Neutralizer", "Färgneutralisering"), + ("ec.color.cccoloroffset", "Channel Offset", "Kanalförskjutning"), + ("ec.color.cckernel", "Convolution Kernel", "Faltningskärna"), + ("ec.color.cctoner", "Multi-Tone Tint", "Flertonsfärgning"), ("ec.color.changecolor", "Change Color", "Ändra färg"), ("ec.color.changetocolor", "Change to Color", "Ändra till färg"), ("ec.color.channelmixer", "Channel Mixer", "Kanalmixer"), @@ -1962,7 +1969,7 @@ const SWEDISH: &[Row] = &[ ("ec.color.leavecolor", "Leave Color", "Behåll färg"), ("ec.color.levels", "Levels", "Nivåer"), ("ec.color.levelsic", "Levels (Individual Controls)", "Nivåer (individuella kontroller)"), - ("ec.color.lumetri", "Lumetri Color", "Lumetri-färg"), + ("ec.color.lumetri", "Color Grading", "Färggradering"), ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL-transformering"), ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO färgrymdstransformering"), ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO visningstransformering"), @@ -1987,20 +1994,20 @@ const SWEDISH: &[Row] = &[ ("ec.control.stereo3d", "Stereo 3D Controls", "Stereo 3D-kontroller"), ("ec.distort.bezierwarp", "Bezier Warp", "Bezierförvrängning"), ("ec.distort.bulge", "Bulge", "Utbuktning"), - ("ec.distort.ccbender", "CC Bender", "CC Bender"), - ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), - ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), - ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), - ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), - ("ec.distort.cclens", "CC Lens", "CC Objektiv"), - ("ec.distort.ccpageturn", "CC Page Turn", "CC Sidvändning"), - ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), - ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Krusningspuls"), - ("ec.distort.ccslant", "CC Slant", "CC Lutning"), - ("ec.distort.ccsmear", "CC Smear", "CC Utsmetning"), - ("ec.distort.ccsplit", "CC Split", "CC Dela"), - ("ec.distort.ccsplit2", "CC Split 2", "CC Dela 2"), - ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.ccbender", "Flexible Bend", "Flexibel böjning"), + ("ec.distort.ccbendit", "Two-Point Bend", "Tvåpunktsböjning"), + ("ec.distort.ccblobbylize", "Organic Displacement", "Organisk förskjutning"), + ("ec.distort.ccflomotion", "Dual Lens Warp", "Dubbel linsförvrängning"), + ("ec.distort.ccgriddler", "Tile Grid", "Plattnät"), + ("ec.distort.cclens", "Lens Distortion", "Linsförvrängning"), + ("ec.distort.ccpageturn", "Page Curl", "Sidkrullning"), + ("ec.distort.ccpowerpin", "Perspective Pin", "Perspektivnålar"), + ("ec.distort.ccripplepulse", "Pulsed Ripple", "Pulserande krusning"), + ("ec.distort.ccslant", "Slant", "Lutning"), + ("ec.distort.ccsmear", "Directional Smear", "Riktningssmetning"), + ("ec.distort.ccsplit", "Split Warp", "Delad förvrängning"), + ("ec.distort.ccsplit2", "Dual Split Warp", "Dubbeldelad förvrängning"), + ("ec.distort.cctiler", "Scaled Tiles", "Skalade plattor"), ("ec.distort.cornerpin", "Corner Pin", "Hörnförankring"), ("ec.distort.displacementmap", "Displacement Map", "Förskjutningskarta"), ("ec.distort.liquify", "Liquify", "Gör flytande"), @@ -2028,11 +2035,11 @@ const SWEDISH: &[Row] = &[ ("ec.generate.audiospectrum", "Audio Spectrum", "Ljudspektrum"), ("ec.generate.audiowaveform", "Audio Waveform", "Ljudvågform"), ("ec.generate.beam", "Beam", "Stråle"), - ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), - ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Ljusutbrott 2.5"), - ("ec.generate.cclightrays", "CC Light Rays", "CC Ljusstrålar"), - ("ec.generate.cclightsweep", "CC Light Sweep", "CC Ljussvep"), - ("ec.generate.ccthreads", "CC Threads", "CC Trådar"), + ("ec.generate.ccgluegun", "Paint Beads", "Färgpärlor"), + ("ec.generate.cclightburst", "Radial Light Burst", "Radiellt ljusutbrott"), + ("ec.generate.cclightrays", "Light Rays", "Ljusstrålar"), + ("ec.generate.cclightsweep", "Light Sweep", "Ljussvep"), + ("ec.generate.ccthreads", "Woven Threads", "Vävda trådar"), ("ec.generate.cellpattern", "Cell Pattern", "Cellmönster"), ("ec.generate.checkerboard", "Checkerboard", "Schackbräde"), ("ec.generate.circle", "Circle", "Cirkel"), @@ -2051,7 +2058,7 @@ const SWEDISH: &[Row] = &[ ("ec.generate.vegas", "Vegas", "Vegas"), ("ec.generate.writeon", "Write-on", "Framskrivning"), ("ec.key.advancedspill", "Advanced Spill Suppressor", "Avancerad spilldämpning"), - ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Enkel trådborttagning"), + ("ec.key.ccsimplewireremoval", "Line Removal", "Linjeborttagning"), ("ec.key.colordifference", "Color Difference Key", "Färgdifferensnyckel"), ("ec.key.colorkey", "Color Key", "Färgnyckel"), ("ec.key.colorrange", "Color Range", "Färgomfång"), @@ -2064,7 +2071,7 @@ const SWEDISH: &[Row] = &[ ("ec.key.screen", "Screen Key", "Skärmnyckel"), ("ec.key.spill", "Spill Suppressor", "Spilldämpning"), ("ec.key.unmult", "Unmult", "Unmult"), - ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.keying.keylight", "Screen Color Key", "Skärmfärgsnyckling"), ("ec.matte.mattechoker", "Matte Choker", "Mattekrympning"), ("ec.matte.objectmatte", "Object Matte", "Objektmatte"), ("ec.matte.refinehard", "Refine Hard Matte", "Förfina hård matte"), @@ -2088,50 +2095,50 @@ const SWEDISH: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "Grundläggande text"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "Gaussisk oskärpa (äldre)"), ("ec.obsolete.lightning", "Lightning", "Blixt"), - ("ec.obsolete.mochashape", "Mocha shape", "Mocha-form"), + ("ec.obsolete.mochashape", "Tracked Shape Matte", "Spårad formmask"), ("ec.obsolete.pathtext", "Path Text", "Text längs bana"), ("ec.paint.paint", "Paint", "Måla"), ("ec.perspective.3dglasses", "3D Glasses", "3D-glasögon"), ("ec.perspective.bevelalpha", "Bevel Alpha", "Alfafasning"), ("ec.perspective.beveledges", "Bevel Edges", "Fasade kanter"), ("ec.perspective.cameratracker", "3D Camera Tracker", "3D-kameraspårning"), - ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), - ("ec.perspective.ccenvironment", "CC Environment", "CC Miljö"), - ("ec.perspective.ccsphere", "CC Sphere", "CC Sfär"), - ("ec.perspective.ccspotlight", "CC Spotlight", "CC Strålkastare"), + ("ec.perspective.cccylinder", "Cylinder Wrap", "Cylinderomslag"), + ("ec.perspective.ccenvironment", "Environment Map", "Miljökarta"), + ("ec.perspective.ccsphere", "Sphere Wrap", "Sfäromslag"), + ("ec.perspective.ccspotlight", "Surface Spotlight", "Ytstrålkastare"), ("ec.perspective.dropshadow", "Drop Shadow", "Slagskugga"), ("ec.perspective.radialshadow", "Radial Shadow", "Radiell skugga"), ("ec.sim.carddance", "Card Dance", "Kortdans"), ("ec.sim.caustics", "Caustics", "Kaustik"), - ("ec.sim.ccballaction", "CC Ball Action", "CC Bollåtgärd"), - ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubblor"), - ("ec.sim.ccdrizzle", "CC Drizzle", "CC Duggregn"), - ("ec.sim.cchair", "CC Hair", "CC Hår"), - ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), - ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Partikelsystem II"), - ("ec.sim.ccparticleworld", "CC Particle World", "CC Partikelvärld"), - ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), - ("ec.sim.ccrainfall", "CC Rainfall", "CC Regn"), - ("ec.sim.ccscatterize", "CC Scatterize", "CC Utspridning"), - ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snöfall"), - ("ec.sim.ccstarburst", "CC Star Burst", "CC Stjärnexplosion"), + ("ec.sim.ccballaction", "Ball Grid", "Kulnät"), + ("ec.sim.ccbubbles", "Bubbles", "Bubblor"), + ("ec.sim.ccdrizzle", "Water Ripples", "Vattenkrusningar"), + ("ec.sim.cchair", "Surface Fibers", "Ytfibrer"), + ("ec.sim.ccmrmercury", "Liquid Particles", "Vätskepartiklar"), + ("ec.sim.ccparticlesystems2", "Particle Emitter 2D", "Partikelsändare 2D"), + ("ec.sim.ccparticleworld", "Particle Emitter 3D", "Partikelsändare 3D"), + ("ec.sim.ccpixelpolly", "Pixel Shatter", "Pixelsplittring"), + ("ec.sim.ccrainfall", "Rainfall", "Regn"), + ("ec.sim.ccscatterize", "Pixel Scatter", "Pixelspridning"), + ("ec.sim.ccsnowfall", "Snowfall", "Snöfall"), + ("ec.sim.ccstarburst", "Starfield", "Stjärnfält"), ("ec.sim.foam", "Foam", "Skum"), ("ec.sim.particleplayground", "Particle Playground", "Partikellekplats"), ("ec.sim.shatter", "Shatter", "Krossa"), ("ec.sim.waveworld", "Wave World", "Vågvärld"), ("ec.stylize.brushstrokes", "Brush Strokes", "Penseldrag"), ("ec.stylize.cartoon", "Cartoon", "Tecknat"), - ("ec.stylize.ccblockload", "CC Block Load", "CC Blockladdning"), - ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Bränn film"), - ("ec.stylize.ccglass", "CC Glass", "CC Glas"), - ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), - ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), - ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), - ("ec.stylize.ccplastic", "CC Plastic", "CC Plast"), - ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), - ("ec.stylize.ccthreshold", "CC Threshold", "CC Tröskel"), - ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Tröskel RGB"), - ("ec.stylize.ccvignette", "CC Vignette", "CC Vinjett"), + ("ec.stylize.ccblockload", "Block Reveal", "Blockframträdande"), + ("ec.stylize.ccburnfilm", "Film Burn", "Filmbränning"), + ("ec.stylize.ccglass", "Glass Relief", "Glasrelief"), + ("ec.stylize.cchextile", "Hexagonal Tiles", "Sexkantiga plattor"), + ("ec.stylize.cckaleida", "Kaleidoscope", "Kalejdoskop"), + ("ec.stylize.ccmrsmoothie", "Palette Map", "Mappage de palette"), + ("ec.stylize.ccplastic", "Plastic Shading", "Plastskuggning"), + ("ec.stylize.ccrepetile", "Edge Tile Repeat", "Kantupprepning"), + ("ec.stylize.ccthreshold", "Channel Threshold", "Kanaltröskel"), + ("ec.stylize.ccthresholdrgb", "RGB Threshold", "RGB-tröskel"), + ("ec.stylize.ccvignette", "Vignette", "Vinjettering"), ("ec.stylize.coloremboss", "Color Emboss", "Färgrelief"), ("ec.stylize.emboss", "Emboss", "Relief"), ("ec.stylize.findedges", "Find Edges", "Hitta kanter"), @@ -2146,8 +2153,8 @@ const SWEDISH: &[Row] = &[ ("ec.stylize.threshold", "Threshold", "Tröskel"), ("ec.text.numbers", "Numbers", "Siffror"), ("ec.text.timecode", "Timecode", "Tidskod"), - ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Tvinga rörelseoskärpa"), - ("ec.time.ccwidetime", "CC Wide Time", "CC Bred tid"), + ("ec.time.ccforcemotionblur", "Temporal Motion Blur", "Tidsbaserad rörelseoskärpa"), + ("ec.time.ccwidetime", "Temporal Blend", "Tidsblandning"), ("ec.time.echo", "Echo", "Eko"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "Pixelrörelseoskärpa"), ("ec.time.posterizetime", "Posterize Time", "Posterisera tid"), @@ -2156,23 +2163,23 @@ const SWEDISH: &[Row] = &[ ("ec.time.timewarp", "Timewarp", "Tidsförvrängning"), ("ec.transition.blockdissolve", "Block Dissolve", "Blockövertoning"), ("ec.transition.cardwipe", "Card Wipe", "Kortridå"), - ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glasridå"), - ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Rutnätsridå"), - ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Bildridå"), - ("ec.transition.ccjaws", "CC Jaws", "CC Käftar"), - ("ec.transition.cclightwipe", "CC Light Wipe", "CC Ljusridå"), - ("ec.transition.cclinesweep", "CC Line Sweep", "CC Linjesvep"), - ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radiell skalridå"), - ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Skalridå"), - ("ec.transition.cctwister", "CC Twister", "CC Virvel"), - ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.ccglasswipe", "Glass Wipe", "Glasövergång"), + ("ec.transition.ccgridwipe", "Grid Wipe", "Rutnätsövergång"), + ("ec.transition.ccimagewipe", "Luminance Image Wipe", "Luminansbildsövergång"), + ("ec.transition.ccjaws", "Serrated Wipe", "Tandad övergång"), + ("ec.transition.cclightwipe", "Light Wipe", "Ljusövergång"), + ("ec.transition.cclinesweep", "Line Sweep", "Linjesvep"), + ("ec.transition.ccradialscalewipe", "Radial Scale Wipe", "Radiell skalövergång"), + ("ec.transition.ccscalewipe", "Scale Wipe", "Skalövergång"), + ("ec.transition.cctwister", "Twist Wipe", "Vridövergång"), + ("ec.transition.ccwarpomatic", "Warp Dissolve", "Förvrängningsupplösning"), ("ec.transition.gradientwipe", "Gradient Wipe", "Gradientridå"), ("ec.transition.iriswipe", "Iris Wipe", "Irisridå"), ("ec.transition.linearwipe", "Linear Wipe", "Linjär ridå"), ("ec.transition.radialwipe", "Radial Wipe", "Radiell ridå"), ("ec.transition.venetian", "Venetian Blinds", "Persienner"), ("ec.utility.applylut", "Apply Color LUT", "Använd färg-LUT"), - ("ec.utility.ccoverbrights", "CC Overbrights", "CC Överljust"), + ("ec.utility.ccoverbrights", "Out-of-Range Display", "Visa utanför intervallet"), ("ec.utility.cineon", "Cineon Converter", "Cineon-konverterare"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "Färgprofilkonverterare"), ("ec.utility.facemeasurements", "Face Measurements", "Ansiktsmått"), @@ -2226,6 +2233,38 @@ mod tests { use crate::i18n::set_current; use effectcraft_engine::effects; + #[test] + fn legacy_effect_names_draw_current_labels_without_changing_custom_names() { + for language in ["en", "ja", "zh-hans", "zh-hant", "fr", "sv", "ka", "cs", "de", "es", "pl", "pt-br", "ru", "uk"] { + set_current(language); + for spec in effects::registry() { + let translated = name(spec.id, spec.name); + for alias in effects::aliases(spec.id) { + assert_eq!(group(spec.id, alias), translated, "{language}: {alias}"); + assert!(matches(spec.id, alias, &spec.name.to_lowercase()), "{language}: search current name of {alias}"); + assert!(matches(spec.id, spec.name, &alias.to_lowercase()), "{language}: search legacy name {alias}"); + assert_eq!(group(spec.id, &format!("{alias} 2")), format!("{translated} 2")); + let custom = format!("{alias} custom"); + assert_eq!(group(spec.id, &custom), custom); + } + } + } + set_current("en"); + } + + #[test] + fn localized_effect_names_do_not_reintroduce_legacy_branding() { + for (language, rows) in COMPLETE.iter().chain(PARTIAL) { + for (_, _, translated) in *rows { + let label = translated.to_lowercase(); + assert!(!label.starts_with("cc "), "{language}: {translated}"); + for mark in ["lumetri", "mercury", "mocha", "keylight"] { + assert!(!label.contains(mark), "{language}: {translated}"); + } + } + } + } + /// Every row names an effect in the registry and still carries its English name, so a rename /// or a removal fails here rather than attaching a translation to a different effect. #[test] @@ -2242,6 +2281,20 @@ mod tests { } } + /// Two effects that draw one name cannot be told apart in the Effect menu, Effect Controls or + /// Effects & Presets, so every catalog names each effect it covers distinctly. + #[test] + fn display_names_are_unique_in_every_catalog() { + for (language, rows) in COMPLETE.iter().chain(PARTIAL) { + let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + for (id, _, translated) in *rows { + if let Some(other) = seen.insert(translated, id) { + assert_ne!(other, *id, "{language}: {id} and {other} are both {translated:?}"); + } + } + } + } + /// Rows are keyed by id, so a repeated id would shadow a translation. #[test] fn ids_are_unique() { diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 15bf774f9..ac6504493 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -708,8 +708,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Import Photoshop File", "Photoshopファイルを読み込み"), ("Interpret Unlabeled Alpha As", "ラベルのないアルファの解釈"), ("Join the ArtCraft Discord", "ArtCraftのDiscordに参加"), - ("Mercury GPU Acceleration ({})", "Mercury GPUアクセラレーション({})"), - ("Mercury Software Only", "Mercuryソフトウェアのみ"), + ("GPU Acceleration ({})", "GPUアクセラレーション({})"), + ("Software Rendering", "ソフトウェアレンダリング"), ("OCIO (built-in ACES config)", "OCIO(組み込みACES設定)"), ("Off (Final Quality)", "オフ(最終品質)"), ("OpenGL (compatibility)", "OpenGL(互換)"), @@ -1118,7 +1118,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Script Console", "スクリプトコンソール"), ("Essential Graphics", "エッセンシャルグラフィックス"), ("ScriptUI Panel", "ScriptUIパネル"), - ("Lumetri Scopes", "Lumetriスコープ"), + ("Video Scopes", "ビデオスコープ"), ("Media Browser", "メディアブラウザー"), ("Content-Aware Fill", "コンテンツに応じた塗りつぶし"), ("Create Nulls From Paths", "パスからヌルを作成"), @@ -1318,6 +1318,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("per second", "毎秒"), ("Waveform", "波形"), ("steps", "ステップ"), + ("GPU Acceleration (no GPU: software)", "GPUアクセラレーション(GPUなし:ソフトウェア)"), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -1837,8 +1838,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Import Photoshop File", "导入 Photoshop 文件"), ("Interpret Unlabeled Alpha As", "将未标记的 Alpha 解释为"), ("Join the ArtCraft Discord", "加入 ArtCraft Discord"), - ("Mercury GPU Acceleration ({})", "Mercury GPU 加速({})"), - ("Mercury Software Only", "仅 Mercury 软件"), + ("GPU Acceleration ({})", "GPU 加速({})"), + ("Software Rendering", "软件渲染"), ("OCIO (built-in ACES config)", "OCIO(内置 ACES 配置)"), ("Off (Final Quality)", "关闭(最终质量)"), ("OpenGL (compatibility)", "OpenGL(兼容)"), @@ -2280,7 +2281,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Script Console", "脚本控制台"), ("Essential Graphics", "基本图形"), ("ScriptUI Panel", "ScriptUI 面板"), - ("Lumetri Scopes", "Lumetri 示波器"), + ("Video Scopes", "视频示波器"), ("Media Browser", "媒体浏览器"), ("Content-Aware Fill", "内容识别填充"), ("Create Nulls From Paths", "从路径创建空对象"), @@ -2433,6 +2434,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Blend Mode", "混合模式"), ("Show mesh", "显示网格"), ("Record Options...", "记录选项..."), + ("GPU Acceleration (no GPU: software)", "GPU 加速(无 GPU:软件)"), ]; /// Swedish (`sv`), written from the English source. @@ -2950,8 +2952,8 @@ const SWEDISH: &[(&str, &str)] = &[ ("Import Photoshop File", "Importera Photoshop-fil"), ("Interpret Unlabeled Alpha As", "Tolka omärkt alfa som"), ("Join the ArtCraft Discord", "Gå med i ArtCraft på Discord"), - ("Mercury GPU Acceleration ({})", "Mercury GPU-acceleration ({})"), - ("Mercury Software Only", "Endast Mercury-programvara"), + ("GPU Acceleration ({})", "GPU-acceleration ({})"), + ("Software Rendering", "Programvarurendering"), ("OCIO (built-in ACES config)", "OCIO (inbyggd ACES-konfiguration)"), ("Off (Final Quality)", "Av (slutlig kvalitet)"), ("OpenGL (compatibility)", "OpenGL (kompatibilitet)"), @@ -3191,7 +3193,7 @@ const SWEDISH: &[(&str, &str)] = &[ ("Drop {} files to import", "Släpp {} filer för att importera"), ("Effects", "Effekter"), ("Label of {}: {}", "Etikett för {}: {}"), - ("Mercury GPU Acceleration (no GPU: software)", "Mercury GPU-acceleration (ingen GPU: programvara)"), + ("GPU Acceleration (no GPU: software)", "GPU-acceleration (ingen GPU: programvara)"), ("Only one project can be opened per drop; skipped {} more", "Bara ett projekt kan öppnas per släpp; hoppade över {} till"), ("Open in Essential Graphics", "Öppna i Grundläggande grafik"), ("Original color: click to go back to it", "Originalfärg: klicka för att återgå till den"), @@ -3440,7 +3442,7 @@ const SWEDISH: &[(&str, &str)] = &[ ("Script Console", "Skriptkonsol"), ("Essential Graphics", "Grundläggande grafik"), ("ScriptUI Panel", "ScriptUI-panel"), - ("Lumetri Scopes", "Lumetri-scope"), + ("Video Scopes", "Videoskop"), ("Media Browser", "Mediebläddrare"), ("Content-Aware Fill", "Innehållsmedveten fyllning"), ("Create Nulls From Paths", "Skapa nullobjekt från banor"), @@ -4608,7 +4610,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Script Console", "指令碼主控台"), ("Essential Graphics", "基本圖形"), ("ScriptUI Panel", "ScriptUI 面板"), - ("Lumetri Scopes", "Lumetri 示波器"), + ("Video Scopes", "視訊示波器"), ("Media Browser", "媒體瀏覽器"), ("Content-Aware Fill", "內容感知填色"), ("Create Nulls From Paths", "從路徑建立空物件"), @@ -4761,6 +4763,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Blend Mode", "混合模式"), ("Show mesh", "顯示網格"), ("Record Options...", "記錄選項..."), + ("Software Rendering", "軟體算繪"), + ("GPU Acceleration ({})", "GPU 加速({})"), + ("GPU Acceleration (no GPU: software)", "GPU 加速(無 GPU:軟體)"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -5301,8 +5306,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Import Photoshop File", "Імпорт файлу Photoshop"), ("Interpret Unlabeled Alpha As", "Інтерпретувати непозначену альфу як"), ("Join the ArtCraft Discord", "Приєднатися до Discord ArtCraft"), - ("Mercury GPU Acceleration ({})", "Прискорення Mercury GPU ({})"), - ("Mercury Software Only", "Лише програмний Mercury"), + ("GPU Acceleration ({})", "Прискорення GPU ({})"), + ("Software Rendering", "Програмне відтворення"), ("OCIO (built-in ACES config)", "OCIO (вбудована конфігурація ACES)"), ("Off (Final Quality)", "Вимкнено (остаточна якість)"), ("OpenGL (compatibility)", "OpenGL (сумісність)"), @@ -5348,6 +5353,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Project", "Проєкт"), ("Total Offset", "Загальний зсув"), ("Per Layer", "На шар"), + ("Video Scopes", "Відеоскопи"), + ("GPU Acceleration (no GPU: software)", "Прискорення GPU (без GPU: програмне)"), ]; // Brazilian Portuguese. @@ -5979,7 +5986,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Guess", "Adivinhar"), ("Import Image Sequence", "Importar sequência de imagens"), ("Import PDF / Illustrator File", "Importar arquivo PDF / Illustrator"), - ("Mercury Software Only", "Apenas software Mercury"), + ("Software Rendering", "Renderização por software"), ("OCIO (built-in ACES config)", "OCIO (config do ACES integrado)"), ("Still Duration", "Duração da imagem fixa"), ("Straight", "Direto"), @@ -6086,7 +6093,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Light", "Claro"), ("Lines", "Linhas"), ("Memory & CPU", "Memória e CPU"), - ("Mercury GPU Acceleration ({})", "Aceleração de GPU Mercury ({})"), + ("GPU Acceleration ({})", "Aceleração de GPU ({})"), ("Off", "Desativado"), ("Options", "Opções"), ("Overlay", "Sobrepor"), @@ -6111,6 +6118,8 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Video", "Vídeo"), ("channel", "canal"), ("files", "arquivos"), + ("Video Scopes", "Escopos de vídeo"), + ("GPU Acceleration (no GPU: software)", "Aceleração de GPU (sem GPU: software)"), ]; /// Polish (pl): original translations covering every row in the Chinese panel catalogs. @@ -6629,8 +6638,8 @@ const POLISH: &[(&str, &str)] = &[ ("Import Photoshop File", "Importuj plik Photoshopa"), ("Interpret Unlabeled Alpha As", "Interpretuj nieoznaczoną alfę jako"), ("Join the ArtCraft Discord", "Dołącz do Discorda ArtCraft"), - ("Mercury GPU Acceleration ({})", "Akceleracja Mercury GPU ({})"), - ("Mercury Software Only", "Tylko programowy Mercury"), + ("GPU Acceleration ({})", "Akceleracja GPU ({})"), + ("Software Rendering", "Renderowanie programowe"), ("OCIO (built-in ACES config)", "OCIO (wbudowana konfiguracja ACES)"), ("Off (Final Quality)", "Wył. (jakość końcowa)"), ("OpenGL (compatibility)", "OpenGL (zgodność)"), @@ -6868,6 +6877,8 @@ const POLISH: &[(&str, &str)] = &[ ("Resolution:", "Rozdzielczość:"), ("Guide Layer", "Warstwa pomocnicza"), ("Layer Styles", "Style warstwy"), + ("Video Scopes", "Wskaźniki wideo"), + ("GPU Acceleration (no GPU: software)", "Akceleracja GPU (bez GPU: programowa)"), ]; /// Czech (cs): original translations covering every row in the Chinese panel catalogs. @@ -7380,8 +7391,8 @@ const CZECH: &[(&str, &str)] = &[ ("Import Photoshop File", "Importovat soubor Photoshop"), ("Interpret Unlabeled Alpha As", "Interpretovat neoznačenou alfu jako"), ("Join the ArtCraft Discord", "Připojit se na Discord ArtCraft"), - ("Mercury GPU Acceleration ({})", "GPU akcelerace Mercury ({})"), - ("Mercury Software Only", "Pouze softwarové zpracování Mercury"), + ("GPU Acceleration ({})", "Akcelerace GPU ({})"), + ("Software Rendering", "Softwarové vykreslování"), ("OCIO (built-in ACES config)", "OCIO (vestavěná konfigurace ACES)"), ("Off (Final Quality)", "Vypnuto (finální kvalita)"), ("OpenGL (compatibility)", "OpenGL (kompatibilita)"), @@ -7432,6 +7443,8 @@ const CZECH: &[(&str, &str)] = &[ ("Variable fonts only", "Jen proměnná písma"), ("Total Offset", "Celkový posun"), ("Per Layer", "Na vrstvu"), + ("Video Scopes", "Videoskopy"), + ("GPU Acceleration (no GPU: software)", "Akcelerace GPU (bez GPU: software)"), ]; /// Spanish: neutral, tú, sentence case; the same source strings as the Chinese catalogs, in the same @@ -7960,8 +7973,8 @@ const SPANISH: &[(&str, &str)] = &[ ("Import Photoshop File", "Importar archivo de Photoshop"), ("Interpret Unlabeled Alpha As", "Interpretar alfa sin etiquetar como"), ("Join the ArtCraft Discord", "Únete al Discord de ArtCraft"), - ("Mercury GPU Acceleration ({})", "Aceleración por GPU Mercury ({})"), - ("Mercury Software Only", "Solo software Mercury"), + ("GPU Acceleration ({})", "Aceleración por GPU ({})"), + ("Software Rendering", "Renderizado por software"), ("OCIO (built-in ACES config)", "OCIO (configuración ACES integrada)"), ("Off (Final Quality)", "Desactivado (calidad final)"), ("OpenGL (compatibility)", "OpenGL (compatibilidad)"), @@ -8002,6 +8015,8 @@ const SPANISH: &[(&str, &str)] = &[ ("After Effects project", "Proyecto de After Effects"), ("Lottie animation", "Animación de Lottie"), ("Project", "Proyecto"), + ("Video Scopes", "Monitores de vídeo"), + ("GPU Acceleration (no GPU: software)", "Aceleración por GPU (sin GPU: software)"), ]; /// Georgian (ka): every row of the Chinese catalogs, in the same order (the tests keep them in step). @@ -8528,8 +8543,8 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Import Photoshop File", "Photoshop ფაილის იმპორტი"), ("Interpret Unlabeled Alpha As", "უცნობი ალფას ინტერპრეტაცია"), ("Join the ArtCraft Discord", "შემოგვიერთდით Discord-ზე"), - ("Mercury GPU Acceleration ({})", "GPU აჩქარება ({})"), - ("Mercury Software Only", "მხოლოდ Mercury Software"), + ("GPU Acceleration ({})", "GPU აჩქარება ({})"), + ("Software Rendering", "პროგრამული რენდერი"), ("OCIO (built-in ACES config)", "OCIO (ჩაშენებული ACES კონფიგურაცია)"), ("Off (Final Quality)", "გამორთული (საბოლოო ხარისხი)"), ("OpenGL (compatibility)", "OpenGL (თავსებადობა)"), @@ -8761,6 +8776,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Total Offset", "ჯამური წანაცვლება"), ("Per Layer", "შრიდან შრემდე"), ("Track", "ტრეკინგი"), + ("Video Scopes", "ვიდეოსკოპები"), ]; /// Georgian rows for interface text no other catalog has yet: strings the panels already send @@ -8951,7 +8967,7 @@ const GEORGIAN_PANELS: &[(&str, &str)] = &[ ("Solid Settings", "სოლიდის პარამეტრები"), ("Save as Template", "შაბლონად შენახვა"), ("My Custom RGB", "ჩემი მორგებული RGB"), - ("Mercury GPU Acceleration (no GPU: software)", "Mercury GPU აჩქარება (GPU არ არის: პროგრამული)"), + ("GPU Acceleration (no GPU: software)", "GPU აჩქარება (GPU არ არის: პროგრამული)"), ("Sort by {}, {}", "დალაგება: {}, {}"), ("Sort by {}", "დალაგება: {}"), ("Label of {}: {}", "{}-ის იარლიყი: {}"), @@ -9510,8 +9526,8 @@ const FRENCH: &[(&str, &str)] = &[ ("Import Photoshop File", "Importer un fichier Photoshop"), ("Interpret Unlabeled Alpha As", "Interpréter l’alpha non étiqueté comme"), ("Join the ArtCraft Discord", "Rejoindre le Discord ArtCraft"), - ("Mercury GPU Acceleration ({})", "Accélération GPU Mercury ({})"), - ("Mercury Software Only", "Mercury, logiciel uniquement"), + ("GPU Acceleration ({})", "Accélération GPU ({})"), + ("Software Rendering", "Rendu logiciel"), ("OCIO (built-in ACES config)", "OCIO (configuration ACES intégrée)"), ("Off (Final Quality)", "Désactivé (qualité finale)"), ("OpenGL (compatibility)", "OpenGL (compatibilité)"), @@ -9926,7 +9942,7 @@ const FRENCH: &[(&str, &str)] = &[ ("Solid Settings", "Paramètres du solide"), ("Save as Template", "Enregistrer en tant que modèle"), ("My Custom RGB", "Mon RVB personnalisé"), - ("Mercury GPU Acceleration (no GPU: software)", "Accélération GPU Mercury (aucun GPU : logiciel)"), + ("GPU Acceleration (no GPU: software)", "Accélération GPU (aucun GPU : logiciel)"), ("Sort by {}, {}", "Trier par {}, {}"), ("Sort by {}", "Trier par {}"), ("Label of {}: {}", "Étiquette de {} : {}"), @@ -10047,7 +10063,7 @@ const FRENCH: &[(&str, &str)] = &[ ("Script Console", "Console de scripts"), ("Essential Graphics", "Graphiques essentiels"), ("ScriptUI Panel", "Panneau ScriptUI"), - ("Lumetri Scopes", "Scopes Lumetri"), + ("Video Scopes", "Oscilloscopes vidéo"), ("Media Browser", "Explorateur de médias"), ("Content-Aware Fill", "Remplissage adapté au contenu"), ("Create Nulls From Paths", "Créer des objets nuls à partir de tracés"), @@ -10266,6 +10282,26 @@ const FRENCH: &[(&str, &str)] = &[ mod tests { use super::*; + /// Check rendered feature labels only. Format names, compatibility tokens and user text + /// are not subject to this guard, and a missing translation keeps its English fallback. + #[test] + fn renamed_panel_labels_do_not_reintroduce_vendor_names() { + for language in ["en", "ja", "zh-hans", "zh-hant", "fr", "sv", "ka", "cs", "de", "es", "pl", "pt-br", "ru", "uk"] { + set_current(language); + for source in ["GPU Acceleration ({})", "GPU Acceleration (no GPU: software)", "Software Rendering", "Video Scopes"] { + let rendered = tr(source); + assert!(!rendered.is_empty(), "{language}: {source}"); + let label = rendered.to_lowercase(); + for mark in ["lumetri", "mercury", "mocha", "keylight"] { + assert!(!label.contains(mark), "{language}: {source} -> {rendered}"); + } + } + // A real interchange label remains factual, not rewritten or rejected. + assert!(!tr("Export Adobe Premiere Pro Project").is_empty()); + } + set_current("en"); + } + /// The interface text that has moved into this catalog. Add a panel here when you convert it; /// `every_row_is_used_by_a_converted_panel` then looks for its rows' English sources in it. pub(super) const CONVERTED: &[(&str, &str)] = &[ diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 635ef9a43..e83530fa4 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -338,11 +338,11 @@ fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &m /// The Project Settings dialog's title and fields. fn project_settings_form(s: &effectcraft_engine::Session) -> (String, Vec) { let st = &s.project.settings; - // The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through + // The catalogue row is `GPU Acceleration ({})`, so the value goes through // tr_args: wrapping the assembled string in tr() would look up the backend's name. let gpu_label = match &s.accel { - Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]), - None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(), + Some(a) => tr_args("GPU Acceleration ({})", &[&a.name()]), + None => tr("GPU Acceleration (no GPU: software)").to_string(), }; let depth = match st.bit_depth.label() { l if l.starts_with("16") => 1, @@ -436,7 +436,7 @@ fn project_settings_form(s: &effectcraft_engine::Session) -> (String, Vec Field::choice( "renderer", "Video rendering and effects", - &[(gpu_label.as_str(), json!("gpu")), ("Mercury Software Only", json!("software"))], + &[(gpu_label.as_str(), json!("gpu")), ("Software Rendering", json!("software"))], usize::from(!st.gpu_acceleration), ), ], @@ -1050,6 +1050,18 @@ pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Toke mod tests { use super::*; + #[test] + fn renderer_form_keeps_descriptive_labels_and_stable_option_values() { + crate::i18n::set_current("en"); + let (_, fields) = project_settings_form(&effectcraft_engine::Session::default()); + let renderer = fields.iter().find(|field| field.key == "renderer").unwrap(); + let FieldKind::Choice { options, .. } = &renderer.kind else { panic!("renderer must be a choice") }; + assert_eq!( + options.as_slice(), + &[("GPU Acceleration (no GPU: software)".to_string(), json!("gpu")), ("Software Rendering".to_string(), json!("software"))] + ); + } + #[test] fn form_params_build_arrays_and_choices() { let f = Form { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 60a30e97e..612aa0a10 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -756,7 +756,7 @@ pub struct UiState { /// Flowchart panel options (layers, effects, solids, direction, root comp). #[serde(default)] pub flowchart: crate::panels::flowchart::FlowOptions, - /// Lumetri Scopes panel options. + /// Video Scopes panel options. #[serde(default)] pub scopes: crate::panels::scopes_panel::ScopesState, } diff --git a/crates/ui-egui/tests/it/ui_angles.rs b/crates/ui-egui/tests/it/ui_angles.rs index 007e11286..40ad17bc1 100644 --- a/crates/ui-egui/tests/it/ui_angles.rs +++ b/crates/ui-egui/tests/it/ui_angles.rs @@ -38,7 +38,7 @@ fn harness(panel: Panel, angle: f64) -> (Harness<'static, EffectcraftApp>, u64, s.execute("comp.new", json!({"name": "Angles", "width": 320, "height": 180, "duration": 4})).unwrap(); let layer = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#40b080"})).unwrap()["layer"].as_u64().unwrap(); let uid = if matches!(panel, Panel::EffectControls) { - let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "CC Bend It"})).unwrap()["effects"][0].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "Two-Point Bend"})).unwrap()["effects"][0].as_u64().unwrap(); s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().get("bend").unwrap().uid } else { s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/rotation").unwrap().uid diff --git a/crates/ui-egui/tests/it/ui_color_hex.rs b/crates/ui-egui/tests/it/ui_color_hex.rs index 4f8a8ab14..8a5a39b8e 100644 --- a/crates/ui-egui/tests/it/ui_color_hex.rs +++ b/crates/ui-egui/tests/it/ui_color_hex.rs @@ -1,4 +1,4 @@ -//! The colour picker every colour input opens: its hex field sets an effect's colour (CC Toner), +//! The colour picker every colour input opens: its hex field sets an effect's colour (Multi-Tone Tint), //! the dialogs' colour button stores sRGB (egui's own `color_edit_button_rgba` read the stored //! sRGB values as linear, so mid-grey `#808080` was stored as `#373737`), the square, slider and //! HSB / RGB fields set the colour, and Cancel / Escape / the original swatch restore it. @@ -45,7 +45,7 @@ fn hex_code_sets_an_effect_colour() { let mut s = Session::default(); s.execute("comp.new", json!({"name": "Hex", "width": 320, "height": 180, "duration": 4})).unwrap(); let layer = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#40b080"})).unwrap()["layer"].as_u64().unwrap(); - let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "CC Toner"})).unwrap()["effects"][0].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "Multi-Tone Tint"})).unwrap()["effects"][0].as_u64().unwrap(); let uid = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().get("midtones").unwrap().uid; s.state.selected_layers = vec![LayerId(layer)]; let mut app = EffectcraftApp::new(s); diff --git a/crates/ui-egui/tests/it/ui_effect_controls.rs b/crates/ui-egui/tests/it/ui_effect_controls.rs index 1db059220..15802d889 100644 --- a/crates/ui-egui/tests/it/ui_effect_controls.rs +++ b/crates/ui-egui/tests/it/ui_effect_controls.rs @@ -260,14 +260,14 @@ fn the_eyedropper_stays_armed_until_the_viewer_has_a_frame() { } } -/// Key Light's Screen Colour eyedropper picks from the effect's input: the shown frame is +/// Screen Color Key's Screen Colour eyedropper picks from the effect's input: the shown frame is /// already keyed (the default screen colour takes most of a green), so sampling it would miss. #[test] fn keyer_eyedropper_picks_the_screen_from_the_effect_input() { let mut s = Session::default(); s.execute("comp.new", json!({"name": "Key", "width": 320, "height": 180, "frameRate": 30, "duration": 1})).unwrap(); let screen = s.execute("layer.newSolid", json!({"name": "Screen", "color": "#30b050"})).unwrap()["layer"].as_u64().unwrap(); - let fx = s.execute("effect.apply", json!({"layers": [screen], "effect": "Key Light"})).unwrap()["effects"][0].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layers": [screen], "effect": "Screen Color Key"})).unwrap()["effects"][0].as_u64().unwrap(); let prop = layer(&s, screen).effects().unwrap().groups().find(|g| g.uid == fx).and_then(|g| g.get("screenColour")).map(|p| p.uid).unwrap(); s.state.selected_layers = vec![LayerId(screen)]; let mut app = EffectcraftApp::new(s); @@ -295,7 +295,7 @@ fn keyer_eyedropper_picks_the_screen_from_the_effect_input() { fn command_clicking_an_effect_twirl_closes_and_opens_its_groups() { let (app, x) = app(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app); - let fx = h.state_mut().session.execute("effect.apply", json!({"layers": [x.small], "effect": "Lumetri Color"})).unwrap()["effects"][0].as_u64().unwrap(); + let fx = h.state_mut().session.execute("effect.apply", json!({"layers": [x.small], "effect": "Color Grading"})).unwrap()["effects"][0].as_u64().unwrap(); // Only this effect open, so its header is on screen. let l = layer(&h.state().session, x.small); let others: Vec = l.effects().unwrap().groups().map(|g| g.uid).filter(|u| *u != fx).collect(); diff --git a/crates/ui-egui/tests/it/ui_effect_drop_highlight.rs b/crates/ui-egui/tests/it/ui_effect_drop_highlight.rs index 6c0ede47a..ddef6c94b 100644 --- a/crates/ui-egui/tests/it/ui_effect_drop_highlight.rs +++ b/crates/ui-egui/tests/it/ui_effect_drop_highlight.rs @@ -13,7 +13,7 @@ fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { fn drag_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.input_mut().events.push(Event::PointerMoved(p)); h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - egui::DragAndDrop::set_payload(&h.ctx, DragPayload::Effect("CC Bend It".into())); + egui::DragAndDrop::set_payload(&h.ctx, DragPayload::Effect("Two-Point Bend".into())); h.run_steps(2); } diff --git a/crates/ui-egui/tests/it/ui_fx_editors.rs b/crates/ui-egui/tests/it/ui_fx_editors.rs index b9530fdf4..b4d999311 100644 --- a/crates/ui-egui/tests/it/ui_fx_editors.rs +++ b/crates/ui-egui/tests/it/ui_fx_editors.rs @@ -130,7 +130,7 @@ fn undo(h: &mut Harness<'_, EffectcraftApp>) { #[test] fn lumetri_rgb_and_hue_saturation_curve_editors() { - let (s0, l, fx) = with_effect("Lumetri Color"); + let (s0, l, fx) = with_effect("Color Grading"); let mut h = harness(s0); only_groups(&mut h, l, fx, &["curves", "rgbCurves", "hueSaturationCurves"]); let pre = format!("effectControls.effect.{fx}.lumetri.rgbCurves"); diff --git a/crates/ui-egui/tests/it/ui_live_preview.rs b/crates/ui-egui/tests/it/ui_live_preview.rs index e72856bfc..abe3cc427 100644 --- a/crates/ui-egui/tests/it/ui_live_preview.rs +++ b/crates/ui-egui/tests/it/ui_live_preview.rs @@ -77,12 +77,12 @@ fn position_anchor_and_effect_edits_present_intermediate_pixels() { (None, "transform/position", json!([22, 18, 0]), json!([18, 18, 0])), (None, "transform/anchor", json!([9, 8, 0]), json!([6, 8, 0])), (Some("Gaussian Blur"), "blurriness", json!(3), json!(6)), - (Some("CC Radial Fast Blur"), "center", json!([4, 4]), json!([18, 12])), + (Some("Fast Radial Blur"), "center", json!([4, 4]), json!([18, 12])), ] { let mut session = Session::default(); session.execute("comp.new", json!({"width":64,"height":36,"duration":1})).unwrap(); let layer = session.execute("layer.newSolid", json!({"color":"#ffffff","width":24,"height":16})).unwrap()["layer"].as_u64().unwrap(); - if effect == Some("CC Radial Fast Blur") { + if effect == Some("Fast Radial Blur") { // A uniform solid is invariant under a radial blur; give the point edit visible detail. session.execute("effect.apply", json!({"layers":[layer],"effect":"Gradient Ramp"})).unwrap(); } diff --git a/crates/ui-egui/tests/it/ui_parameter_spacing.rs b/crates/ui-egui/tests/it/ui_parameter_spacing.rs index 71b70a14e..167029f7c 100644 --- a/crates/ui-egui/tests/it/ui_parameter_spacing.rs +++ b/crates/ui-egui/tests/it/ui_parameter_spacing.rs @@ -54,7 +54,7 @@ fn nested_groups_keep_indentation_value_alignment_and_effect_gaps_at_fixed_compa let mut s = Session::default(); s.execute("comp.new", json!({"width":64,"height":36,"duration":1})).unwrap(); s.execute("layer.newSolid", json!({"name":"Plate","color":"#406080"})).unwrap(); - let fx = s.execute("effect.apply", json!({"effect":"Lumetri Color"})).unwrap()["effects"][0].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"effect":"Color Grading"})).unwrap()["effects"][0].as_u64().unwrap(); let next = s.execute("effect.apply", json!({"effect":"Checkbox Control"})).unwrap()["effects"][0].as_u64().unwrap(); let g = s.active_comp().unwrap().layers[0].props.find_group(fx).unwrap(); let basic = g.sub("basicCorrection").unwrap(); diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index 7b32fa2ec..e01608e16 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -2370,7 +2370,7 @@ fn command_clicking_a_group_twirl_opens_its_whole_tree() { // twirls open with it, and closed again with it. let mut h = harness(); let id = layer_id(&h, "Box"); - h.state_mut().session.execute("effect.apply", json!({"effect": "Lumetri Color", "layers": [id]})).unwrap(); + h.state_mut().session.execute("effect.apply", json!({"effect": "Color Grading", "layers": [id]})).unwrap(); fn walk(g: &effectcraft_engine::project::PropGroup, out: &mut Vec) { for sg in g.groups() { out.push(sg.uid); diff --git a/docs/architecture.md b/docs/architecture.md index 83c03f14f..a04907aec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,7 @@ are not rewritten, so an expression that computes an effect point on a shape or other values may need `+ [thisComp.width / 2, thisComp.height / 2]`. Effects that use the layer bounds (edge pinning, wipes, Offset, generators) now cover the whole comp-sized bounds instead of their bottom-right quarter. Tracking data of effects that analyse footage (Warp Stabilizer, 3D -Camera Tracker, Mocha shape) is left as it is: footage has a source rectangle, whose effect space +Camera Tracker, Tracked Shape Matte) is left as it is: footage has a source rectangle, whose effect space did not change. ## 4. Rendering @@ -331,7 +331,7 @@ before conversion (a quarter of the conversions; the result differs from convert in blocks where colours clip) and resampled from there, as other frames are from full size. **GPU compositor** (`crates/gpu`; Project Settings ▸ Video Rendering and Effects ▸ Mercury GPU -Acceleration, the default, or Mercury Software Only; `render.backend`). The CPU renderer is the +Acceleration, the default, or Software Rendering; `render.backend`). The CPU renderer is the reference and keeps rendering layer content (sources, masks, CPU effects, layer styles) into the layer cache. The render crate defines an `Accelerator` trait; `effectcraft_gpu::Gpu` implements it on wgpu compute shaders. `RenderOpts::backend` picks `Cpu`, `Gpu` or `Auto` (an accelerator @@ -341,7 +341,7 @@ per comp, output scale and path (readback or viewer display) it averages CPU and (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 Mercury GPU Acceleration), so runs of a render don't differ by timing. +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 `draw_comp`: cached layer buffers are uploaded once per buffer, then transformed with the CPU's @@ -370,16 +370,16 @@ Preserve Transparency / blend kernels (a 3D track matte renders solo through the Environment Light Background layers draw their sky in a kernel (`adv_sky`, from `Renderer::sky_draw`) before the rest of the run, in Classic and Advanced 3D comps. Wireframe outlines draw on the GPU from the CPU's pixel list (`Renderer::wireframe_pixels`). GPU effects -(`effects::GPU_EFFECTS`, 280 of them: blurs, colour correction, keying incl. Key Light, mattes, -channel, stylize, distortion and warps (Warp, Bezier Warp, Smear, Reshape, CC Bend It, CC Page -Turn, CC Bender, CC Blobbylize), Cartoon, bevels, shapes, the transitions (CC light family, CC -transitions, Block Dissolve), perspective (Radial Shadow, CC Cylinder / Sphere / Spotlight / +(`effects::GPU_EFFECTS`, 280 of them: blurs, colour correction, keying incl. Screen Color Key, mattes, +channel, stylize, distortion and warps (Warp, Bezier Warp, Smear, Reshape, Two-Point Bend, CC Page +Turn, Flexible Bend, Organic Displacement), Cartoon, bevels, shapes, the transitions (CC light family, CC +transitions, Block Dissolve), perspective (Radial Shadow, Cylinder Wrap / Sphere / Spotlight / Environment, 3D Glasses), generators, noise, grain, text (Numbers, Timecode), time, 3D Channel (aux channels as an extra texture), Immersive Video, colour management (colour programs from `effects::color_program`, LUTs in a storage buffer) and the simulations' render passes (the simulation stays on the CPU and hands its plan — sprites, pieces, blobs, grids — to a tiled -rasteriser; the particle effects too, `gpu::fx_particles`: CC Particle World / Systems II, -Particle Playground with its Layer Map frames in one atlas, CC Ball Action, CC Pixel Polly, CC +rasteriser; the particle effects too, `gpu::fx_particles`: Particle Emitter 3D / Systems II, +Particle Playground with its Layer Map frames in one atlas, Ball Grid, Pixel Shatter, CC Scatterize; plans too large for one item table draw in several passes over the previous one), and the geometry generators (Lightning, Advanced Lightning, Radio Waves, Stroke, Scribble, Vegas, Write-on, Audio Spectrum / Waveform, Basic / Path Text: bolts, strokes, waves, audio @@ -390,7 +390,7 @@ as kernels, in one module per family (`gpu::fx_*` with `shaders/fx_*.wgsl`); con effects run as one chain with one upload and one readback. Statistics that need the whole frame (Auto Levels / Contrast / Color, Equalize, Shadow/Highlight, Color Stabilizer, Remove Grain's noise level) are measured on the CPU from one readback and applied on the GPU; effects reading -other frames (Echo, Posterize Time, Time Difference, Time Displacement, CC Force Motion Blur, CC +other frames (Echo, Posterize Time, Time Difference, Time Displacement, Temporal Motion Blur, CC Wide Time, Pixel Motion Blur, Timewarp) upload the frames the host renders (`time_frames`) and combine them on the GPU; Time Displacement's per-pixel times and Timewarp's motion vectors (`time_fx::timewarp_plan`) are computed on the CPU. Numbers and Timecode rasterise their glyph @@ -433,12 +433,12 @@ sentinel is implemented here. Browser scope futures are not synchronously awaite forwards shared-device errors, while owned worker devices install their own handlers. On the web (WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU (the Info panel's pixel readout reads GPU frames back asynchronously). **GPU particles** -(`effects::psim`, `gpu::particles`): CC Particle World, CC Particle Systems II and Particle +(`effects::psim`, `gpu::particles`): Particle Emitter 3D, Particle Emitter 2D and Particle Playground cannons without interacting forces evolve every particle independently, so the GPU backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions). -CC Particle World, CC Particle Systems II and CC Mr. Mercury sample their Birth Rate at every +Particle Emitter 3D, Particle Emitter 2D and Liquid Particles sample their Birth Rate at every step (`EffectHost::param_at`, #1373), so a keyed rate only decides the births of its own time; the rate is left out of the simulation key, and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU checkpoint's births). The rate's keyframes are read at the step's exact layer time @@ -629,7 +629,7 @@ Unix milliseconds. The layer's Start Time becomes `dataStart + videoStart − fi (`videoStart` given, or the file's creation date on the desktop; reduced to a time of day when the data has no dates). -**Lumetri Scopes** (`raster::scopes`) are density plots of the viewer frame (rendered over the +**Video Scopes** (`raster::scopes`) are density plots of the viewer frame (rendered over the comp background) with BT.601 / BT.709 / BT.2020 luma and colour-difference coefficients. ## 5. Expressions diff --git a/docs/effects.md b/docs/effects.md index d9f726481..723edd0d0 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -48,21 +48,21 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | Bilateral Blur | `ec.blur.bilateral` | 3 | GPU | 32 | Implemented | -| CC Cross Blur | `ec.blur.cccross` | 3 | GPU | 32 | Implemented | -| CC Radial Blur | `ec.blur.ccradial` | 4 | GPU | 32 | Implemented | -| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented | -| CC Vector Blur | `ec.blur.ccvector` | 4 | GPU | 32 | Implemented | | Camera Lens Blur | `ec.blur.cameralens` | 16 | GPU | 32 | Implemented | | Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented | | Channel Blur | `ec.blur.channel` | 6 | GPU | 32 | Implemented | | Compound Blur | `ec.blur.compound` | 4 | GPU | 32 | Implemented | +| Cross Blur | `ec.blur.cccross` | 3 | GPU | 32 | Implemented | | Directional Blur | `ec.blur.directional` | 2 | GPU | 32 | Implemented | | Fast Box Blur | `ec.blur.fastbox` | 4 | GPU | 32 | Implemented | +| Fast Radial Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented | | Gaussian Blur | `ec.blur.gaussian` | 3 | GPU | 32 | Implemented | | Radial Blur | `ec.blur.radial` | 4 | GPU | 32 | Implemented | | Sharpen | `ec.blur.sharpen` | 1 | GPU | 32 | Implemented | | Smart Blur | `ec.blur.smart` | 3 | GPU | 32 | Implemented | | Unsharp Mask | `ec.blur.unsharp` | 3 | GPU | 32 | Implemented | +| Variable Radial Blur | `ec.blur.ccradial` | 4 | GPU | 32 | Implemented | +| Vector Blur | `ec.blur.ccvector` | 4 | GPU | 32 | Implemented | ## Channel @@ -70,7 +70,6 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | Arithmetic | `ec.channel.arithmetic` | 5 | GPU | 32 | Implemented | | Blend | `ec.channel.blend` | 4 | GPU | 32 | Implemented | -| CC Composite | `ec.channel.cccomposite` | 3 | GPU | 32 | Implemented | | Calculations | `ec.channel.calculations` | 9 | GPU | 32 | Implemented | | Channel Combiner | `ec.channel.combiner` | 6 | GPU | 32 | Implemented | | Compound Arithmetic | `ec.channel.compoundarithmetic` | 6 | GPU | 32 | Implemented | @@ -81,6 +80,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Set Matte | `ec.channel.setmatte` | 6 | GPU | 32 | Implemented | | Shift Channels | `ec.channel.shiftchannels` | 4 | GPU | 32 | Implemented | | Solid Composite | `ec.channel.solidcomposite` | 4 | GPU | 32 | Implemented | +| Source Composite | `ec.channel.cccomposite` | 3 | GPU | 32 | Implemented | ## Color Correction @@ -92,18 +92,18 @@ Effects are addressed by id (`ec..`) or display name in commands | Black & White | `ec.color.blackwhite` | 8 | GPU | 32 | Implemented | | Brightness & Contrast | `ec.color.brightnesscontrast` | 3 | GPU | 32 | Implemented | | Broadcast Colors | `ec.color.broadcast` | 3 | GPU | 32 | Implemented | -| CC Color Neutralizer | `ec.color.cccolorneutralizer` | 11 | GPU | 32 | Implemented | -| CC Color Offset | `ec.color.cccoloroffset` | 4 | GPU | 32 | Implemented | -| CC Kernel | `ec.color.cckernel` | 12 | GPU | 32 | Implemented | -| CC Toner | `ec.color.cctoner` | 7 | GPU | 32 | Implemented | | Change Color | `ec.color.changecolor` | 9 | GPU | 32 | Implemented | | Change to Color | `ec.color.changetocolor` | 9 | GPU | 32 | Implemented | | Channel Mixer | `ec.color.channelmixer` | 13 | GPU | 32 | Implemented | +| Channel Offset | `ec.color.cccoloroffset` | 4 | GPU | 32 | Implemented | | Color Balance | `ec.color.colorbalance` | 10 | GPU | 32 | Implemented | | Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | GPU | 32 | Implemented | +| Color Grading | `ec.color.lumetri` | 66 | GPU | 32 | Implemented | | Color Link | `ec.color.colorlink` | 6 | GPU | 32 | Implemented | +| Color Neutralizer | `ec.color.cccolorneutralizer` | 11 | GPU | 32 | Implemented | | Color Stabilizer | `ec.color.colorstabilizer` | 6 | GPU | 32 | Implemented | | Colorama | `ec.color.colorama` | 20 | GPU | 32 | Implemented | +| Convolution Kernel | `ec.color.cckernel` | 12 | GPU | 32 | Implemented | | Curves | `ec.color.curves` | 0 | GPU | 32 | Implemented | | Equalize | `ec.color.equalize` | 2 | GPU | 32 | Implemented | | Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented | @@ -112,7 +112,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Leave Color | `ec.color.leavecolor` | 5 | GPU | 32 | Implemented | | Levels | `ec.color.levels` | 36 | GPU | 32 | Implemented | | Levels (Individual Controls) | `ec.color.levelsic` | 27 | GPU | 32 | Implemented | -| Lumetri Color | `ec.color.lumetri` | 66 | GPU | 32 | Implemented | +| Multi-Tone Tint | `ec.color.cctoner` | 7 | GPU | 32 | Implemented | | OCIO CDL Transform | `ec.color.ociocdl` | 12 | GPU | 32 | Implemented | | OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | GPU | 32 | Implemented | | OCIO Display Transform | `ec.color.ociodisplay` | 9 | GPU | 32 | Implemented | @@ -133,40 +133,40 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | Bezier Warp | `ec.distort.bezierwarp` | 13 | GPU | 32 | Implemented | | Bulge | `ec.distort.bulge` | 6 | GPU | 32 | Implemented | -| CC Bend It | `ec.distort.ccbendit` | 4 | GPU | 32 | Implemented | -| CC Bender | `ec.distort.ccbender` | 5 | GPU | 32 | Implemented | -| CC Blobbylize | `ec.distort.ccblobbylize` | 15 | GPU | 32 | Implemented | -| CC Flo Motion | `ec.distort.ccflomotion` | 7 | GPU | 32 | Implemented | -| CC Griddler | `ec.distort.ccgriddler` | 5 | GPU | 32 | Implemented | -| CC Lens | `ec.distort.cclens` | 3 | GPU | 32 | Implemented | -| CC Page Turn | `ec.distort.ccpageturn` | 7 | GPU | 32 | Implemented | -| CC Power Pin | `ec.distort.ccpowerpin` | 10 | GPU | 32 | Implemented | -| CC Ripple Pulse | `ec.distort.ccripplepulse` | 5 | GPU | 32 | Implemented | -| CC Slant | `ec.distort.ccslant` | 4 | GPU | 32 | Implemented | -| CC Smear | `ec.distort.ccsmear` | 4 | GPU | 32 | Implemented | -| CC Split | `ec.distort.ccsplit` | 3 | GPU | 32 | Implemented | -| CC Split 2 | `ec.distort.ccsplit2` | 4 | GPU | 32 | Implemented | -| CC Tiler | `ec.distort.cctiler` | 3 | GPU | 32 | Implemented | | Corner Pin | `ec.distort.cornerpin` | 4 | GPU | 32 | Implemented | | Detail-preserving Upscale | `ec.distort.upscale` | 4 | | 32 | Implemented | +| Directional Smear | `ec.distort.ccsmear` | 4 | GPU | 32 | Implemented | | Displacement Map | `ec.distort.displacementmap` | 8 | GPU | 32 | Implemented | +| Dual Lens Warp | `ec.distort.ccflomotion` | 7 | GPU | 32 | Implemented | +| Dual Split Warp | `ec.distort.ccsplit2` | 4 | GPU | 32 | Implemented | +| Flexible Bend | `ec.distort.ccbender` | 5 | GPU | 32 | Implemented | +| Lens Distortion | `ec.distort.cclens` | 3 | GPU | 32 | Implemented | | Liquify | `ec.distort.liquify` | 13 | GPU | 32 | Implemented | | Magnify | `ec.distort.magnify` | 10 | GPU | 32 | Implemented | | Mesh Warp | `ec.distort.meshwarp` | 3 | GPU | 32 | Implemented | | Mirror | `ec.distort.mirror` | 2 | GPU | 32 | Implemented | | Offset | `ec.distort.offset` | 2 | GPU | 32 | Implemented | | Optics Compensation | `ec.distort.opticscompensation` | 6 | GPU | 32 | Implemented | +| Organic Displacement | `ec.distort.ccblobbylize` | 15 | GPU | 32 | Implemented | +| Page Curl | `ec.distort.ccpageturn` | 7 | GPU | 32 | Implemented | +| Perspective Pin | `ec.distort.ccpowerpin` | 10 | GPU | 32 | Implemented | | Polar Coordinates | `ec.distort.polar` | 2 | GPU | 32 | Implemented | +| Pulsed Ripple | `ec.distort.ccripplepulse` | 5 | GPU | 32 | Implemented | | Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented | | Reshape | `ec.distort.reshape` | 6 | GPU | 32 | Implemented | | Ripple | `ec.distort.ripple` | 7 | GPU | 32 | Implemented | | Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Implemented | +| Scaled Tiles | `ec.distort.cctiler` | 3 | GPU | 32 | Implemented | +| Slant | `ec.distort.ccslant` | 4 | GPU | 32 | Implemented | | Smear | `ec.distort.smear` | 8 | GPU | 32 | Implemented | | Spherize | `ec.distort.spherize` | 2 | GPU | 32 | Implemented | +| Split Warp | `ec.distort.ccsplit` | 3 | GPU | 32 | Implemented | +| Tile Grid | `ec.distort.ccgriddler` | 5 | GPU | 32 | Implemented | | Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented | | Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Implemented | | Twirl | `ec.distort.twirl` | 3 | GPU | 32 | Implemented | | Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | GPU | 32 | Implemented | +| Two-Point Bend | `ec.distort.ccbendit` | 4 | GPU | 32 | Implemented | | Warp | `ec.distort.warp` | 5 | GPU | 32 | Implemented | | Warp Stabilizer | `ec.distort.warpstabilizer` | 20 | | 32 | Implemented | | Wave Warp | `ec.distort.wavewarp` | 7 | GPU | 32 | Implemented | @@ -194,11 +194,6 @@ Effects are addressed by id (`ec..`) or display name in commands | Audio Spectrum | `ec.generate.audiospectrum` | 23 | GPU | 32 | Implemented | | Audio Waveform | `ec.generate.audiowaveform` | 17 | GPU | 32 | Implemented | | Beam | `ec.generate.beam` | 11 | GPU | 32 | Implemented | -| CC Glue Gun | `ec.generate.ccgluegun` | 7 | GPU | 32 | Implemented | -| CC Light Burst 2.5 | `ec.generate.cclightburst` | 6 | GPU | 32 | Implemented | -| CC Light Rays | `ec.generate.cclightrays` | 8 | GPU | 32 | Implemented | -| CC Light Sweep | `ec.generate.cclightsweep` | 9 | GPU | 32 | Implemented | -| CC Threads | `ec.generate.ccthreads` | 8 | GPU | 32 | Implemented | | Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Implemented | | Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented | | Circle | `ec.generate.circle` | 11 | GPU | 32 | Implemented | @@ -209,11 +204,16 @@ Effects are addressed by id (`ec..`) or display name in commands | Gradient Ramp | `ec.generate.gradientramp` | 7 | GPU | 32 | Implemented | | Grid | `ec.generate.grid` | 12 | GPU | 32 | Implemented | | Lens Flare | `ec.generate.lensflare` | 4 | GPU | 32 | Implemented | +| Light Rays | `ec.generate.cclightrays` | 8 | GPU | 32 | Implemented | +| Light Sweep | `ec.generate.cclightsweep` | 9 | GPU | 32 | Implemented | +| Paint Beads | `ec.generate.ccgluegun` | 7 | GPU | 32 | Implemented | | Paint Bucket | `ec.generate.paintbucket` | 11 | GPU | 32 | Implemented | +| Radial Light Burst | `ec.generate.cclightburst` | 6 | GPU | 32 | Implemented | | Radio Waves | `ec.generate.radiowaves` | 31 | GPU | 32 | Implemented | | Scribble | `ec.generate.scribble` | 25 | GPU | 32 | Implemented | | Stroke | `ec.generate.stroke` | 11 | GPU | 32 | Implemented | | Vegas | `ec.generate.vegas` | 26 | GPU | 32 | Implemented | +| Woven Threads | `ec.generate.ccthreads` | 8 | GPU | 32 | Implemented | | Write-on | `ec.generate.writeon` | 10 | GPU | 32 | Implemented | ## Immersive Video @@ -238,15 +238,15 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | Advanced Spill Suppressor | `ec.key.advancedspill` | 8 | GPU | 32 | Implemented | -| CC Simple Wire Removal | `ec.key.ccsimplewireremoval` | 7 | GPU | 32 | Implemented | | Color Difference Key | `ec.key.colordifference` | 16 | GPU | 32 | Implemented | | Color Range | `ec.key.colorrange` | 8 | GPU | 32 | Implemented | | Difference Matte | `ec.key.differencematte` | 6 | GPU | 32 | Implemented | | Extract | `ec.key.extract` | 6 | GPU | 32 | Implemented | | Inner/Outer Key | `ec.key.innerouter` | 88 | GPU | 32 | Implemented | | Key Cleaner | `ec.key.keycleaner` | 4 | GPU | 32 | Implemented | -| Key Light | `ec.keying.keylight` | 56 | GPU | 32 | Implemented | +| Line Removal | `ec.key.ccsimplewireremoval` | 7 | GPU | 32 | Implemented | | Linear Color Key | `ec.key.linearcolor` | 6 | GPU | 32 | Implemented | +| Screen Color Key | `ec.keying.keylight` | 56 | GPU | 32 | Implemented | | Screen Key | `ec.key.screen` | 11 | GPU | 32 | Implemented | | Unmult | `ec.key.unmult` | 6 | GPU | 32 | Implemented | @@ -255,12 +255,12 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | Matte Choker | `ec.matte.mattechoker` | 7 | GPU | 32 | Implemented | -| Mocha shape | `ec.obsolete.mochashape` | 6 | | 32 | Implemented | | Object Matte | `ec.matte.objectmatte` | 13 | | 32 | Implemented | | Refine Hard Matte | `ec.matte.refinehard` | 16 | GPU | 32 | Implemented | | Refine Soft Matte | `ec.matte.refinesoft` | 18 | GPU | 32 | Implemented | | Roto Brush & Refine Edge | `ec.matte.rotobrush` | 25 | | 32 | Implemented | | Simple Choker | `ec.matte.simplechoker` | 2 | GPU | 32 | Implemented | +| Tracked Shape Matte | `ec.obsolete.mochashape` | 6 | | 32 | Implemented | ## Noise & Grain @@ -308,65 +308,65 @@ Effects are addressed by id (`ec..`) or display name in commands | 3D Glasses | `ec.perspective.3dglasses` | 8 | GPU | 32 | Implemented | | Bevel Alpha | `ec.perspective.bevelalpha` | 4 | GPU | 32 | Implemented | | Bevel Edges | `ec.perspective.beveledges` | 4 | GPU | 32 | Implemented | -| CC Cylinder | `ec.perspective.cccylinder` | 15 | GPU | 32 | Implemented | -| CC Environment | `ec.perspective.ccenvironment` | 4 | GPU | 32 | Implemented | -| CC Sphere | `ec.perspective.ccsphere` | 14 | GPU | 32 | Implemented | -| CC Spotlight | `ec.perspective.ccspotlight` | 8 | GPU | 32 | Implemented | +| Cylinder Wrap | `ec.perspective.cccylinder` | 15 | GPU | 32 | Implemented | | Drop Shadow | `ec.perspective.dropshadow` | 6 | GPU | 32 | Implemented | +| Environment Map | `ec.perspective.ccenvironment` | 4 | GPU | 32 | Implemented | | Radial Shadow | `ec.perspective.radialshadow` | 9 | GPU | 32 | Implemented | +| Sphere Wrap | `ec.perspective.ccsphere` | 14 | GPU | 32 | Implemented | +| Surface Spotlight | `ec.perspective.ccspotlight` | 8 | GPU | 32 | Implemented | ## Simulation | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| CC Ball Action | `ec.sim.ccballaction` | 8 | GPU | 32 | Implemented | -| CC Bubbles | `ec.sim.ccbubbles` | 7 | GPU | 32 | Implemented | -| CC Drizzle | `ec.sim.ccdrizzle` | 14 | GPU | 32 | Implemented | -| CC Hair | `ec.sim.cchair` | 20 | GPU | 32 | Implemented | -| CC Mr. Mercury | `ec.sim.ccmrmercury` | 23 | GPU | 32 | Implemented | -| CC Particle Systems II | `ec.sim.ccparticlesystems2` | 21 | GPU | 32 | Implemented | -| CC Particle World | `ec.sim.ccparticleworld` | 36 | GPU | 32 | Implemented | -| CC Pixel Polly | `ec.sim.ccpixelpolly` | 10 | GPU | 32 | Implemented | -| CC Rainfall | `ec.sim.ccrainfall` | 14 | GPU | 32 | Implemented | -| CC Scatterize | `ec.sim.ccscatterize` | 4 | GPU | 32 | Implemented | -| CC Snowfall | `ec.sim.ccsnowfall` | 19 | GPU | 32 | Implemented | -| CC Star Burst | `ec.sim.ccstarburst` | 6 | GPU | 32 | Implemented | +| Ball Grid | `ec.sim.ccballaction` | 8 | GPU | 32 | Implemented | +| Bubbles | `ec.sim.ccbubbles` | 7 | GPU | 32 | Implemented | | Card Dance | `ec.sim.carddance` | 53 | GPU | 32 | Implemented | | Caustics | `ec.sim.caustics` | 28 | GPU | 32 | Implemented | | Foam | `ec.sim.foam` | 36 | GPU | 32 | Implemented | +| Liquid Particles | `ec.sim.ccmrmercury` | 23 | GPU | 32 | Implemented | +| Particle Emitter 2D | `ec.sim.ccparticlesystems2` | 21 | GPU | 32 | Implemented | +| Particle Emitter 3D | `ec.sim.ccparticleworld` | 36 | GPU | 32 | Implemented | | Particle Playground | `ec.sim.particleplayground` | 92 | GPU | 32 | Implemented | +| Pixel Scatter | `ec.sim.ccscatterize` | 4 | GPU | 32 | Implemented | +| Pixel Shatter | `ec.sim.ccpixelpolly` | 10 | GPU | 32 | Implemented | +| Rainfall | `ec.sim.ccrainfall` | 14 | GPU | 32 | Implemented | | Shatter | `ec.sim.shatter` | 60 | GPU | 32 | Implemented | +| Snowfall | `ec.sim.ccsnowfall` | 19 | GPU | 32 | Implemented | +| Starfield | `ec.sim.ccstarburst` | 6 | GPU | 32 | Implemented | +| Surface Fibers | `ec.sim.cchair` | 20 | GPU | 32 | Implemented | +| Water Ripples | `ec.sim.ccdrizzle` | 14 | GPU | 32 | Implemented | | Wave World | `ec.sim.waveworld` | 35 | GPU | 32 | Implemented | ## Stylize | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| +| Block Reveal | `ec.stylize.ccblockload` | 5 | GPU | 32 | Implemented | | Brush Strokes | `ec.stylize.brushstrokes` | 7 | GPU | 32 | Implemented | -| CC Block Load | `ec.stylize.ccblockload` | 5 | GPU | 32 | Implemented | -| CC Burn Film | `ec.stylize.ccburnfilm` | 3 | GPU | 32 | Implemented | -| CC Glass | `ec.stylize.ccglass` | 17 | GPU | 32 | Implemented | -| CC HexTile | `ec.stylize.cchextile` | 5 | GPU | 32 | Implemented | -| CC Kaleida | `ec.stylize.cckaleida` | 4 | GPU | 32 | Implemented | -| CC Mr. Smoothie | `ec.stylize.ccmrsmoothie` | 7 | GPU | 32 | Implemented | -| CC Plastic | `ec.stylize.ccplastic` | 18 | GPU | 32 | Implemented | -| CC RepeTile | `ec.stylize.ccrepetile` | 5 | GPU | 32 | Implemented | -| CC Threshold | `ec.stylize.ccthreshold` | 4 | GPU | 32 | Implemented | -| CC Threshold RGB | `ec.stylize.ccthresholdrgb` | 7 | GPU | 32 | Implemented | -| CC Vignette | `ec.stylize.ccvignette` | 4 | GPU | 32 | Implemented | | Cartoon | `ec.stylize.cartoon` | 12 | GPU | 32 | Implemented | +| Channel Threshold | `ec.stylize.ccthreshold` | 4 | GPU | 32 | Implemented | | Color Emboss | `ec.stylize.coloremboss` | 4 | GPU | 32 | Implemented | +| Edge Tile Repeat | `ec.stylize.ccrepetile` | 5 | GPU | 32 | Implemented | | Emboss | `ec.stylize.emboss` | 4 | GPU | 32 | Implemented | +| Film Burn | `ec.stylize.ccburnfilm` | 3 | GPU | 32 | Implemented | | Find Edges | `ec.stylize.findedges` | 2 | GPU | 32 | Implemented | +| Glass Relief | `ec.stylize.ccglass` | 17 | GPU | 32 | Implemented | | Glow | `ec.stylize.glow` | 14 | GPU | 32 | Implemented | +| Hexagonal Tiles | `ec.stylize.cchextile` | 5 | GPU | 32 | Implemented | +| Kaleidoscope | `ec.stylize.cckaleida` | 4 | GPU | 32 | Implemented | | Mosaic | `ec.stylize.mosaic` | 3 | GPU | 32 | Implemented | | Motion Tile | `ec.stylize.motiontile` | 8 | GPU | 32 | Implemented | +| Palette Map | `ec.stylize.ccmrsmoothie` | 7 | GPU | 32 | Implemented | +| Plastic Shading | `ec.stylize.ccplastic` | 18 | GPU | 32 | Implemented | | Posterize | `ec.stylize.posterize` | 1 | GPU | 32 | Implemented | +| RGB Threshold | `ec.stylize.ccthresholdrgb` | 7 | GPU | 32 | Implemented | | Roughen Edges | `ec.stylize.roughenedges` | 13 | GPU | 32 | Implemented | | Scatter | `ec.stylize.scatter` | 3 | GPU | 32 | Implemented | | Strobe Light | `ec.stylize.strobe` | 7 | GPU | 32 | Implemented | | Texturize | `ec.stylize.texturize` | 4 | GPU | 32 | Implemented | | Threshold | `ec.stylize.threshold` | 1 | GPU | 32 | Implemented | +| Vignette | `ec.stylize.ccvignette` | 4 | GPU | 32 | Implemented | ## Text @@ -379,11 +379,11 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| CC Force Motion Blur | `ec.time.ccforcemotionblur` | 4 | GPU | 32 | Implemented | -| CC Wide Time | `ec.time.ccwidetime` | 3 | GPU | 32 | Implemented | | Echo | `ec.time.echo` | 5 | GPU | 32 | Implemented | | Pixel Motion Blur | `ec.time.pixelmotionblur` | 4 | GPU | 32 | Implemented | | Posterize Time | `ec.time.posterizetime` | 1 | GPU | 32 | Implemented | +| Temporal Blend | `ec.time.ccwidetime` | 3 | GPU | 32 | Implemented | +| Temporal Motion Blur | `ec.time.ccforcemotionblur` | 4 | GPU | 32 | Implemented | | Time Difference | `ec.time.timedifference` | 5 | GPU | 32 | Implemented | | Time Displacement | `ec.time.timedisplacement` | 4 | GPU | 32 | Implemented | | Timewarp | `ec.time.timewarp` | 28 | GPU | 32 | Implemented | @@ -393,29 +393,28 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | Block Dissolve | `ec.transition.blockdissolve` | 5 | GPU | 32 | Implemented | -| CC Glass Wipe | `ec.transition.ccglasswipe` | 5 | GPU | 32 | Implemented | -| CC Grid Wipe | `ec.transition.ccgridwipe` | 7 | GPU | 32 | Implemented | -| CC Image Wipe | `ec.transition.ccimagewipe` | 7 | GPU | 32 | Implemented | -| CC Jaws | `ec.transition.ccjaws` | 6 | GPU | 32 | Implemented | -| CC Light Wipe | `ec.transition.cclightwipe` | 8 | GPU | 32 | Implemented | -| CC Line Sweep | `ec.transition.cclinesweep` | 5 | GPU | 32 | Implemented | -| CC Radial ScaleWipe | `ec.transition.ccradialscalewipe` | 3 | GPU | 32 | Implemented | -| CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | GPU | 32 | Implemented | -| CC Twister | `ec.transition.cctwister` | 5 | GPU | 32 | Implemented | -| CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | GPU | 32 | Implemented | | Card Wipe | `ec.transition.cardwipe` | 44 | GPU | 32 | Implemented | +| Glass Wipe | `ec.transition.ccglasswipe` | 5 | GPU | 32 | Implemented | | Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented | +| Grid Wipe | `ec.transition.ccgridwipe` | 7 | GPU | 32 | Implemented | | Iris Wipe | `ec.transition.iriswipe` | 7 | GPU | 32 | Implemented | +| Light Wipe | `ec.transition.cclightwipe` | 8 | GPU | 32 | Implemented | +| Line Sweep | `ec.transition.cclinesweep` | 5 | GPU | 32 | Implemented | | Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented | +| Luminance Image Wipe | `ec.transition.ccimagewipe` | 7 | GPU | 32 | Implemented | +| Radial Scale Wipe | `ec.transition.ccradialscalewipe` | 3 | GPU | 32 | Implemented | | Radial Wipe | `ec.transition.radialwipe` | 5 | GPU | 32 | Implemented | +| Scale Wipe | `ec.transition.ccscalewipe` | 3 | GPU | 32 | Implemented | +| Serrated Wipe | `ec.transition.ccjaws` | 6 | GPU | 32 | Implemented | +| Twist Wipe | `ec.transition.cctwister` | 5 | GPU | 32 | Implemented | | Venetian Blinds | `ec.transition.venetian` | 4 | GPU | 32 | Implemented | +| Warp Dissolve | `ec.transition.ccwarpomatic` | 8 | GPU | 32 | Implemented | ## Utility | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | Apply Color LUT | `ec.utility.applylut` | 1 | GPU | 32 | Implemented | -| CC Overbrights | `ec.utility.ccoverbrights` | 2 | GPU | 32 | Implemented | | Cineon Converter | `ec.utility.cineon` | 7 | GPU | 32 | Implemented | | Color Profile Converter | `ec.utility.colorprofileconverter` | 7 | GPU | 32 | Implemented | | Face Measurements | `ec.utility.facemeasurements` | 14 | | 32 | Implemented | @@ -423,4 +422,5 @@ Effects are addressed by id (`ec..`) or display name in commands | Grow Bounds | `ec.utility.growbounds` | 1 | GPU | 32 | Implemented | | HDR Compander | `ec.utility.hdrcompander` | 3 | GPU | 32 | Implemented | | HDR Highlight Compression | `ec.utility.hdrcompression` | 1 | GPU | 32 | Implemented | +| Out-of-Range Display | `ec.utility.ccoverbrights` | 2 | GPU | 32 | Implemented | diff --git a/docs/feature-naming.md b/docs/feature-naming.md new file mode 100644 index 000000000..a69bc713f --- /dev/null +++ b/docs/feature-naming.md @@ -0,0 +1,108 @@ +# Descriptive feature names + +Related to #1365. This is a naming cleanup, not a trademark clearance or a product rename. + +## Naming policy + +Use short, descriptive labels for EffectCraft implementations. Do not reuse a vendor prefix or +coined feature name as our branding. Qualify similar effects by what distinguishes them; each +English display name remains unique across the registry. + +- Renderer: **GPU Acceleration** / **Software Rendering**. +- Panel: **Video Scopes**. +- The table below covers the 69 former CC effects plus color grading, the shape matte and keying. + +## Compatibility contract + +Only presentation names change. Effect IDs, command IDs, parameter IDs, enum values, panel IDs, +serialized types and AEP import/export match names stay unchanged. Old effect display names +remain case-insensitive lookup and search aliases. Existing project instance names, custom names, +expressions, animation and values are not rewritten. The UI displays a current label for an exact +legacy default name, optionally followed by its numeric instance suffix; all other names stay +verbatim. A user-entered name identical to a legacy default cannot be distinguished from it, but +its saved value is still retained. + +External format specifications, comparison documents, attribution records and compatibility +fixtures may still name the external product or effect they actually describe. + +## Display mapping + +| Stable ID | Previous display name | Current display name | +|---|---|---| +| `ec.sim.ccballaction` | CC Ball Action | Ball Grid | +| `ec.distort.ccbendit` | CC Bend It | Two-Point Bend | +| `ec.distort.ccbender` | CC Bender | Flexible Bend | +| `ec.distort.ccblobbylize` | CC Blobbylize | Organic Displacement | +| `ec.stylize.ccblockload` | CC Block Load | Block Reveal | +| `ec.sim.ccbubbles` | CC Bubbles | Bubbles | +| `ec.stylize.ccburnfilm` | CC Burn Film | Film Burn | +| `ec.color.cccolorneutralizer` | CC Color Neutralizer | Color Neutralizer | +| `ec.color.cccoloroffset` | CC Color Offset | Channel Offset | +| `ec.channel.cccomposite` | CC Composite | Source Composite | +| `ec.blur.cccross` | CC Cross Blur | Cross Blur | +| `ec.perspective.cccylinder` | CC Cylinder | Cylinder Wrap | +| `ec.sim.ccdrizzle` | CC Drizzle | Water Ripples | +| `ec.perspective.ccenvironment` | CC Environment | Environment Map | +| `ec.distort.ccflomotion` | CC Flo Motion | Dual Lens Warp | +| `ec.time.ccforcemotionblur` | CC Force Motion Blur | Temporal Motion Blur | +| `ec.stylize.ccglass` | CC Glass | Glass Relief | +| `ec.transition.ccglasswipe` | CC Glass Wipe | Glass Wipe | +| `ec.generate.ccgluegun` | CC Glue Gun | Paint Beads | +| `ec.transition.ccgridwipe` | CC Grid Wipe | Grid Wipe | +| `ec.distort.ccgriddler` | CC Griddler | Tile Grid | +| `ec.sim.cchair` | CC Hair | Surface Fibers | +| `ec.stylize.cchextile` | CC HexTile | Hexagonal Tiles | +| `ec.transition.ccimagewipe` | CC Image Wipe | Luminance Image Wipe | +| `ec.transition.ccjaws` | CC Jaws | Serrated Wipe | +| `ec.stylize.cckaleida` | CC Kaleida | Kaleidoscope | +| `ec.color.cckernel` | CC Kernel | Convolution Kernel | +| `ec.distort.cclens` | CC Lens | Lens Distortion | +| `ec.generate.cclightburst` | CC Light Burst 2.5 | Radial Light Burst | +| `ec.generate.cclightrays` | CC Light Rays | Light Rays | +| `ec.generate.cclightsweep` | CC Light Sweep | Light Sweep | +| `ec.transition.cclightwipe` | CC Light Wipe | Light Wipe | +| `ec.transition.cclinesweep` | CC Line Sweep | Line Sweep | +| `ec.sim.ccmrmercury` | CC Mr. Mercury | Liquid Particles | +| `ec.stylize.ccmrsmoothie` | CC Mr. Smoothie | Palette Map | +| `ec.utility.ccoverbrights` | CC Overbrights | Out-of-Range Display | +| `ec.distort.ccpageturn` | CC Page Turn | Page Curl | +| `ec.sim.ccparticlesystems2` | CC Particle Systems II | Particle Emitter 2D | +| `ec.sim.ccparticleworld` | CC Particle World | Particle Emitter 3D | +| `ec.sim.ccpixelpolly` | CC Pixel Polly | Pixel Shatter | +| `ec.stylize.ccplastic` | CC Plastic | Plastic Shading | +| `ec.distort.ccpowerpin` | CC Power Pin | Perspective Pin | +| `ec.blur.ccradial` | CC Radial Blur | Variable Radial Blur | +| `ec.blur.ccradialfast` | CC Radial Fast Blur | Fast Radial Blur | +| `ec.transition.ccradialscalewipe` | CC Radial ScaleWipe | Radial Scale Wipe | +| `ec.sim.ccrainfall` | CC Rainfall | Rainfall | +| `ec.stylize.ccrepetile` | CC RepeTile | Edge Tile Repeat | +| `ec.distort.ccripplepulse` | CC Ripple Pulse | Pulsed Ripple | +| `ec.transition.ccscalewipe` | CC Scale Wipe | Scale Wipe | +| `ec.sim.ccscatterize` | CC Scatterize | Pixel Scatter | +| `ec.key.ccsimplewireremoval` | CC Simple Wire Removal | Line Removal | +| `ec.distort.ccslant` | CC Slant | Slant | +| `ec.distort.ccsmear` | CC Smear | Directional Smear | +| `ec.sim.ccsnowfall` | CC Snowfall | Snowfall | +| `ec.perspective.ccsphere` | CC Sphere | Sphere Wrap | +| `ec.distort.ccsplit` | CC Split | Split Warp | +| `ec.distort.ccsplit2` | CC Split 2 | Dual Split Warp | +| `ec.perspective.ccspotlight` | CC Spotlight | Surface Spotlight | +| `ec.sim.ccstarburst` | CC Star Burst | Starfield | +| `ec.generate.ccthreads` | CC Threads | Woven Threads | +| `ec.stylize.ccthreshold` | CC Threshold | Channel Threshold | +| `ec.stylize.ccthresholdrgb` | CC Threshold RGB | RGB Threshold | +| `ec.distort.cctiler` | CC Tiler | Scaled Tiles | +| `ec.color.cctoner` | CC Toner | Multi-Tone Tint | +| `ec.transition.cctwister` | CC Twister | Twist Wipe | +| `ec.blur.ccvector` | CC Vector Blur | Vector Blur | +| `ec.stylize.ccvignette` | CC Vignette | Vignette | +| `ec.transition.ccwarpomatic` | CC WarpoMatic | Warp Dissolve | +| `ec.time.ccwidetime` | CC Wide Time | Temporal Blend | +| `ec.color.lumetri` | Lumetri Color | Color Grading | +| `ec.obsolete.mochashape` | Mocha shape | Tracked Shape Matte | +| `ec.keying.keylight` | Key Light | Screen Color Key | + +`Roto Brush & Refine Edge` and `Warp Stabilizer` keep their current names; renaming them is +deferred. + +No effect algorithms or formats are changed. diff --git a/docs/web.md b/docs/web.md index 666ffe37b..f94a687be 100644 --- a/docs/web.md +++ b/docs/web.md @@ -174,7 +174,7 @@ 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 Mercury GPU Acceleration), not on whichever + project's renderer (the worker's GPU with GPU Acceleration), not on whichever compositor measured faster, 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 From 7c62272305312b88c25c59a2f24f08d676ee97b5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 9 Nov 2026 04:52:32 +0100 Subject: [PATCH 38/94] .aep import: placed .ai/PDF footage framed by its ArtBox, Null anchors at the corner (#3099) (#3311) * #3099: placed .ai pages fill the size After Effects stored; null anchors default to the corner Footage of an .aep keeps the size After Effects stored for a placed PDF / Illustrator / EPS file (the ArtBox, rounded up), and masks and anchors are measured against it. The page was drawn as a PDF viewer frames it (CropBox, /Rotate) at its own size, scaled by the width ratio at full resolution and stretched to the stored aspect below 3/4: masks too large, art off centre, squeezed at half resolution. - effectcraft-pdf: Framing::Placed / parse_page_framed (ArtBox clipped to the CropBox, /Rotate ignored); boxes with a non-finite corner or span are not used - Footage::placed_page (set by the .aep importer, serialized only when true): the page is framed so and filled into the stored size (effectcraft_render::vector_fit), decoded, continuously rasterised and in Create Shapes from Vector Layer; Reload keeps the size; the frame cache keys it by size and framing - .aep import/export: a null's absent Anchor Point is [0, 0], as After Effects reads it - fixture ai-mask-3099.aep (own synthetic files, scrubbed, profiles stubbed) and tests against After Effects 2026's read-back * #3099: placed-page framing only for .ai and .pdf, EPS as before; doc comments After Effects' sizing was measured with PDFs named .ai only; an EPS from an .aep keeps its bounding box at its own size (listed as not measured in docs/gaps.md and docs/aep-import.md). Doc comments on the touched functions. * #3099: shapes from a placed page keep its frame (track matte) Create Shapes from Vector Layer converted every path of a placed page, so bleed, art past the artboard or the page's background showed where the footage's raster hides them. The layers it makes from a placed page now take a hidden " Frame" shape layer (the footage's width x height) as their Alpha track matte: drawn at the comp's resolution, its edge is the raster's on shape and image layers alike. The command returns the frame layer's id too. --- ATTRIBUTION.md | 2 +- crates/aep/README.md | 8 +- crates/aep/src/export/plan.rs | 13 +- crates/aep/src/import/build.rs | 29 +- crates/aep/src/import/props.rs | 14 +- crates/aep/tests/fixtures/ai-mask-3099.aep | Bin 0 -> 134446 bytes .../fixtures/ai-mask-3099.aep.attribution | 7 + crates/aep/tests/fixtures/tools/README.md | 1 + .../aep/tests/fixtures/tools/ai-mask-3099.jsx | 94 +++++ .../aep/tests/fixtures/tools/ai-mask-3099.py | 66 ++++ crates/aep/tests/it/export/built.rs | 39 ++ crates/aep/tests/it/import.rs | 1 + crates/aep/tests/it/import/ai_mask.rs | 113 ++++++ crates/engine/src/cmd/create.rs | 29 +- crates/engine/src/footage/footage_reload.rs | 8 +- crates/engine/src/interchange/vector.rs | 64 +++- crates/host/tests/it/vector_import.rs | 356 +++++++++++++++++- crates/media/src/layered.rs | 101 ++++- crates/media/src/pool.rs | 20 +- crates/pdf/README.md | 8 +- crates/pdf/src/lib.rs | 30 +- crates/pdf/src/page.rs | 41 +- crates/pdf/src/tests.rs | 80 ++++ crates/project/src/footage.rs | 8 + crates/render/src/footage.rs | 31 +- crates/render/src/lib.rs | 2 +- docs/aep-import.md | 10 +- docs/gaps.md | 6 + 28 files changed, 1132 insertions(+), 49 deletions(-) create mode 100644 crates/aep/tests/fixtures/ai-mask-3099.aep create mode 100644 crates/aep/tests/fixtures/ai-mask-3099.aep.attribution create mode 100644 crates/aep/tests/fixtures/tools/ai-mask-3099.jsx create mode 100644 crates/aep/tests/fixtures/tools/ai-mask-3099.py create mode 100644 crates/aep/tests/it/import/ai_mask.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 93b34b9bf..4bf3a044d 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -64,7 +64,7 @@ and licence texts are in [`NOTICE`](NOTICE). | `crates/model/tests/fixtures/cube.obj` | EffectCraft contributors | Original work: Wavefront OBJ test fixture (unit cube, two materials) | MIT OR Apache-2.0 | | `crates/model/tests/fixtures/cube.mtl` | EffectCraft contributors | Original work: MTL test fixture (materials of cube.obj) | MIT OR Apache-2.0 | -One small After Effects test project of `crates/aep` is in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them. +Two small After Effects test projects of `crates/aep` are in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar) and `crates/aep/tests/fixtures/ai-mask-3099.aep` (original work, made with After Effects 2026 from four hand-written one-page PDFs named `.ai`, footage paths and host name neutralised, the embedded colour profiles replaced by stubs made with `crates/aep/examples/stubicc.rs`; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them. ## Screenshots diff --git a/crates/aep/README.md b/crates/aep/README.md index 965bea317..5e8035d8f 100644 --- a/crates/aep/README.md +++ b/crates/aep/README.md @@ -69,13 +69,19 @@ tests reach them only through `tests/it/common/mod.rs`, which hands a test its c of its own cut from the combined file; a test whose fixture is missing (or with the variable not set) prints `SKIPPED: fixture missing` and passes. -`tests/fixtures/synthetic.aep` (115 KB) is the one project in the repository: made in After Effects 2026 +`tests/fixtures/synthetic.aep` (115 KB) is one of the two projects in the repository: made in After Effects 2026 with the working space set to None (one composition, an image sequence of three 8x8 PNGs, a solid, a folder, a Levels and a Glow effect with expressions). Every After Effects project embeds the working space's ICC profile (HP sRGB IEC61966-2.1 for the default) in a JSON settings chunk; the one `Utf8` chunk of this file that held a profile was replaced by `{}`, and nothing else was changed. The tests in `tests/it/import/synthetic.rs` damage copies of it and need no other file. +`tests/fixtures/ai-mask-3099.aep` (131 KB, #3099) is the second: four hand-written one-page PDFs +named `.ai` with distinct page boxes, placed in a composition, one of them with two masks under a +Null parented to a Null (generated by `tests/fixtures/tools/ai-mask-3099.py` and +`ai-mask-3099.jsx`, scrubbed with `scrub.py` and `stubicc`). `tests/it/import/ai_mask.rs` checks +the import against After Effects' read-back, and the host's `vector_import` tests render it. + ## Tools - `tests/fixtures/tools/`: the generator of the test projects (regenerates the corpus with After diff --git a/crates/aep/src/export/plan.rs b/crates/aep/src/export/plan.rs index f8aa2b3b8..a6789eda9 100644 --- a/crates/aep/src/export/plan.rs +++ b/crates/aep/src/export/plan.rs @@ -817,7 +817,7 @@ fn plan_transform(layer: &Layer, src: &LayerSrc, comp: &Comp, source_size: (u32, let cam_light = matches!(src, LayerSrc::Camera(_) | LayerSrc::Light(_)); let is_shape = matches!(src, LayerSrc::Shape(_)); let mut props = Vec::new(); - let at = PropAt { comp, comp_name, layer: &layer.name, source: source_size, text: is_text || cam_light || is_shape }; + let at = PropAt { comp, comp_name, layer: &layer.name, source: source_size, text: is_text || cam_light || is_shape, null: matches!(src, LayerSrc::Null) }; if let Some(tr) = layer.transform() { for kind in PropKind::ALL { // A camera's or light's point of interest is After Effects' Anchor Point. @@ -1172,16 +1172,21 @@ struct PropAt<'a> { source: (u32, u32), /// The anchor is stored in pixels (text, camera, light and shape layers). text: bool, + /// A null layer: an Anchor Point that is not written is its top-left corner (#3099). + null: bool, } +/// What is written for transform property `p` (`kind`) of a layer: its value or keys in the +/// stored unit and its expression; `None` when it is at After Effects' default and has neither. fn plan_prop(kind: PropKind, label: Option<&str>, p: &Property, at: PropAt<'_>, cp: &CompPlan, col: &mut Collector) -> Option { - let PropAt { comp, comp_name, layer, source, text } = at; + let PropAt { comp, comp_name, layer, source, text, null } = at; // A text layer's anchor is stored in pixels, with the origin as its default (385). let (sw, sh) = if text { (1.0, 1.0) } else { (f64::from(source.0.max(1)), f64::from(source.1.max(1))) }; let what = format!("{comp_name}: '{layer}' {}", label.unwrap_or(kind.match_name())); let dims = kind.dims(); let default: Vec = match kind { - PropKind::Anchor if text => vec![0.0, 0.0, 0.0], + // After Effects 2026 reads a null's absent Anchor Point as `[0, 0]` (#3099). + PropKind::Anchor if text || null => vec![0.0, 0.0, 0.0], PropKind::Anchor => vec![0.5, 0.5, 0.0], PropKind::Position => vec![f64::from(comp.width) / 2.0, f64::from(comp.height) / 2.0, 0.0], PropKind::Scale => vec![1.0, 1.0, 1.0], @@ -1319,7 +1324,7 @@ fn plan_prop(kind: PropKind, label: Option<&str>, p: &Property, at: PropAt<'_>, /// back disabled). fn plan_remap(p: &Property, comp: &Comp, cp: &CompPlan, layer: &str, comp_name: &str, col: &mut Collector) -> PropPlan { // Rotation has the same layout (one component, stored as is, not spatial). - let at = PropAt { comp, comp_name, layer, source: (1, 1), text: false }; + let at = PropAt { comp, comp_name, layer, source: (1, 1), text: false, null: false }; let planned = plan_prop(PropKind::Rotation, Some("Time Remap"), p, at, cp, col); col.exported.time_remaps += 1; let mut plan = planned.unwrap_or_else(|| PropPlan { kind: PropKind::Rotation, value: vec![0.0], keys: Vec::new(), expr: None }); diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 5a55c557a..7d5b902f1 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -285,6 +285,8 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ cx.col.missing_footage.push(shown); } let kind = if sequence.is_empty() { footage_kind(&path, f.native_frame_rate, f.audio_sample_rate.is_some()) } else { FootageKind::Sequence }; + // A placed PDF / Illustrator file keeps After Effects' framing and size (#3099). + let placed_page = kind == FootageKind::Still && is_placed_page_path(&path); let mut footage = Footage { path, kind, @@ -295,6 +297,7 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ // footage), and only then. has_audio: kind == FootageKind::Audio || (kind == FootageKind::Video && f.audio_sample_rate.is_some()), missing: !present, + placed_page, sequence, ..Footage::default() }; @@ -327,6 +330,14 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ } } +/// Whether `path` names a file whose page is framed as After Effects places it +/// ([`Footage::placed_page`]): an Illustrator or PDF file, by its extension. EPS files keep +/// their bounding box at their own size: how After Effects sizes them was not measured (#3099). +fn is_placed_page_path(path: &str) -> bool { + let ext = path.rsplit(['/', '\\']).next().and_then(|name| name.rsplit_once('.')).map(|(_, ext)| ext.to_ascii_lowercase()); + matches!(ext.as_deref(), Some("pdf" | "ai")) +} + /// 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 { @@ -453,11 +464,13 @@ fn camera_light_orient(auto_oriented: bool, light: Option) -> AutoOri } /// The unit a layer's anchor is stored in. -fn anchor_unit(camera_or_light: bool, text_or_shape: bool, size: (u32, u32)) -> AnchorUnit { +fn anchor_unit(camera_or_light: bool, text_or_shape: bool, null: bool, size: (u32, u32)) -> AnchorUnit { if camera_or_light { AnchorUnit::PointOfInterest } else if text_or_shape { AnchorUnit::Pixels + } else if null { + AnchorUnit::Null(size.0, size.1) } else { AnchorUnit::Source(size.0, size.1) } @@ -582,7 +595,7 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition import_layer_styles(&layer, &mut l, &comp.global_light, &mut Ids(&mut project.next_id), &tm, &what, &mut cx.col); // Text and shape layers store their anchor in pixels (385, 462); cameras and lights their // point of interest. - let anchor = anchor_unit(is_camera || light.is_some(), is_text || is_shape, size); + let anchor = anchor_unit(is_camera || light.is_some(), is_text || is_shape, is_null, size); if let Some(tr) = l.props.sub_mut("transform") { import_transform(&layer, &name, comp_size, anchor, &tm, tr, &mut cx.col); } @@ -906,6 +919,18 @@ fn resolve_parents( mod tests { use super::*; + /// #3099: an `.ai` or `.pdf` from an `.aep` is a placed page; an EPS (not measured in After + /// Effects) and other files are not. + #[test] + fn only_illustrator_and_pdf_files_are_placed_pages() { + for path in [r"C:\fixture\x\logo.ai", "/work/Logo.AI", "art.pdf", r"C:\a.b\page.PDF"] { + assert!(is_placed_page_path(path), "{path}"); + } + for path in [r"C:\fixture\x\still.eps", "a.epsf", "a.epsi", "still.png", r"C:\dir.ai\file", "ai", ""] { + assert!(!is_placed_page_path(path), "{path}"); + } + } + #[test] fn unlisted_extensions_follow_what_the_file_stores() { assert_eq!(footage_kind("C:/clips/a.MP4", 0, false), FootageKind::Video); diff --git a/crates/aep/src/import/props.rs b/crates/aep/src/import/props.rs index 838a2ee44..fd0a4f9fc 100644 --- a/crates/aep/src/import/props.rs +++ b/crates/aep/src/import/props.rs @@ -136,8 +136,12 @@ const IGNORED: [&str; 4] = ["ADBE Position_0", "ADBE Position_1", "ADBE Position /// How the stored Anchor Point becomes pixels. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum AnchorUnit { - /// A fraction of a source this big (pixels): AV layers. + /// A fraction of a source this big (pixels): AV layers; the source's centre when absent. Source(u32, u32), + /// A fraction of a null object's source this big (pixels); its top-left corner when absent: + /// After Effects 2026 reads a null's absent Anchor Point as `[0, 0]` (#3099: two nulls made + /// with `addNull`, one left at its default, read back `[0, 0, 0]` and `[10, 20, 0]`). + Null(u32, u32), /// Pixels as stored, the origin when absent: text (385) and shape (462) layers, which have no /// source. Pixels, @@ -169,6 +173,14 @@ pub(crate) fn import_transform( let (sw, sh) = (f64::from(w), f64::from(h)); (Kind::Anchor(sw, sh), tr.anchor_px(sw, sh)) } + AnchorUnit::Null(w, h) => { + let (sw, sh) = (f64::from(w), f64::from(h)); + let px = match tr.anchor_fraction.source { + ValueSource::Default | ValueSource::Unreadable => [0.0; 3], + ValueSource::Stored | ValueSource::Keyframed => tr.anchor_px(sw, sh), + }; + (Kind::Anchor(sw, sh), px) + } AnchorUnit::Pixels | AnchorUnit::PointOfInterest => (Kind::Anchor(1.0, 1.0), tr.text_anchor_px()), }; let poi = anchor == AnchorUnit::PointOfInterest; diff --git a/crates/aep/tests/fixtures/ai-mask-3099.aep b/crates/aep/tests/fixtures/ai-mask-3099.aep new file mode 100644 index 0000000000000000000000000000000000000000..7223fda82f8a520db8d01325cd5bde56de1ae26e GIT binary patch literal 134446 zcmeHw3y>toS!VU@>_aPQwU*anEre+19NZez^XS=K%}ng>%6m??VrN@YxkhH{tx4rfY{u{3VD$csKm&-ik%8mElLi{!&TP9zWXCqiO%|%YX9L zJIj#vt>l8|t&n^t$T4~~z+?jF;p4RN3F_Ib-22;G-Mb<`bmD@ zI1JQYBDu9=NfgI7fxg^{6t`KS*?WM&kh_{nz2r3QH148hZ);j~Zq`h#c}6evMLDOxYivn|Hi=;COJI-ak2gex^JV z8Y`3>A+=4Ia`iBDjPmewn~aUirFvlM5PblCr0z5@IvaTXioO}RtPNg)3&)}3lECu` zjNvFSFTHdSu73;7)%c^WceUC`P@J*S*~Pdnsl1P*;!>2;RnOHbdxQQTiOl7CWQI$S ztrvbJI49^H;yJa*tdaN$iu9sWv&!>UO|RD-j|@7^ft9O$BQlr5M7jHTxkq@p2P(G) zZw*u+*GVYj1E2dCU`F1kL}=91#veWX#lL-QsxtmH_5{@tt{%FNW$F0efKCNBi?O(E zIgz`#B-Z~po(|NU>P+Cij44YJxpMXOmA)_8kd8vpdi zNci~pAF^X+<{XI<1<(1RLuQy3_iYbiTcqHQ5SzHnUhB@e7O$7%QYK=z29HHS{d$gFZ`$^4Dp<43nQ{fKgW z%DmF}IPU{&R{2JA{o)i}_Gddl>+aHS0iGzqm2gC!jo-id=s^QpAX8seG*%Muw{Nsh=ps7;^X;-iGv zq+wX~)>PEJG}MtCB@nqnXeql}-D8Nb5vDxlyFOVw%nA&V(!+)IF}*}@rS%kEqatPi z>5!3>&eDi;S^K#O|BXiC4xGuojp{4Hgu;S|$I?^pG1{7}EfH=Rh|98V#~|kt91!7% zbm53CM%?rprQ<5SvbbNOp=rmqS3~P|VsgT-OpVxL63W3xO4Spu=dY~`Nb?<9yRsh!b z*-PIXo1@p(E>@@N!4zOdm-crNGrl@+DKt=|6?d^V3;t?gv|jdLwJF`~K#$Gel|5t9 zB@0%dHy4CTk4tX21}CHyN^tJ*bAiEc6xKU&+vtKe9;JqjGObnze|(&v^t7cuo`dc= zg&eqG3tW^UaIU5tfT@>BLzBSM_CJvx@bwye3Q|Vp3<6-wDS_4G-IG8J;0WQ$opEr$!CPLl~;SN1!`>2 zw?K^z`WC3MLEi$vrb!!>mR78FGceG7^rhQ0-zA%?yMnY#mLa+1kRL6%6GI|D6;z3q+-hvll3R@pT5_wg zK}&8mHXYgK;x6$Lpj@wz_8-h?<;gTI-5FwN$&HrWWcTG0`E?GsOFZDk z1J@&?CAZMIpe45;4SGaNZUTd(OUhw&z2O@UE+x)_cUwkq2EreBMJLqwB#0`H62yjH<3~5b+dYSyF91E zr{Lw!ScuGUWG%7(mlQ40YVRys`efu2UP=*aw;l2+qCtL{?ATs-{w_bvUZU#oAKZ(=?mj8 zv6QSMNlX7o?yS756ixGMkw?y@md-JsNclUX)t)R`6%F?C&j77{;y?aDZd$E2*9d8~ zL962*Jiau3{rdIs&%p21SAUVi|@R_>>qi{2EAuM*sw80mztVkZ273bPh^Hf?rW056E?*Ks1p5kn!y)69Q}3F z*r30T8XNT2QDcMtI)F{9u^Yd<004;*xFc%8FE2EAzcwjbk_ymYhf*y1>!81m+|5L< z5-VQv)#jKq>M;qSJmtH7(43W~FbSdbaAYopAQdb@B3YSIdL72=biH)%vL5(8M{=F+ zYwR(iw&^2WEoCT^Gm=<2S^pf7=O+9&vMz61nuO#e`s<`-RP@&YN>gd#(O*Z^4GSTL z{yIfzYytgs3ie9$*C|S4=&w^0G4$6diWvIq6h#dEbvi=~{dLe^2mN)R@30_Az0-Q6 zm+3;FxZHHiUSaH2>JK_wVPt#y=$lqhzP4rsfn!=>Q&k{=*XHQ8wTsoMdO&tiC)x~= zo?Zvtxmqq*QDy~6Wp=YR3;t?gSmkT=$nm`}vn{y>D81i!9&ETJ0(-`!8(D#;N3wl5 z`sm328~IcfRXH^$*he ze-Xbc{yOjdKl-^Bzo?Hd2A>{3=lmgoo{zKWuOqq)KW6xr^<>eiAm5k&4bbYRMn-be z>R$5K*@Lt??!0D=FZ|dy$3Ok%()cGPf9js4)f~K%G|HpD4(Y+7Tc&z-c9hS$m$I)G zPnFsu_C67As?Fu`F*yB2BBlHhuRxdRdzK2XKp(oVxe|p^c9c(v5kr5Sq<;?j>u5@e zg7={J_|ff6KQeV7W@TQfck-{v+-O1lyzEZ*_3nz^nb1k$_jQ04BpN|~oirjvc@p&3 z8373K`#R{aBh52nlyQhJ6ReQ0xAbI+W%aZUf17=o&|gPtBpMMdh5kBfY|vjvjZH@$ z82Ei13D7O_;Z(Vt*iWgwopvu>IF;&9EFz@jSm)d3b}`CyL@nsA(+1t<%c6anuVkl4 z?SuY00Fr9`DbxB}d`*UNftkhuTkGSX2ZJu#MSWZ#g5TE>d%dE+PWI4~D^(hw&|im+ zPYKNy`s)-OvGMylMG-@Pox+HrzfM6u3jKA8(ir;d6h#dEb&4W}{yIewLw}vl5JP_* z^w&Xu9q2nONK!BQ>&!40m-38f3WS=E5@vjL9{qI~PUx>gaF8e@hrI{a+@iluE`OaP zZ%#w6bIpiYs=Wfg0|avj+-|jp8>B%OE`1%6NmriEwrTB!E0(RVx@1bo6|%~aTrYTr z(!{g>^1uVf2bV~x{F^8T#j{v@KlrZ|-%wF}V<*tP(Ds8`rW+ouJS+FUzgLvoYR|t( z&sgz;@|^E@Dl9marY&vTf0JdO_K>#kEeHkI7n&?>_w5sH+_tS~GYH${iI1@S6ED5d z_QqBjq<$S++M;c8PD|SuF|dzD28Z6-A4Cvf5*z?mAKVyt^8duUKoAEAo;fpO^Xy&r3aoE8Y(NI$4`M z=bXQu6Ma}5^W57ik3ja$-%QYK5U1Z;nuz0x(Y0vL$dRAZF3RKiq){`oVcw4^*P_6yFE$M>^mvDSX@l42a(sxMwF4hSD>PWzsl>(lbS*z_&e6 zdQQ_&4xV44Ih3Araukhh#r=V@DO!}6)gCB43v^l?QF<1-6s>n=J&f!%a}Dr`W2KmNdj_m4q0|&S@G#*_qCjMYub9LxH~?oq4$~17+tt4T7?BSIf>~ zYu=7z=S8X-$U`MG&sJpTedJjvJEQCjl+DOUX{w_{Lnu2_m;`p|BV24+ z8ByV?N=MmQg^VhlBZgMnKz+=iD3*CATqlg&6>}J{JE^^X|5p4!k;Qe3}t7OowXB!M%o=Exd&xufJ@cuaNSu&OA-yC>`Y-w%giDU z7g2WJ@{U3(N>rtz>}*=gZ0D_eS$0l)Jm1ghr6bvS-dSELV6?pz*?B*E7D~@3Jp;w= zW$F1hFVQX~q-WK5+%8?>C}PP5SDtf9HZfwj@>~=#l%8`UhAYo7h)LrVO3xG#TzSTo zXYF3y#}ci!+p%_u>q66p`-OFd$8%bGetzcM3f3->jM8&#|J?<4+eaUpl6TvFkT+#V((}}62-0);X~$YGtGU#eSlUU?V$-s$ z-L@Z;pT(r;BY&jW9eazrZr^`GQErpemau%pZ(zj@tv-=>Eg|8kvvV(ZtqZ>zcIV@- zzWVCV58b%)vp;kF`oi%~-g)IS^w{EMGcRQ#b_=EGoDzM zXa&#>RscGv_oPwU{G9K+?yMvf|L|636dsXv;dW`lYTR4Z{E5(hd#}G)Uj;O~-Bcq}r z+zTL$l!!|-gnI!{Skip8h{MGu{#xLe_qd}oIeIhXZ3f6E#tPJ9bfq5SA>Pzm)?<)O z2-}2h(_;*y9^*0eY5;0$vX9wAyU~C0I2!j8`6v2_93W8}0hh!I0QMoz>~ zkI|-wcXCFV{!9wEhEG^fZ`d-SXC{QAv{4(@5FY5#m>!7{G6Y;Q;6Hhz?U;h=>c zL;DcHG!;sO$D7rI+g1>!E=kek_Y<=87_^PV9a$N8n;sO;V(s~zk10Gqw&+oE@VgY{ zHc8A0tJawFM{EC_oPUfMN|!kiyB0W>7wM7Xd!o0bkE~~udi*Gi_^1bdS4&}JhD(r_ z^t3!H<_96)0S*35ZT!*GU;NuV8ncpiJY5@zXLaX{Y%GauGqTE|8TcE$$B%Ar`jM$a zqB5^Ehj5$o8_Flgeu1Cu04?&4rMK?ZY~IjRdjFS(YPMuo){weNwMfy^eYc$)&X zaXiloa#Uw`YU8g)R&dd62aT#_cLJL|Q=27g&2ndIOMV!(B%Ix8DPtw@Yu1GuEvusL zgWKFvq3=581;=&URA+avSq^lQFn;YSv^%w_>k)J+uU%z!sZZH~o`-C9sWz04C@@+w zzGEW_La*h-##*+tlA3vP&6=c}m#3Y;GE{N{r<+bWUn<=fkP|=(kp%wi>8fR_SoIWI z#g>`jqQs>XZJ${k=-tuX7A?dOJT}om`%LL!6qebHsbHG6);=JY0Ni({ecH8)xGuHN z74ngc@?xNSVa*A{EMb=;lHO@T*&kxd2KEuqu8`FVc*9N_hE;D(Mcqq79rba62&JX$ zZgr0#!bX_#l<)e1=ys_iL!|U@VGT$x(G+Ptg*70h4CPQpQmUGXD4z$OoABRAq;CJI zG(Jt%>y{pbWydSeTei#QRhh=_G1{7}EfM|{h|98V#~|nFHsFYK;fO8PyVGx!ju%=l zpLT3}HMHKQh+R%&lN~RF*h1YhR$V=40a+K**p48!;&_f{g`paGVQ0MrG%^9$22Ssela;lX!ZDSb5%eUEj9T;L|uFCLwm&Gp&uXZxb0P&@*+5 zTSM$E2nF?4+h$RiU+2KIG$KFrQo8Tm1g9Vgv7&m%$yL+wv)4HVfpUb?E0Rh**cMhK z`P!}($&#+92|4a!ZE|4AYlAj{j&4BXfL_IR>f{Xc^|oWXa7NYe9H);@UA}OQpZ8DI z7phYsv!5gi8E$`f=(-m<5v(xt_lUKhzum++LX~`y-8|V-SagZ-qSVQ3%%jkg_)Uhy}m%K4h~H$-Jt|vubFFbLMU>d z?f7*#5{ixJa6{?T_8WBEI8#&f!OvfgteOKBzocA3#g0jH$28_J3)n@fy@czDK(eb1 zIpd7`?Xj9{XQ#++^oH++4sI^)ycH+n=ka~p#fGr zhQI1Xx0dwK>Khw+LQ0JEVA+cL#;Q;B*{iM#tIaEYV|`O&H*ea`Mzk7OH*ap-yy*-N zj*eFJ%96ifh5fqI_e5Xa)g#*vYP7`A3f8RPmIsT&aQCHa3yXt;gOG`*uleq3%@UWN zrZKq6o_)&+Z`rb=XngQCWFRub^X-7Z*g#&;5Lm&E{qo4lh_&a~{AFlodn@4)4 zr>@K|SaruFd1%*5{OLUX+r~fJ|GB4r_!po1xihyu@Z$IXXV>xQ{7yQkRa>H?X-RYH z>!cR~APor*Nf%?-*#N+NiXynWc`T8r}apO-Zcqffc#BPPJJ)@ zEj@E;&~Tr+^u5mcXPntx zR1#pdoQ(H9Hw3Nl((WoRu><_VXOsK_={W`X1(JO``Gr@D@C&61JiV*Yg{2BnRqQyspyU>ynMj}`|AxKHVw%SKmx!@r10OjZWA`8eKr57H;ME1U z@Zit=hu{B)M=yQwBR_cK9XG!I(0?B^&xP>R+ z=|q}juX`R~8-?Z_=4w?H&Hs9R|2L6ET1x`keN zMgeYtWWQ_Ng7&BI^bPny5L|*dLegI37CxGbTi9;hf?0_Ir?%tlf>O6|0$PKp^S%K; zQVuw);Q&IYz(Q&*oWOXRXorZ@s!+ z(oBP{=)($ZJy>1>XyL5=io1p@x7;GJsHK-Y3VjOzUSJd=*>M!g=>LM=)0a9Pg+L#5 zuLdy+z4a4cDL?#)@AR`t}M#xg=%j-G?#l=Bi!EhhXdN|_7@5z9?UZ*bnxUOWJPBO!b94^4wOoE z2Hu%+5A9I7@7SSoAKsyI-?>BOKDa~u;sI@9N9gQ6s)O48qVM0K{&sAKaM*qXu*3uc ze2#td%DfrOuh5+`kAd_aD?>{C`~MQMm*!#PNI23Nn)7HAt`kP?ia8AFC$-n_|7xOG znC}k#KDjUPOI&8Byqw-Em!q0A*U$60zUdC0uv2sW0c~3K$O7rjbN#_>nd_U&J*2(6 zK)qC1o6GGjT~uW*kltMGeX)M2Kru~Oo69|{{XkQ$;t9>=-oHcTKB&E~c=ej@Xs+)A z+SR69#S@y#EnWuRfe0W1dkg_S-%igh&d=D3mWyd_*_spr;!P)whwc=cQKCl*^zq)8Vf7=)}`^L{5KC>~lQLEQrQ=#%k&Go{u zjkA5QPxq2F2InMsps!4`M7PiOO_B@c8?$q;VMkzhI3k%342dh%z`PautASz7tU>h~u)tfNF^w_eXsSo&^m^TO4EjNi zfi=(MwbyOfFcx;ggPj4x{(9iQA5P8;H12AkcLv^mY$~wy$PX5M-#y2uV3HJ2o?#`B zCO=(1@r>ia7VqJE&kUsR$tunQ-#qIaxwA4fSQ#2#96B{teR8aN^1BDCV}pZ56{JtX4*chKEOntGYQnI4p`z+}>0)e5?hA3f)cQA+<01 zh>T*I9(4&s6F=Cb{Xg!ObRbx;zE4gi^h(CAjn*@axq4%?Wg5cv3ATEjAL;KAb z+vnw+K`7QT&kRVZjcUbr^4px|2F!;tD?f3&`Wfphme-2lSy42Xx{%O^nF97rnp_5& z$kiEHvGRTC^3Mz~#CM=2LVay(?U1h{ZLJ-oA>%H#LxYZu72R95%yR>V??uqmf|5mg zz7}NdDt9q4GNigB%bJ)@*<4Kt114+W6$WBvp@~f5?zEjJ=~vv&vg5MwAu$wrP7tm< uD8+dYt{93uCkR&_l;S)HR}4j-6ND=dN^zc6a2;-_8(^=bvwiEJO!)sG$5C|v literal 0 HcmV?d00001 diff --git a/crates/aep/tests/fixtures/ai-mask-3099.aep.attribution b/crates/aep/tests/fixtures/ai-mask-3099.aep.attribution new file mode 100644 index 000000000..86efa3b65 --- /dev/null +++ b/crates/aep/tests/fixtures/ai-mask-3099.aep.attribution @@ -0,0 +1,7 @@ +asset: ai-mask-3099.aep +title: Small After Effects test project: placed .ai files with masks under two parented Nulls (#3099) +author: EffectCraft contributors +source: original work (made in After Effects 2026 from own data: four hand-written one-page PDFs named .ai, generated by tools/ai-mask-3099.py, imported and laid out by tools/ai-mask-3099.jsx) +license: MIT OR Apache-2.0 +added: 2026-11-08 by EffectCraft contributors +notes: Scrubbed with tools/scrub.py (footage paths C:\fixture\x\.ai of the same length, host name HOST000, the XMP packet of an empty project of the contributor); its four embedded colour profiles replaced by stubs (description only, no colour data) with crates/aep/examples/stubicc.rs, then checked with the corpus repository's tools/aep/inspect-embedded.py (CLEAN). No Adobe asset is in it. diff --git a/crates/aep/tests/fixtures/tools/README.md b/crates/aep/tests/fixtures/tools/README.md index eee0174ee..f1c720b2e 100644 --- a/crates/aep/tests/fixtures/tools/README.md +++ b/crates/aep/tests/fixtures/tools/README.md @@ -29,6 +29,7 @@ single-case files some cases are merged from, and the contributor's own animatio | `scrub.py` | fixture hygiene of a saved `.aep`: footage alias paths replaced by neutral ones of the same length (`C:\fixtures\...`), host name `HOST000...`, neutral ascend counts, the XMP packet replaced by the base project's | | `layers-comps-scrub-aepx.py` | the same XMP swap for the `.aepx` of the pair | | `patch-text.py` | the byte edits of the text cases 732 and 733 (see below) | +| `ai-mask-3099.py`, `ai-mask-3099.jsx` | the committed `../ai-mask-3099.aep` (#3099): the `.py` writes four one-page PDFs named `.ai` with distinct page boxes, the `.jsx` (ExtendScript, run once through an After Effects MCP server's script call in an empty untitled project) imports them, builds composition `ai-mask` and returns After Effects' read-back that `../../it/import/ai_mask.rs` compares against | ## Run order (per file) diff --git a/crates/aep/tests/fixtures/tools/ai-mask-3099.jsx b/crates/aep/tests/fixtures/tools/ai-mask-3099.jsx new file mode 100644 index 000000000..bcac03d0b --- /dev/null +++ b/crates/aep/tests/fixtures/tools/ai-mask-3099.jsx @@ -0,0 +1,94 @@ +// Issue 3099: how After Effects sizes a placed .ai (PDF) and where masks / parented layers land. +// Generates ai-mask-3099.aep: run `python -I ai-mask-3099.py ` first, set `dir` to that +// folder, then run this script (ExtendScript) in an empty, untitled project. It returns After +// Effects' read-back (footage sizes, transforms, mask vertices, toComp points; the tests compare +// against it) and saves /i3099-ai-mask.aep, which is then scrubbed (README: steps 5 and 6). +(function () { + var dir = "/"; + var out = {}; + if (app.project.file !== null || app.project.numItems !== 0) { + return "ABORT: project not empty/untitled (" + app.project.numItems + " items)"; + } + var target = new File(dir + "i3099-ai-mask.aep"); + if (target.exists) { return "ABORT: target exists"; } + var names = ["i3099-a-boxes.ai", "i3099-b-frac.ai", "i3099-c-rot.ai", "i3099-d-trim.ai"]; + var items = {}; + out.footage = {}; + for (var i = 0; i < names.length; i++) { + var io = new ImportOptions(new File(dir + names[i])); + io.importAs = ImportAsType.FOOTAGE; + var it = app.project.importFile(io); + items[names[i]] = it; + out.footage[names[i]] = { w: it.width, h: it.height, par: it.pixelAspect }; + } + var comp = app.project.items.addComp("ai-mask", 1920, 1080, 1, 5, 25); + var art = comp.layers.add(items["i3099-a-boxes.ai"]); + art.name = "art"; + var artD = comp.layers.add(items["i3099-d-trim.ai"]); + artD.name = "artD"; + artD.collapseTransformation = true; + var artC = comp.layers.add(items["i3099-c-rot.ai"]); + artC.name = "artC"; + artC.property("ADBE Transform Group").property("ADBE Position").setValue([1500, 800]); + var n1 = comp.layers.addNull(); n1.name = "N1"; + var n2 = comp.layers.addNull(); n2.name = "N2"; + n2.setParentWithJump(n1); + art.setParentWithJump(n2); + artD.setParentWithJump(n2); + var t = function (l, p, v) { l.property("ADBE Transform Group").property(p).setValue(v); }; + t(n1, "ADBE Position", [700, 400]); + t(n1, "ADBE Rotate Z", 15); + t(n1, "ADBE Scale", [80, 120]); + t(n2, "ADBE Anchor Point", [10, 20]); + t(n2, "ADBE Position", [50, -30]); + t(n2, "ADBE Scale", [150, 150]); + t(art, "ADBE Position", [0, 0]); + t(artD, "ADBE Position", [300, 250]); + // Two rectangular masks on each art layer, in layer pixels. + var rect = function (l, nm, x0, y0, x1, y1) { + var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom"); + m.name = nm; + var s = new Shape(); + s.vertices = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + s.closed = true; + m.property("ADBE Mask Shape").setValue(s); + }; + rect(art, "M1", 40, 30, 200, 90); + rect(art, "M2", 220, 100, 350, 160); + rect(artD, "M1", 40, 30, 200, 90); + rect(artD, "M2", 220, 100, 350, 160); + // World positions of layer points through the parent chain (toComp), read via a Point Control. + var probe = n1.property("ADBE Effect Parade").addProperty("ADBE Point Control"); + var pt = probe.property(1); + var toComp = function (ln, p) { + pt.expression = "thisComp.layer('" + ln + "').toComp([" + p[0] + "," + p[1] + "])"; + var v = pt.valueAtTime(0, false); + return [v[0], v[1]]; + }; + var rd = function (l) { + var tg = l.property("ADBE Transform Group"); + var r = { + w: l.width, h: l.height, + anchor: tg.property("ADBE Anchor Point").value, + position: tg.property("ADBE Position").value, + scale: tg.property("ADBE Scale").value, + rot: tg.property("ADBE Rotate Z").value, + masks: [] + }; + var mp = l.property("ADBE Mask Parade"); + for (var k = 1; k <= mp.numProperties; k++) { + r.masks.push(mp.property(k).property("ADBE Mask Shape").value.vertices); + } + return r; + }; + out.layers = { art: rd(art), artD: rd(artD), artC: rd(artC), N1: rd(n1), N2: rd(n2) }; + out.world = {}; + var pts = { tl: [0, 0], tr: [art.width, 0], bl: [0, art.height], m1: [40, 30], m2: [350, 160] }; + for (var key in pts) { out.world["art_" + key] = toComp("art", pts[key]); } + var ptsD = { tl: [0, 0], tr: [artD.width, 0], bl: [0, artD.height], m1: [40, 30] }; + for (var key2 in ptsD) { out.world["artD_" + key2] = toComp("artD", ptsD[key2]); } + probe.remove(); + app.project.save(target); + out.saved = target.fsName; + return out; +})(); diff --git a/crates/aep/tests/fixtures/tools/ai-mask-3099.py b/crates/aep/tests/fixtures/tools/ai-mask-3099.py new file mode 100644 index 000000000..6b5b7facf --- /dev/null +++ b/crates/aep/tests/fixtures/tools/ai-mask-3099.py @@ -0,0 +1,66 @@ +"""Writes hand-made one-page PDFs named .ai with distinct page boxes (issue 3099 repro).""" +import os +import sys + +out = sys.argv[1] +os.makedirs(out, exist_ok=True) + + +def pdf(page_extra, content): + objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R " + page_extra.encode() + b" /Contents 4 0 R >>", + b"<< /Length " + str(len(content)).encode() + b" >>\nstream\n" + content + b"\nendstream", + ] + data = b"%PDF-1.4\n" + offs = [] + for i, o in enumerate(objs, 1): + offs.append(len(data)) + data += str(i).encode() + b" 0 obj\n" + o + b"\nendobj\n" + x = len(data) + data += b"xref\n0 " + str(len(objs) + 1).encode() + b"\n0000000000 65535 f \n" + for o in offs: + data += b"%010d 00000 n \n" % o + data += b"trailer\n<< /Size " + str(len(objs) + 1).encode() + b" /Root 1 0 R >>\nstartxref\n" + str(x).encode() + b"\n%%EOF\n" + return data + + +def boxes_content(boxes): + # One outline per box (different colours) plus a filled marker at the media box origin corner. + cols = ["0 0 0", "1 0 0", "0 0.6 0", "0 0 1", "1 0.5 0"] + s = b"0.85 0.85 0.85 rg " + x0, y0, x1, y1 = boxes[0] + s += b"%g %g %g %g re f\n" % (x0, y0, x1 - x0, y1 - y0) + for c, (a, b, cc, d) in zip(cols, boxes): + s += c.encode() + b" RG 1 w %g %g %g %g re S\n" % (a + 0.5, b + 0.5, cc - a - 1, d - b - 1) + # A solid square in the lower-left of the last (innermost) box: shows y orientation. + a, b, _, _ = boxes[-1] + s += b"0.2 0.2 0.8 rg %g %g 20 10 re f" % (a + 4, b + 4) + return s + + +def arr(b): + return "[%s]" % " ".join("%g" % v for v in b) + + +# A: every box distinct, nested, non-square. +media, crop, bleed, trim, art = [0, 0, 420, 220], [5, 5, 415, 215], [10, 10, 410, 210], [20, 20, 400, 200], [60, 50, 300.6, 170.4] +a = pdf( + f"/MediaBox {arr(media)} /CropBox {arr(crop)} /BleedBox {arr(bleed)} /TrimBox {arr(trim)} /ArtBox {arr(art)}", + boxes_content([media, crop, bleed, trim, art]), +) +# B: only a fractional media box. +mb = [0, 0, 300.6, 120.4] +b = pdf(f"/MediaBox {arr(mb)}", boxes_content([mb])) +# C: media box with an offset origin and /Rotate 90. +mc = [100, 50, 400, 150] +c = pdf(f"/MediaBox {arr(mc)} /Rotate 90", boxes_content([mc])) +# D: media + trim + art, no crop box (bleed around the artboard). +md, td, ad = [0, 0, 418, 218], [9, 9, 409, 209], [40, 30, 250, 150] +d = pdf(f"/MediaBox {arr(md)} /TrimBox {arr(td)} /BleedBox {arr(md)} /ArtBox {arr(ad)}", boxes_content([md, td, ad])) + +for name, data in [("i3099-a-boxes.ai", a), ("i3099-b-frac.ai", b), ("i3099-c-rot.ai", c), ("i3099-d-trim.ai", d)]: + with open(os.path.join(out, name), "wb") as f: + f.write(data) + print(name, len(data)) diff --git a/crates/aep/tests/it/export/built.rs b/crates/aep/tests/it/export/built.rs index 14f9d8795..0da683cc4 100644 --- a/crates/aep/tests/it/export/built.rs +++ b/crates/aep/tests/it/export/built.rs @@ -83,6 +83,45 @@ fn ntsc_and_odd_frame_rates_use_the_time_bases_after_effects_writes() { } } +/// #3099: After Effects reads a null's absent Anchor Point as its top-left corner `[0, 0]`. A +/// null anchored at its centre is written (it was left out as "the default" and came back at the +/// corner); one at the corner is not, and both import back as they were. +#[test] +fn null_anchor_points_round_trip() { + let mut p = Project::default(); + let c = add_comp(&mut p, "C", 800, 600, FrameRate::new(30, 1), 2.0, None); + add_layer(&mut p, c, "Corner", LayerSource::Null, (100, 100)); + add_layer(&mut p, c, "Centre", LayerSource::Null, (100, 100)); + for (idx, anchor) in [(0, [50.0, 50.0, 0.0]), (1, [0.0, 0.0, 0.0])] { + with_layer(&mut p, c, idx, |l| { + if let Some(a) = l.props.prop_mut("transform/anchor") { + a.value = Value::Vec3(anchor); + } + }); + } + let r = export_all(&p); + let file = parse(&r.bytes).expect("parse"); + let view = effectcraft_aep::Project::new(&file).expect("project"); + let comp = view.all_items().into_iter().find(|i| i.comp().is_some()).expect("comp"); + let stored: Vec<_> = + comp.comp().expect("c").layers().map(|l| (l.name().unwrap_or_default(), l.transform(800, 600).expect("transform").anchor_fraction.source)).collect(); + assert_eq!(stored, [("Centre".to_string(), effectcraft_aep::ValueSource::Stored), ("Corner".to_string(), effectcraft_aep::ValueSource::Default)]); + let mut back = Project::default(); + let none = |_: &str| false; + let res = effectcraft_aep::import::import( + &mut back, + &r.bytes, + &effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) }, + ) + .expect("import"); + let comp = res.comps.first().and_then(|id| back.comp(*id)).expect("comp"); + for (name, want) in [("Centre", [50.0, 50.0]), ("Corner", [0.0, 0.0])] { + let l = comp.layers.iter().find(|l| l.name == name).expect("layer"); + let a = l.props.prop("transform/anchor").expect("anchor").value.as_vec2(); + assert!(close(a[0], want[0]) && close(a[1], want[1]), "{name}: {a:?}"); + } +} + #[test] fn layer_header_fields() { let mut p = Project::default(); diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index eb6963d6f..2535edcff 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -4,6 +4,7 @@ mod helpers; mod aepx; +mod ai_mask; mod blend_modes; mod cameras; mod cases; diff --git a/crates/aep/tests/it/import/ai_mask.rs b/crates/aep/tests/it/import/ai_mask.rs new file mode 100644 index 000000000..e5f57abe0 --- /dev/null +++ b/crates/aep/tests/it/import/ai_mask.rs @@ -0,0 +1,113 @@ +//! A placed Illustrator file with masks under two parented Nulls (#3099), against After Effects +//! 2026's own read-back of the committed project `tests/fixtures/ai-mask-3099.aep` (generator: +//! `tests/fixtures/tools/ai-mask-3099.py` and `ai-mask-3099.jsx`). Most of these guard After +//! Effects' values the importer already read so (the drawing of the page is tested in +//! `effectcraft-media` and the host's `vector_import` tests); the Anchor Point of the null left +//! at its default (`[0, 0]`, not the centre) was wrong before #3099. +//! +//! The four `.ai` files are hand-written one-page PDFs with distinct page boxes. After Effects +//! sized them by their ArtBox (a, d), by the MediaBox rounded up (b: 300.6 × 120.4 → 301 × 121) +//! and ignored `/Rotate 90` (c). Composition `ai-mask` (1920 × 1080): `art` (a) and `artD` (d, +//! Continuously Rasterize) are children of Null `N2`, itself a child of Null `N1`; both carry two +//! rectangular masks; `artC` (c) is on its own. + +use effectcraft_aep::import::{ImportOptions, import}; +use effectcraft_keyframe::Value; +use effectcraft_project::{ItemKind, Layer, Project}; + +use crate::import::helpers::comp; + +const AI_MASK: &[u8] = include_bytes!("../../fixtures/ai-mask-3099.aep"); + +/// The fixture imported into an empty project (losses allowed, no footage file present). +fn load() -> (Project, effectcraft_aep::import::ImportResult) { + let mut project = Project::default(); + let none = |_: &str| false; + let r = import(&mut project, AI_MASK, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); + (project, r) +} + +/// The first two components of the static value of property `path` of `l`. +fn vec2(l: &Layer, path: &str) -> [f64; 2] { + let v = l.props.prop(path).unwrap_or_else(|| panic!("{}: {path}", l.name)).value.as_vec2(); + [v[0], v[1]] +} + +/// `a` and `b` agree within float32 noise. +fn near(a: [f64; 2], b: [f64; 2]) -> bool { + (a[0] - b[0]).abs() < 1e-3 && (a[1] - b[1]).abs() < 1e-3 +} + +/// The four `.ai` items have After Effects' sizes and are placed pages. +#[test] +fn placed_ai_sizes_are_after_effects_sizes() { + let (p, _) = load(); + let mut sizes: Vec<(String, (u32, u32))> = p + .items + .values() + .filter_map(|i| match &i.kind { + ItemKind::Footage(f) if f.path.ends_with(".ai") => { + // Framed and drawn as After Effects places the page (#3099). + assert!(f.placed_page, "{}", i.name); + Some((i.name.clone(), (f.width, f.height))) + } + _ => None, + }) + .collect(); + sizes.sort(); + assert_eq!( + sizes, + [ + ("i3099-a-boxes.ai".to_string(), (241, 121)), + ("i3099-b-frac.ai".to_string(), (301, 121)), + ("i3099-c-rot.ai".to_string(), (300, 100)), + ("i3099-d-trim.ai".to_string(), (210, 120)), + ] + ); +} + +/// Parents, transforms (the default null anchor at `[0, 0]`) and both masks of each art layer +/// as After Effects read them back. +#[test] +fn masks_anchors_and_parents_are_after_effects_values() { + let (p, r) = load(); + let (_, c) = comp(&p, &r, "ai-mask"); + let layer = |name: &str| c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}")); + let (art, art_d, art_c, n1, n2) = (layer("art"), layer("artD"), layer("artC"), layer("N1"), layer("N2")); + // Parents: art, artD -> N2 -> N1. + assert_eq!(art.parent, Some(n2.id)); + assert_eq!(art_d.parent, Some(n2.id)); + assert_eq!(n2.parent, Some(n1.id)); + assert_eq!(n1.parent, None); + assert_eq!(art_c.parent, None); + // Transforms as After Effects read them back (anchor = half the source size for footage). + for (l, anchor, position, scale) in [ + (art, [120.5, 60.5], [0.0, 0.0], [100.0, 100.0]), + (art_d, [105.0, 60.0], [300.0, 250.0], [100.0, 100.0]), + (art_c, [150.0, 50.0], [1500.0, 800.0], [100.0, 100.0]), + (n1, [0.0, 0.0], [700.0, 400.0], [80.0, 120.0]), + (n2, [10.0, 20.0], [50.0, -30.0], [150.0, 150.0]), + ] { + assert!(near(vec2(l, "transform/anchor"), anchor), "{} anchor {:?}", l.name, vec2(l, "transform/anchor")); + assert!(near(vec2(l, "transform/position"), position), "{} position {:?}", l.name, vec2(l, "transform/position")); + assert!(near(vec2(l, "transform/scale"), scale), "{} scale {:?}", l.name, vec2(l, "transform/scale")); + } + match n1.props.prop("transform/rotation").map(|p| &p.value) { + Some(Value::Scalar(v)) => assert!((v - 15.0).abs() < 1e-9, "N1 rotation {v}"), + other => panic!("N1 rotation {other:?}"), + } + // Both art layers: M1 (40, 30)-(200, 90) and M2 (220, 100)-(350, 160) in layer pixels, the + // second reaching past the layer, as After Effects read the vertices back. + let rects = [[[40.0, 30.0], [200.0, 30.0], [200.0, 90.0], [40.0, 90.0]], [[220.0, 100.0], [350.0, 100.0], [350.0, 160.0], [220.0, 160.0]]]; + for l in [art, art_d] { + let masks: Vec<_> = l.masks().expect("masks").groups().collect(); + assert_eq!(masks.len(), 2, "{}", l.name); + for (m, want) in masks.iter().zip(&rects) { + let Some(Value::Path(path)) = m.get("path").map(|p| &p.value) else { panic!("{}: {} has no path", l.name, m.name) }; + assert_eq!(path.vertices.len(), 4, "{}: {}", l.name, m.name); + for (v, w) in path.vertices.iter().zip(want) { + assert!(near(*v, *w), "{}: {} vertex {v:?} != {w:?}", l.name, m.name); + } + } + } +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 6c4b02c87..250ca836d 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -5,7 +5,7 @@ use effectcraft_color::Label; use effectcraft_keyframe::Value as KV; use effectcraft_project::build::{self, Ids}; -use effectcraft_project::{ItemKind, LayerSource, Solid}; +use effectcraft_project::{ItemKind, LayerSource, MatteKind, Solid, TrackMatte}; use serde_json::{Value, json}; use super::{CommandSpec, bad, has_layers, layers_p, str_p}; @@ -13,6 +13,8 @@ use crate::{EngineError, Result, Session, cmd}; const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d"; +/// Layer ▸ Create (`layer.create`): the `op` ([`OPS`]) on the given or selected layers; returns +/// the created layer ids and what was skipped. fn create(s: &mut Session, p: &Value) -> Result { let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?; let (cid, lids) = layers_p(s, p)?; @@ -99,8 +101,11 @@ fn create(s: &mut Session, p: &Value) -> Result { continue; }; let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?; - let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page) + // In the source layer's pixels, as the footage is drawn (a placed page of an After + // Effects project fills the stored size, #3099). + let doc = crate::vector::footage_doc(&f, &bytes) .unwrap_or_else(|| Err(format!("{}: not a vector file", f.path))) + .map(|d| crate::vector::fitted_to_footage(d, &f)) .map_err(EngineError::Other)?; // Images become footage layers (PNG files written next to the source and // imported), parented to the shape layer below it. @@ -123,7 +128,7 @@ fn create(s: &mut Session, p: &Value) -> Result { // Without images: one shape layer. With images: the shapes between them // in layers of their own, so the stack keeps the document's paint order. let segments = if image_items.is_empty() { vec![doc.clone()] } else { crate::vector::split_at_images(&doc) }; - let top = crate::vector::shapes_from_vector(proj, &comp, &src, segments.last().unwrap_or(&doc)); + let mut top = crate::vector::shapes_from_vector(proj, &comp, &src, segments.last().unwrap_or(&doc)); // Top to bottom: the last segment, image n, segment n − 1, … image 1, segment 0. let mut stack = vec![]; for k in (0..segments.len().saturating_sub(1)).rev() { @@ -140,9 +145,25 @@ fn create(s: &mut Session, p: &Value) -> Result { sl.name = format!("{} {}", top.name, k + 1); stack.push(sl); } + // A placed page shows only its frame (the footage's raster ends there): every + // layer made from it takes a hidden frame layer as its track matte (#3099). + let matte = crate::vector::frame_matte(proj, &comp, &src, &f); + if let Some(m) = &matte { + let tm = Some(TrackMatte { layer: m.id, kind: MatteKind::Alpha }); + top.track_matte = tm; + for l in &mut stack { + l.track_matte = tm; + } + } let mut ids = vec![top.id]; ids.extend(stack.iter().filter(|l| matches!(l.source, LayerSource::Shape)).map(|l| l.id)); - // Each directly above the source: the top layer first. + // The frame matte is made too (last: the selection stays on the top layer). + ids.extend(matte.as_ref().map(|m| m.id)); + // Each directly above the source: the frame matte (when there is one) above + // all, then the top layer first. + if let Some(m) = matte { + insert_above(proj, cid, lid, m, true)?; + } insert_above(proj, cid, lid, top, true)?; for l in stack { insert_above(proj, cid, lid, l, true)?; diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index e56e16a96..5a0576f10 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -176,9 +176,11 @@ fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u Some((u32::try_from(found.index).ok()?, size)) } +/// The pixel size of vector footage `f` as its file is now (its page, framing and layer), or +/// `None` when the file cannot be read or shows nothing. fn vector_size(s: &Session, f: &Footage) -> Option<(u32, u32)> { let bytes = s.services.read_file(&f.path).ok()?; - let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)?.ok()?; + let doc = crate::vector::footage_doc(f, &bytes)?.ok()?; (!doc.root.children.is_empty()).then(|| doc.pixel_size()) } @@ -229,6 +231,10 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { // The layer is gone from the file: keep the item as it was. None => (w, h) = (f.width, f.height), }, + // A placed page of an After Effects project keeps the size After Effects stored: the + // layers' anchors and masks are measured against it, and the page is drawn to fill it + // (the probe would measure the page as a viewer frames it, #3099). + None if f.placed_page => (w, h) = (f.width, f.height), // A later page of a PDF: the probe measures the first page. None if f.page != 0 => (w, h) = (f.width, f.height), None => {} diff --git a/crates/engine/src/interchange/vector.rs b/crates/engine/src/interchange/vector.rs index 324d3a82a..b52517cb3 100644 --- a/crates/engine/src/interchange/vector.rs +++ b/crates/engine/src/interchange/vector.rs @@ -261,15 +261,73 @@ pub fn is_vector(f: &effectcraft_project::Footage) -> bool { effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg") } +/// `doc` (the document of footage `f`) in the footage's layer pixels, as the renderer draws it +/// ([`effectcraft_render::vector_fit`]): a placed page of an After Effects project is scaled to +/// fill the size stored for it; other footage is unchanged. +pub fn fitted_to_footage(mut doc: Doc, f: &effectcraft_project::Footage) -> Doc { + let (_, [sx, sy]) = effectcraft_render::vector_fit(f, [doc.width, doc.height], doc.pixel_size(), 1.0); + if f.placed_page { + doc.root.transform = Affine::scale_non_uniform(sx, sy) * doc.root.transform; + (doc.width, doc.height) = (doc.width * sx, doc.height * sy); + } + doc +} + +/// Create Shapes from Vector Layer on a placed page of an After Effects project: a hidden shape +/// layer filling the frame the footage is drawn in (its `width` × `height` raster, in the layer +/// pixels of [`fitted_to_footage`]'s document), to be the Alpha track matte of every layer made +/// from the page, so bleed, art past the artboard and the page's background stay hidden as on +/// the footage (#3099). `None` for other footage, whose shapes are converted whole as before. +/// +/// A track matte is drawn at the composition's resolution, so its edge is the footage's raster +/// edge on shape and image layers alike; a mask on an image layer would be drawn at the image's +/// own resolution and blur its edge when the image is scaled up. +pub fn frame_matte(proj: &mut Project, comp: &Comp, src: &Layer, f: &effectcraft_project::Footage) -> Option { + if !f.placed_page || f.width == 0 || f.height == 0 { + return None; + } + let svg = format!( + r##""##, + f.width, f.height + ); + let doc = effectcraft_svg::parse(svg.as_bytes()).ok()?; + let mut l = build::layer(proj, comp, &format!("{} Frame", src.name), LayerSource::Shape, (0, 0), None); + add_doc_shapes(proj, &mut l, &doc); + copy_transform(proj, src, &mut l); + like_source(&mut l, src); + // The layers it mattes carry the source's opacity already. + if let Some(o) = l.props.prop_mut("transform/opacity") { + o.value = Value::Scalar(100.0); + o.keys.clear(); + o.expr = None; + } + l.name = comp.unique_layer_name(&format!("{} Frame", src.name)); + l.switches.video = false; + Some(l) +} + +/// The vector document of footage `f` (its file read as `bytes`; `None` for other formats): its +/// page, framed as [`effectcraft_project::Footage::placed_page`] says, restricted to its layer. +pub fn footage_doc(f: &effectcraft_project::Footage, bytes: &[u8]) -> Option> { + let framing = if f.placed_page { effectcraft_pdf::Framing::Placed } else { effectcraft_pdf::Framing::Viewer }; + vector_doc(&f.path, bytes, f.layer.as_ref(), f.page, framing) +} + /// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page -/// `page` of a PDF, restricted to one layer when `layer` names it. -pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32) -> Option> { +/// `page` of a PDF framed as `framing` says, restricted to one layer when `layer` names it. +pub fn vector_doc( + path: &str, + bytes: &[u8], + layer: Option<&effectcraft_project::SourceLayer>, + page: u32, + framing: effectcraft_pdf::Framing, +) -> Option> { if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) { return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); } effectcraft_pdf::sniff(bytes)?; Some( - effectcraft_pdf::parse_page(bytes, page as usize) + effectcraft_pdf::parse_page_framed(bytes, page as usize, framing) .map(|d| match layer { Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0, None => d, diff --git a/crates/host/tests/it/vector_import.rs b/crates/host/tests/it/vector_import.rs index badd60b66..eff5b4b4b 100644 --- a/crates/host/tests/it/vector_import.rs +++ b/crates/host/tests/it/vector_import.rs @@ -466,6 +466,11 @@ fn psd_smart_objects_with_perspective_and_warp_bake_as_placed() { /// A PDF with two placed images (one turned a quarter) between a background and a shape. fn image_pdf() -> Vec { + image_pdf_with("") +} + +/// [`image_pdf`] with `boxes` added to the page dictionary. +fn image_pdf_with(boxes: &str) -> Vec { let content = "0.9 0.9 0.9 rg 0 0 120 80 re f\nq 60 0 0 30 10 40 cm /Im Do Q\nq 0 40 -20 0 110 10 cm /Im Do Q\n0 0 1 rg 20 5 30 20 re f\n"; // 8×8 pixels in quadrants: red, green / white, black. let quad = |x: usize, y: usize| -> [u8; 3] { [[[255, 0, 0], [0, 255, 0]], [[255, 255, 255], [0, 0, 0]]][y / 4][x / 4] }; @@ -474,7 +479,7 @@ fn image_pdf() -> Vec { &[ (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), - (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>".into(), None), + (3, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] {boxes} /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>"), None), (4, "<< >>".into(), Some(content.as_bytes().to_vec())), (5, "<< /Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 >>".into(), Some(px)), ], @@ -546,6 +551,355 @@ fn retain_pdf(width: u32) -> Vec { ) } +/// A 200 × 100 pt page: red left half, blue right half. +fn halves_pdf() -> Vec { + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >>".into(), None), + (4, "<< >>".into(), Some(b"1 0 0 rg 0 0 100 100 re f 0 0 1 rg 100 0 100 100 re f".to_vec())), + ], + 1, + ) +} + +/// #3099: footage from an After Effects project ([`effectcraft_project::Footage::placed_page`]) +/// keeps the size After Effects stored for a placed .ai, which can differ from the page as read +/// here. The page fills that size at full and at reduced resolution alike: it was drawn at its own +/// aspect at full resolution and stretched to the stored size below 3/4 (the art jumped when the +/// viewer changed resolution, and the masks, stored against that size, came out too large at +/// full resolution). Two placed items of one file at different stored sizes each get their own +/// pixels, and neither reuses the file's frame at its own size, decoded first. +#[test] +fn placed_vector_footage_fills_its_stored_size_at_every_resolution() { + use effectcraft_project::ItemKind; + use effectcraft_time::Tick; + + let path = tmp("stored-size.ai"); + std::fs::write(&path, halves_pdf()).unwrap(); + let mut s = effectcraft_host::session(); + let item = ItemId(s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap()); + let red = |p: [f32; 4]| p[3] > 0.99 && p[0] > 0.9 && p[2] < 0.1; + let blue = |p: [f32; 4]| p[3] > 0.99 && p[2] > 0.9 && p[0] < 0.1; + // At the file's own size first (its frame goes to the pool's cache). + s.execute("comp.new", json!({"name": "Own", "width": 200, "height": 100, "frameRate": 25, "duration": 1})).unwrap(); + s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap(); + let own_comp = s.active_comp_id().unwrap(); + let own = s.render(own_comp, Tick::ZERO, RenderOpts::default()); + assert!(red(own.get(50, 90)) && blue(own.get(150, 90))); + // Two placed items of the file with the sizes an After Effects project stores (other aspects). + let proj = std::sync::Arc::make_mut(&mut s.project); + let ItemKind::Footage(f) = &mut proj.item_mut(item).unwrap().kind else { panic!("footage") }; + (f.width, f.height, f.placed_page) = (100, 80, true); + let tall = effectcraft_project::Footage { width: 60, height: 120, ..f.clone() }; + let tall = proj.add_item("tall", Default::default(), None, ItemKind::Footage(tall)); + for (item, (w, h)) in [(item, (100u32, 80u32)), (tall, (60, 120))] { + s.execute("comp.new", json!({"name": format!("Stored {w}"), "width": w, "height": h, "frameRate": 25, "duration": 1})).unwrap(); + let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + let (l, r, top, bottom) = (i64::from(w / 4), i64::from(w * 3 / 4), 3, i64::from(h) - 4); + for collapse in [false, true] { + s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": collapse})).unwrap(); + let full = s.render(cid, Tick::ZERO, RenderOpts::default()); + assert_eq!((full.width, full.height), (w, h)); + for y in [top, bottom] { + let (a, b) = (full.get(l, y), full.get(r, y)); + assert!(red(a) && blue(b), "{w}×{h}, collapse {collapse}, full resolution, row {y}: {a:?} {b:?}"); + } + let half = s.render(cid, Tick::ZERO, RenderOpts { scale: 0.5, ..RenderOpts::default() }); + assert_eq!((half.width, half.height), (w / 2, h / 2)); + for y in [top / 2, bottom / 2] { + let (a, b) = (half.get(l / 2, y), half.get(r / 2, y)); + assert!(red(a) && blue(b), "{w}×{h}, collapse {collapse}, half resolution, row {y}: {a:?} {b:?}"); + } + } + // Create Shapes from Vector Layer draws the shapes where the footage is drawn. + s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "video", "value": false})).unwrap(); + let shapes = s.render(cid, Tick::ZERO, RenderOpts::default()); + for y in [top, bottom] { + let (a, b) = (shapes.get(l, y), shapes.get(r, y)); + assert!(red(a) && blue(b), "{w}×{h}, shapes, row {y}: {a:?} {b:?}"); + } + } +} + +/// #3099 review: Create Shapes from Vector Layer on a placed page keeps the page's frame. The +/// footage's raster shows only the ArtBox (241 × 121) of `art_box_ai()`, whose grey media box +/// reaches past it on every side; in a composition larger than the footage, the outlines show +/// the same colours inside the frame and nothing (the background) outside it. Without the clip +/// they showed the whole page: the grey background around the art. +#[test] +fn shapes_from_a_placed_page_stay_inside_its_frame() { + use effectcraft_project::ItemKind; + use effectcraft_time::Tick; + + let path = tmp("placed-frame.ai"); + std::fs::write(&path, art_box_ai()).unwrap(); + let mut s = effectcraft_host::session(); + let item = ItemId(s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap()); + // As an After Effects project stores it: the ArtBox's size, placed. + let ItemKind::Footage(f) = &mut std::sync::Arc::make_mut(&mut s.project).item_mut(item).unwrap().kind else { panic!("footage") }; + (f.width, f.height, f.placed_page) = (241, 121, true); + s.execute("comp.new", json!({"name": "Big", "width": 400, "height": 300, "frameRate": 25, "duration": 1})).unwrap(); + let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + // The layer is centred: layer pixel (x, y) is comp (x + 79.5, y + 89.5). + let at = |img: &Image, x: f64, y: f64| img.get((x + 79.5).floor() as i64, (y + 89.5).floor() as i64); + let red = |p: [f32; 4]| p[3] > 0.99 && p[0] > 0.9 && p[1] < 0.1 && p[2] < 0.1; + let blue = |p: [f32; 4]| p[3] > 0.99 && p[2] > 0.9 && p[0] < 0.1 && p[1] < 0.1; + let empty = |p: [f32; 4]| p[0] < 0.02 && p[1] < 0.02 && p[2] < 0.02; + let inside = [([20.0, 20.0], true), ([110.0, 100.0], true), ([130.0, 20.0], false), ([230.0, 110.0], false)]; + // Outside the frame, where the page's grey media box would show: left, right, above, below. + let outside = [[-20.0, 60.0], [-55.0, 100.0], [260.0, 60.0], [300.0, 10.0], [120.0, -20.0], [120.0, 140.0]]; + let check = |img: &Image, what: &str| { + for (p, want_red) in inside { + let px = at(img, p[0], p[1]); + assert!(if want_red { red(px) } else { blue(px) }, "{what}: inside the frame at {p:?}: {px:?}"); + } + for p in outside { + let px = at(img, p[0], p[1]); + assert!(empty(px), "{what}: outside the frame at {p:?}: {px:?}"); + } + }; + check(&s.render(cid, Tick::ZERO, RenderOpts::default()), "footage"); + s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).unwrap(); + let comp = s.project.comp(cid).unwrap(); + assert!(!comp.layers.iter().find(|l| l.id.0 == lid).unwrap().switches.video, "the source is switched off"); + check(&s.render(cid, Tick::ZERO, RenderOpts::default()), "shapes"); +} + +/// #3099 review: the images of a placed page become footage layers clipped to the frame too. +/// `image_pdf_with` an ArtBox [20 15 100 70]: the upright image reaches past the frame's left +/// edge, the turned one past its right and bottom edges, the grey page around it. After the +/// conversion the layers inside the frame match the footage, and outside it nothing shows. +#[test] +fn images_from_a_placed_page_stay_inside_its_frame() { + use effectcraft_project::{ItemKind, MatteKind, TrackMatte}; + use effectcraft_time::Tick; + + let path = tmp("placed-images.pdf"); + std::fs::write(&path, image_pdf_with("/ArtBox [20 15 100 70]")).unwrap(); + let mut s = effectcraft_host::session(); + let item = ItemId(s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap()); + let ItemKind::Footage(f) = &mut std::sync::Arc::make_mut(&mut s.project).item_mut(item).unwrap().kind else { panic!("footage") }; + (f.width, f.height, f.placed_page) = (80, 55, true); + s.execute("comp.new", json!({"name": "Big", "width": 200, "height": 150, "frameRate": 25, "duration": 1})).unwrap(); + let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + // Centred: layer pixel (x, y) is comp (x + 60, y + 47.5). + let at = |img: &Image, x: f64, y: f64| img.get((x + 60.0).floor() as i64, (y + 47.5).floor() as i64); + let empty = |p: [f32; 4]| p[0] < 0.02 && p[1] < 0.02 && p[2] < 0.02; + // Inside: both images and the grey page; outside: the images past the frame, the grey page. + let inside = [[10.0, 8.0], [40.0, 8.0], [10.0, 22.0], [40.0, 22.0], [75.0, 25.0], [75.0, 50.0], [5.0, 40.0], [60.0, 10.0]]; + let outside = [[-5.0, 15.0], [85.0, 40.0], [75.0, 58.0], [-15.0, 50.0], [40.0, -5.0]]; + let footage = s.render(cid, Tick::ZERO, RenderOpts::default()); + for p in outside { + assert!(empty(at(&footage, p[0], p[1])), "footage outside the frame at {p:?}: {:?}", at(&footage, p[0], p[1])); + } + let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{r}"); + // One hidden frame layer is the Alpha track matte of every layer made from the page. + let comp = s.project.comp(cid).unwrap(); + let frame = comp.layers.iter().find(|l| l.name.ends_with(" Frame")).expect("the frame matte layer"); + assert!(!frame.switches.video); + let made: Vec<_> = comp.layers.iter().filter(|l| l.id != frame.id && l.id.0 != lid).collect(); + assert_eq!(made.iter().filter(|l| l.name == "Image").count(), 2); + for l in &made { + assert_eq!(l.track_matte, Some(TrackMatte { layer: frame.id, kind: MatteKind::Alpha }), "{}", l.name); + } + let shapes = s.render(cid, Tick::ZERO, RenderOpts::default()); + for p in inside { + let (a, b) = (at(&shapes, p[0], p[1]), at(&footage, p[0], p[1])); + assert!((0..4).all(|k| (a[k] - b[k]).abs() < 0.05), "inside the frame at {p:?}: {a:?} vs footage {b:?}"); + } + for p in outside { + assert!(empty(at(&shapes, p[0], p[1])), "shapes outside the frame at {p:?}: {:?}", at(&shapes, p[0], p[1])); + } +} + +/// A PDF with an ArtBox, imported here (not from an After Effects project), and a project saved +/// with it before #3099: shown as a PDF viewer frames it (the crop box, 410 × 210) at its own +/// size, exactly as before; the project file has no new field. +#[test] +fn a_pdf_with_an_art_box_imported_here_keeps_the_viewer_framing() { + use effectcraft_project::{ItemKind, Project}; + use effectcraft_time::Tick; + + let bytes = art_box_ai(); + let path = tmp("viewer-art-box.pdf"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + let item = ItemId(s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap()); + let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") }; + assert_eq!((f.width, f.height, f.placed_page), (410, 210, false)); + s.execute("comp.new", json!({"name": "V", "width": 410, "height": 210, "frameRate": 25, "duration": 1})).unwrap(); + s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let direct = effectcraft_svg::rasterize(&effectcraft_pdf::parse(&bytes).unwrap(), 410, 210, 1.0); + assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 1.0 / 255.0); + let saved = s.project.to_json(); + assert!(!saved.contains("placed_page"), "the saved project is in the earlier format"); + s.project = std::sync::Arc::new(Project::from_json(&saved).unwrap()); + assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 1.0 / 255.0, "reopened"); +} + +/// `i3099-a-boxes.ai` of `effectcraft-aep`'s `ai-mask-3099.aep` (its boxes as in +/// `tests/fixtures/tools/ai-mask-3099.py`): MediaBox 420 × 220, CropBox, BleedBox and TrimBox +/// inside it, ArtBox [60 50 300.6 170.4]. Here grey over the media box, the art box red on its +/// left half and blue on its right half. +fn art_box_ai() -> Vec { + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 220] /CropBox [5 5 415 215] /BleedBox [10 10 410 210] /TrimBox [20 20 400 200] /ArtBox [60 50 300.6 170.4] /Contents 4 0 R >>"; + let content = b"0.5 g 0 0 420 220 re f 1 0 0 rg 60 50 120.3 120.4 re f 0 0 1 rg 180.3 50 120.3 120.4 re f"; + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, page.into(), None), + (4, "<< >>".into(), Some(content.to_vec())), + ], + 1, + ) +} + +/// `i3099-d-trim.ai` of `ai-mask-3099.aep`: MediaBox 418 × 218, TrimBox, ArtBox [40 30 250 150] +/// (no CropBox). Grey over the media box, the art box red on its left half, blue on its right. +fn trim_ai() -> Vec { + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 418 218] /TrimBox [9 9 409 209] /BleedBox [0 0 418 218] /ArtBox [40 30 250 150] /Contents 4 0 R >>"; + let content = b"0.5 g 0 0 418 218 re f 1 0 0 rg 40 30 105 120 re f 0 0 1 rg 145 30 105 120 re f"; + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, page.into(), None), + (4, "<< >>".into(), Some(content.to_vec())), + ], + 1, + ) +} + +/// `i3099-c-rot.ai` of `ai-mask-3099.aep`: MediaBox [100 50 400 150] with `/Rotate 90`, grey, +/// with a blue 20 × 10 marker 4 pt in from the media box's lower-left corner. +fn rotated_ai() -> Vec { + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [100 50 400 150] /Rotate 90 /Contents 4 0 R >>".into(), None), + (4, "<< >>".into(), Some(b"0.5 g 100 50 300 100 re f 0 0 1 rg 104 54 20 10 re f".to_vec())), + ], + 1, + ) +} + +/// #3099 end to end, against After Effects 2026's own read-back of the committed project +/// `ai-mask-3099.aep`: a placed .ai (ArtBox inside a larger crop box) parented to a Null parented +/// to a Null, with two rectangular masks. After Effects sizes the footage 241 × 121 (the ArtBox) +/// and puts the layer's corners at the comp points below (`toComp`). The art fills the layer +/// between the masks at full and at half resolution; it was drawn from the crop box (410 × 210) +/// scaled to the layer's width, so the masks cut the wrong part of it and half resolution +/// squeezed it, and the Null left at its default anchor moved it. `artC` (`/Rotate 90`) is drawn +/// unturned as After Effects draws it, `artD` (Continuously Rasterize) as `art`. +#[test] +fn aep_ai_layer_with_masks_under_two_nulls_renders_like_after_effects() { + use effectcraft_engine::render::EvalCtx; + use effectcraft_project::ItemKind; + use effectcraft_time::Tick; + + let dir = std::path::PathBuf::from(tmp("ai-mask-3099")); + std::fs::create_dir_all(&dir).unwrap(); + let aep = dir.join("ai-mask-3099.aep"); + // The fixture belongs to `effectcraft-aep` (its sidecar and generator are there); this test + // renders it through the wired session, which that crate cannot. + std::fs::write(&aep, include_bytes!("../../../aep/tests/fixtures/ai-mask-3099.aep")).unwrap(); + let mut s = effectcraft_host::session(); + s.execute_checked("file.importAep", json!({"path": aep.to_string_lossy(), "allowLosses": true})).unwrap(); + // The project's footage paths are neutral (`C:\fixture\x\...`): the files are linked here. + for (name, bytes, size) in + [("i3099-a-boxes.ai", art_box_ai(), (241, 121)), ("i3099-c-rot.ai", rotated_ai(), (300, 100)), ("i3099-d-trim.ai", trim_ai(), (210, 120))] + { + let file = dir.join(name); + std::fs::write(&file, bytes).unwrap(); + let item = s + .project + .items + .values() + .find(|i| matches!(&i.kind, ItemKind::Footage(f) if f.path.ends_with(name))) + .map(|i| i.id) + .unwrap_or_else(|| panic!("footage {name}")); + let ItemKind::Footage(f) = &mut std::sync::Arc::make_mut(&mut s.project).item_mut(item).unwrap().kind else { panic!("footage") }; + assert_eq!((f.width, f.height, f.placed_page), (size.0, size.1, true), "{name}: After Effects' size of the placed .ai"); + (f.path, f.missing) = (file.to_string_lossy().into_owned(), false); + } + let cid = s.project.items.values().find(|i| i.name == "ai-mask").map(|i| i.id).expect("comp ai-mask"); + + // After Effects' toComp of the art layer's points (layer pixels → comp pixels). + let ae = [ + ([0.0, 0.0], [634.193413913741, 195.085717365354]), + ([241.0, 0.0], [913.539162876539, 269.936185209003]), + ([0.0, 121.0], [577.822625890412, 405.464362331113]), + ([40.0, 30.0], [666.58162514008, 259.669026149884]), + ([350.0, 160.0], [965.342375965623, 581.976354279664]), + ]; + let comp = s.project.comp(cid).unwrap(); + let art = comp.layer_by_name("art").unwrap(); + let (m, _) = EvalCtx::new(&s.project, cid, comp, Tick::ZERO).layer_to_comp(art); + let to_comp = |[u, v]: [f64; 2]| [m.0[0][0] * u + m.0[0][1] * v + m.0[0][2], m.0[1][0] * u + m.0[1][1] * v + m.0[1][2]]; + for (p, want) in ae { + let got = to_comp(p); + assert!((got[0] - want[0]).abs() < 1e-3 && (got[1] - want[1]).abs() < 1e-3, "{p:?}: {got:?} vs After Effects {want:?}"); + } + + // Inside the masks (M1: x 40..200, y 30..90; M2: x 220..350, y 100..160, cut by the layer). + let red = |p: [f32; 4]| p[0] > 0.9 && p[1] < 0.1 && p[2] < 0.1; + let blue = |p: [f32; 4]| p[2] > 0.9 && p[0] < 0.1 && p[1] < 0.1; + let full = s.render(cid, Tick::ZERO, RenderOpts::default()); + let half = s.render(cid, Tick::ZERO, RenderOpts { scale: 0.5, ..RenderOpts::default() }); + for (p, want_red) in [([60.0, 45.0], true), ([100.0, 75.0], true), ([150.0, 40.0], false), ([190.0, 80.0], false), ([232.0, 112.0], false)] { + let [x, y] = to_comp(p); + let at = |img: &Image, k: f64| img.get((x * k).round() as i64, (y * k).round() as i64); + for (img, k, what) in [(&full, 1.0, "full"), (&half, 0.5, "half")] { + let px = at(img, k); + let ok = if want_red { red(px) } else { blue(px) }; + assert!(ok, "{what} resolution, layer point {p:?}: {px:?} (want {})", if want_red { "red" } else { "blue" }); + } + } + // Outside the masks the art is cut away: the black background. + for p in [[100.0, 110.0], [20.0, 60.0]] { + let [x, y] = to_comp(p); + let px = full.get(x.round() as i64, y.round() as i64); + assert!(px[0] < 0.05 && px[1] < 0.05 && px[2] < 0.05, "layer point {p:?}: {px:?}"); + } + // artC (/Rotate 90, position (1500, 800), anchor (150, 50)): After Effects draws the page + // unturned, 300 × 100, the marker at its lower left (comp 1354..1374, 836..846). + for (img, k, what) in [(&full, 1.0, "full"), (&half, 0.5, "half")] { + let at = |x: f64, y: f64| img.get((x * k).round() as i64, (y * k).round() as i64); + let marker = at(1364.0, 841.0); + assert!(blue(marker), "{what} resolution, artC marker: {marker:?}"); + for (x, y) in [(1450.0, 780.0), (1630.0, 790.0), (1364.0, 770.0)] { + let grey = at(x, y); + assert!(grey[3] > 0.99 && (grey[0] - grey[2]).abs() < 0.02 && grey[0] > 0.1 && grey[0] < 0.9, "{what} resolution, artC at ({x}, {y}): {grey:?}"); + } + } + for (x, y) in [(1340.0, 800.0), (1500.0, 870.0)] { + let px = full.get(x as i64, y as i64); + assert!(px[0] < 0.05 && px[2] < 0.05, "outside artC ({x}, {y}): {px:?}"); + } + // artD (ArtBox 210 × 120, Continuously Rasterize, the same masks, also under N2): placed by + // After Effects' toComp of its corners. + let (tl, tr, bl) = ([883.191424310055, 728.610562914909], [1126.6047325349, 793.832962280744], [827.286510567911, 937.250541393348]); + let d_to_comp = |[u, v]: [f64; 2]| [0, 1].map(|i| tl[i] + u / 210.0 * (tr[i] - tl[i]) + v / 120.0 * (bl[i] - tl[i])); + for (p, want_red) in [([60.0, 45.0], true), ([90.0, 75.0], true), ([150.0, 40.0], false), ([190.0, 80.0], false)] { + let [x, y] = d_to_comp(p); + for (img, k, what) in [(&full, 1.0, "full"), (&half, 0.5, "half")] { + let px = img.get((x * k).round() as i64, (y * k).round() as i64); + let ok = if want_red { red(px) } else { blue(px) }; + assert!(ok, "artD, {what} resolution, layer point {p:?}: {px:?} (want {})", if want_red { "red" } else { "blue" }); + } + } +} + #[test] fn ai_composition_retains_layer_sizes_and_positions() { use effectcraft_project::{ItemKind, LayerSource, Project}; diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index b259c8b7f..64ade9394 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -14,19 +14,20 @@ fn is_svg(path: &str, bytes: &[u8]) -> bool { } /// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page -/// `page` of a PDF, restricted to the footage's layer when it names one. +/// `page` of a PDF framed as `framing` says, restricted to the footage's layer when it names one. pub fn vector_doc( path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, + framing: effectcraft_pdf::Framing, ) -> Option> { if is_svg(path, bytes) { return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); } effectcraft_pdf::sniff(bytes)?; Some( - effectcraft_pdf::parse_page(bytes, page as usize) + effectcraft_pdf::parse_page_framed(bytes, page as usize, framing) .map(|d| match layer { Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0, None => d, @@ -111,14 +112,14 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); } if is_svg(path, bytes) || is_pdf_like(path, bytes) { - let doc = vector_doc(path, bytes, footage.layer.as_ref(), footage.page).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?; - let (w, h) = doc.pixel_size(); + let doc = footage_doc(path, bytes, footage).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?; + let ((w, h), to_image) = fit(&doc, footage, 1.0); // A declared size up to 30000² would be a 14 GB allocation that aborts the app (#2136): // past what Continuously Rasterize can draw, the footage reports an error instead. if u64::from(w) * u64::from(h) > u64::from(MAX_VECTOR_SIDE) * u64::from(MAX_VECTOR_SIDE) { return Err(MediaError::Decode(format!("{path}: {w}×{h} pixels is larger than vector footage can be drawn ({MAX_VECTOR_SIDE}×{MAX_VECTOR_SIDE})"))); } - let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); + let img = effectcraft_svg::rasterize_with(&doc, w, h, to_image); // The rasteriser produces premultiplied pixels already. let _ = op; return Ok(Some(img)); @@ -129,12 +130,29 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - /// The largest side vector footage is drawn at, decoded or continuously rasterised. const MAX_VECTOR_SIDE: u32 = 16384; -/// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize). -pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, scale: f64) -> Option { - let doc = vector_doc(path, bytes, layer, page)?.ok()?; - let (w, h) = doc.pixel_size(); - let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE), ((h as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE)); - Some(effectcraft_svg::rasterize(&doc, sw, sh, scale)) +/// The vector footage `footage` (of the file `path` with these `bytes`) rasterised at `scale` × +/// its size as [`effectcraft_render::vector_fit`] draws it (Continuously Rasterize). +pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], footage: &Footage, scale: f64) -> Option { + let doc = footage_doc(path, bytes, footage)?.ok()?; + let ((sw, sh), to_image) = fit(&doc, footage, scale); + Some(effectcraft_svg::rasterize_with(&doc, sw.clamp(1, MAX_VECTOR_SIDE), sh.clamp(1, MAX_VECTOR_SIDE), to_image)) +} + +/// The page framing of vector footage: as After Effects places it for footage from an After +/// Effects project ([`Footage::placed_page`]), as a PDF viewer shows it otherwise. +fn framing(footage: &Footage) -> effectcraft_pdf::Framing { + if footage.placed_page { effectcraft_pdf::Framing::Placed } else { effectcraft_pdf::Framing::Viewer } +} + +/// [`vector_doc`] of `footage`'s page, layer and framing. +fn footage_doc(path: &str, bytes: &[u8], footage: &Footage) -> Option> { + vector_doc(path, bytes, footage.layer.as_ref(), footage.page, framing(footage)) +} + +/// The image size and document → image mapping of `doc` drawn for `footage` at `scale`. +fn fit(doc: &effectcraft_svg::Doc, footage: &Footage, scale: f64) -> ((u32, u32), effectcraft_svg::Affine) { + let (size, [sx, sy]) = effectcraft_render::vector_fit(footage, [doc.width, doc.height], doc.pixel_size(), scale); + (size, effectcraft_svg::Affine::scale_non_uniform(sx, sy)) } #[cfg(test)] @@ -154,4 +172,65 @@ mod tests { let img = decode("small.svg", small, &footage, AlphaOp::new(footage.alpha, footage.premul_color)).unwrap().unwrap(); assert_eq!((img.width, img.height), (40, 20)); } + + /// A 200 × 100 pt page: red left half, blue right half. + fn halves_pdf() -> Vec { + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >>".into(), None), + (4, "<< >>".into(), Some(b"1 0 0 rg 0 0 100 100 re f 0 0 1 rg 100 0 100 100 re f".to_vec())), + ], + 1, + ) + } + + /// An opaque red pixel. + fn is_red(p: [f32; 4]) -> bool { + p[3] > 0.99 && p[0] > 0.9 && p[2] < 0.1 + } + + /// An opaque blue pixel. + fn is_blue(p: [f32; 4]) -> bool { + p[3] > 0.99 && p[2] > 0.9 && p[0] < 0.1 + } + + /// #3099: an After Effects project keeps the size After Effects measured for a placed .ai or + /// PDF, which can differ from this reader's page size. A placed page fills that size (the + /// layer's anchor and masks are measured against it), decoded and continuously rasterised; + /// other footage is drawn at the page's own size as before, whatever size it says. + #[test] + fn placed_vector_footage_fills_the_footage_size() { + let pdf = halves_pdf(); + let mut footage = probe("art.ai", &pdf).unwrap().unwrap(); + assert_eq!((footage.width, footage.height), (200, 100)); + assert!(!footage.placed_page); + let op = AlphaOp::new(footage.alpha, footage.premul_color); + let own = decode("art.ai", &pdf, &footage, op).unwrap().unwrap(); + assert_eq!((own.width, own.height), (200, 100)); + assert!(is_red(own.get(50, 50)) && is_blue(own.get(150, 50))); + + (footage.width, footage.height) = (100, 80); + let unplaced = decode("art.ai", &pdf, &footage, op).unwrap().unwrap(); + assert!(unplaced == own, "not placed: drawn as before"); + assert_eq!(rasterize_vector("art.ai", &pdf, &footage, 0.5).map(|i| (i.width, i.height)), Some((100, 50))); + + footage.placed_page = true; + let img = decode("art.ai", &pdf, &footage, op).unwrap().unwrap(); + assert_eq!((img.width, img.height), (100, 80)); + for (x, y) in [(25, 3), (25, 76)] { + assert!(is_red(img.get(x, y)), "red at ({x}, {y}): {:?}", img.get(x, y)); + } + for (x, y) in [(75, 3), (75, 76)] { + assert!(is_blue(img.get(x, y)), "blue at ({x}, {y}): {:?}", img.get(x, y)); + } + + let sharp = rasterize_vector("art.ai", &pdf, &footage, 2.0).unwrap(); + assert_eq!((sharp.width, sharp.height), (200, 160)); + assert!(is_red(sharp.get(50, 155)) && is_blue(sharp.get(150, 155))); + // Placed at the page's own size: 1:1. + (footage.width, footage.height) = (200, 100); + assert!(decode("art.ai", &pdf, &footage, op).unwrap().unwrap() == own, "placed at its own size: 1:1"); + } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index a60048b66..cd6676220 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -42,14 +42,16 @@ fn colour_bars(w: u32, h: u32) -> Image { struct Key { path: Arc, frame: i64, - /// The alpha interpretation (bits 0–1) and how float values are encoded (bits 2–3: sRGB, - /// as stored, a 2.4 power curve): they change the decoded pixels. + /// The alpha interpretation (bits 0–1), how float values are encoded (bits 2–3: sRGB, as + /// stored, a 2.4 power curve) and a placed PDF / Illustrator page's framing (bit 4, + /// [`Footage::placed_page`]): they change the decoded pixels. interp: u8, matte: [u32; 3], /// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded). size: (u32, u32), /// A still at a reduced `size`: the footage's nominal size, which decides whether it is - /// resampled (see `Inner::still_at_size`); (0, 0) otherwise. + /// 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), } @@ -756,6 +758,8 @@ impl Inner { ((d.0 as i128 * rate.num as i128 + den / 2) / den) as i64 } + /// Where the frame of `footage` at `t` comes from (file, frame or media time) and its cache + /// key, at the footage's own size. fn locate(footage: &Footage, t: Tick, float: FloatCurve) -> Loc { let alpha = match footage.alpha { AlphaMode::Straight => 0, @@ -768,7 +772,8 @@ impl Inner { FloatCurve::Srgb => 0, FloatCurve::Linear => 1, FloatCurve::Gamma24 => 2, - } << 2); + } << 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) }; match footage.kind { @@ -818,6 +823,11 @@ impl Inner { loc.key.nominal = (footage.width, footage.height); } } + // A placed PDF / Illustrator page is drawn to fill the footage's size (`layered::decode`, + // #3099): two items of one file stored at different sizes have different pixels. + if footage.placed_page && !movie { + loc.key.nominal = (footage.width, footage.height); + } let mut c = lock(&self.cache); // sequential playback of a movie: read the next frame ahead if playback && movie { @@ -1041,7 +1051,7 @@ impl FootageSource for MediaPool { return None; } let bytes = self.inner.read(&footage.path).ok()?; - crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), footage.page, scale).map(Arc::new) + crate::layered::rasterize_vector(&footage.path, &bytes, footage, scale).map(Arc::new) } fn set_aux_footage(&self, paths: &[String]) { diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 20f655611..eb960cc6e 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -29,7 +29,13 @@ ffmpeg elsewhere) to check the code tables against an independent implementation the app's existing object model. References retain object and generation numbers. A bounded compatibility repair recovers referenced objects from object streams when damaged xrefs omit them; every recovery or unresolved reference is reported in `Doc::skipped` (up to 64 messages). - The existing page tree preserves inherited resources, MediaBox / CropBox and `/Rotate`. + The existing page tree preserves inherited resources, MediaBox / CropBox and `/Rotate`. A page + is its CropBox (else MediaBox), turned by `/Rotate` (`Framing::Viewer`, the default). Footage + from an After Effects project is framed as After Effects places a PDF or `.ai` + (`Framing::Placed`, `parse_page_framed`): the page's own ArtBox clipped to the CropBox (else + MediaBox) when it has one, else the CropBox, else the MediaBox, with `/Rotate` ignored + (measured in After Effects 2026 with own files, #3099). Not measured: a CropBox without an + ArtBox, files saved by Illustrator itself, `.pdf` names. Content/image filters remain app-owned: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85, RunLength and CCITTFax. Encrypted files remain refused; `page_count` returns zero when the reader refuses a document. diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 567dca9c0..ff914e759 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -92,8 +92,25 @@ pub fn parse(bytes: &[u8]) -> Result { /// Parse page `index` of a PDF (EPS files have one page). pub fn parse_page(bytes: &[u8], index: usize) -> Result { + parse_page_framed(bytes, index, Framing::Viewer) +} + +/// Which region of a PDF page becomes the document, and whether `/Rotate` turns it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Framing { + /// As a PDF viewer shows the page: its CropBox (else MediaBox), turned by `/Rotate`. + #[default] + Viewer, + /// As After Effects places a PDF or `.ai` as footage: the page's ArtBox clipped to the + /// CropBox (else MediaBox) when it has one, else the CropBox, else the MediaBox, with + /// `/Rotate` ignored (measured in After Effects 2026 with own files, #3099). + Placed, +} + +/// [`parse_page`] with the page framed as `framing` says (EPS files are their bounding box). +pub fn parse_page_framed(bytes: &[u8], index: usize, framing: Framing) -> Result { match sniff(bytes) { - Some(Format::Pdf) => parse_pdf(bytes, index), + Some(Format::Pdf) => parse_pdf(bytes, index, framing), Some(Format::Eps) => parse_eps(bytes), None => Err(Error::NotVector), } @@ -142,17 +159,22 @@ fn finish(nodes: Vec, flags: Vec, skipped: Vec, width: f64, Doc { width, height, root, skipped } } -fn parse_pdf(bytes: &[u8], index: usize) -> Result { +/// Page `index` of a PDF framed as `framing` says, as a document in points (y down). +fn parse_pdf(bytes: &[u8], index: usize, framing: Framing) -> Result { let file = object::File::parse(bytes)?; let pages = page::pages(&file); if pages.is_empty() { return Err(Error::NoPages); } let pg = pages.get(index).ok_or(Error::PageOutOfRange(index))?; - let [x0, y0, x1, y1] = pg.bbox; + let (frame, rotate) = match framing { + Framing::Viewer => (pg.bbox, pg.rotate), + Framing::Placed => (pg.art.unwrap_or(pg.bbox), 0), + }; + let [x0, y0, x1, y1] = frame; let (w, h) = (x1 - x0, y1 - y0); // Default user space (y up) → document pixels (y down), with the page's /Rotate. - let (to_doc, dw, dh) = match pg.rotate { + let (to_doc, dw, dh) = match rotate { 90 => (Affine::new([0.0, 1.0, 1.0, 0.0, -y0, -x0]), h, w), 180 => (Affine::new([-1.0, 0.0, 0.0, 1.0, x1, -y0]), w, h), 270 => (Affine::new([0.0, -1.0, -1.0, 0.0, y1, x1]), h, w), diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index b7eb70fe0..0ebc0421d 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -23,6 +23,9 @@ pub struct Page { pub resources: Dict, /// CropBox (else MediaBox) `[x0, y0, x1, y1]`. pub bbox: [f64; 4], + /// The page's own ArtBox clipped to [`Page::bbox`] (ISO 32000-1 §14.11.2), when it has one + /// that keeps an area there; not inherited (table 30). + pub art: Option<[f64; 4]>, pub rotate: i32, } @@ -31,13 +34,15 @@ pub fn pages(file: &File) -> Vec { let mut out = vec![]; let Some(cat) = file.catalog() else { return out }; let Some(root) = file.get_dict(cat, "Pages") else { return out }; + /// Collects the pages under `node`, passing on the attributes pages inherit (resources, + /// MediaBox, CropBox, /Rotate); bounded in depth and page count. fn walk(file: &File, node: &Dict, inherited: (Option, Option>, Option>, i32), out: &mut Vec, depth: usize) { if depth > 32 || out.len() > 10_000 { return; } let res = file.get_dict(node, "Resources").cloned().or(inherited.0); - let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.1); - let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.2); + let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| is_area(v)).or(inherited.1); + let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| is_area(v)).or(inherited.2); let rot = file.get_num(node, "Rotate").map(|r| r as i32).unwrap_or(inherited.3); match file.get(node, "Kids").and_then(Obj::array) { Some(kids) if file.get(node, "Type").and_then(Obj::name) != Some("Page") => { @@ -49,8 +54,16 @@ pub fn pages(file: &File) -> Vec { } _ => { let b = crop.or(media).unwrap_or_else(|| vec![0.0, 0.0, 612.0, 792.0]); - let bbox = [b[0].min(b[2]), b[1].min(b[3]), b[0].max(b[2]), b[1].max(b[3])]; - out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, rotate: rot.rem_euclid(360) }); + let bbox = normalized(&b).unwrap_or([0.0, 0.0, 612.0, 792.0]); + // The ArtBox is the page's own (not inherited), clipped to the crop box. + let art = file + .get(node, "ArtBox") + .map(|m| file.nums(m)) + .filter(|v| is_area(v)) + .and_then(|v| normalized(&v)) + .map(|a| [a[0].max(bbox[0]), a[1].max(bbox[1]), a[2].min(bbox[2]), a[3].min(bbox[3])]) + .filter(|a| a[2] > a[0] && a[3] > a[1]); + out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, art, rotate: rot.rem_euclid(360) }); } } } @@ -58,6 +71,26 @@ pub fn pages(file: &File) -> Vec { out } +/// A box `[x0, y0, x1, y1]` (corners in any order) with finite corners and a finite span that +/// encloses an area. +fn is_area(v: &[f64]) -> bool { + match v { + [x0, y0, x1, y1] => { + let (w, h) = (x1 - x0, y1 - y0); + v.iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite() && w.abs() > 0.0 && h.abs() > 0.0 + } + _ => false, + } +} + +/// A box with its corners ordered (`x0 < x1`, `y0 < y1`), `None` unless [`is_area`]. +fn normalized(v: &[f64]) -> Option<[f64; 4]> { + match v { + [x0, y0, x1, y1] if is_area(v) => Some([x0.min(*x1), y0.min(*y1), x0.max(*x1), y0.max(*y1)]), + _ => None, + } +} + /// The page's content streams, decoded and joined. pub fn page_content(file: &File, page: &Page) -> Vec { let mut out = vec![]; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2d09a7406..cd3eecdb2 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -112,6 +112,86 @@ fn pdf_forms_rotation_cmyk_and_alpha() { assert_eq!(img.get(30, 30)[3], 0.0, "form clipped to its bounding box"); } +/// A one-page PDF with a MediaBox [0 0 420 220], `pages` added to the /Pages node and `page` to +/// the page: grey over the media box, a blue 20 × 10 marker at PDF (64, 54). +fn boxes_pdf(pages: &str, page: &str) -> Vec { + let content = "0.5 g 0 0 420 220 re f 0 0 1 rg 64 54 20 10 re f"; + pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None), + (2, format!("<< /Type /Pages /Kids [3 0 R] /Count 1 {pages} >>"), None), + (3, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 220] {page} /Contents 4 0 R >>"), None), + (4, "<< >>".to_string(), Some(content.as_bytes().to_vec())), + ], + 1, + ) +} + +/// The first page of `bytes` framed as After Effects places it. +fn placed(bytes: &[u8]) -> Doc { + parse_page_framed(bytes, 0, Framing::Placed).unwrap() +} + +/// #3099: After Effects 2026 places a .ai or PDF as footage by the page's ArtBox and shows only +/// that region (a page with MediaBox 420 × 220, CropBox 410 × 210, TrimBox 380 × 180 and ArtBox +/// [60 50 300.6 170.4] imports as 241 × 121). `Framing::Placed` frames it so; the viewer framing +/// (the default, used by File ▸ Import) stays the CropBox. +#[test] +fn placed_framing_is_the_art_box() { + let bytes = boxes_pdf("/CropBox [5 5 415 215]", "/TrimBox [20 20 400 200] /ArtBox [60 50 300.6 170.4]"); + let doc = placed(&bytes); + assert!((doc.width - 240.6).abs() < 1e-9 && (doc.height - 120.4).abs() < 1e-9, "{} × {}", doc.width, doc.height); + assert_eq!(doc.pixel_size(), (241, 121)); + // The art box's top-left corner is the document's origin: the marker (PDF x 64..84, y 54..64) + // is at x 4..24, y 106.4..116.4 from the top. + let (w, h) = doc.pixel_size(); + let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); + let marker = img.get(10, 110); + assert!(marker[2] > 0.99 && marker[0] < 0.01, "{marker:?}"); + assert!(img.get(30, 110)[2] < 0.6, "{:?}", img.get(30, 110)); + let viewer = parse(&bytes).unwrap(); + assert_eq!((viewer.width, viewer.height), (410.0, 210.0), "the viewer framing is unchanged"); +} + +/// The ArtBox is clipped to the crop box (ISO 32000-1 §14.11.2); one that keeps no area there, +/// a degenerate or unreadable one, or one on the /Pages node (not inherited, table 30) leaves the +/// crop box (else the media box). +#[test] +fn placed_framing_clips_the_art_box_and_falls_back() { + let size = |pages: &str, page: &str| { + let d = placed(&boxes_pdf(pages, page)); + (d.width, d.height) + }; + let crop = "/CropBox [5 5 415 215]"; + assert_eq!(size(crop, "/ArtBox [-100 -100 1000 1000]"), (410.0, 210.0), "larger than the page"); + assert_eq!(size(crop, "/ArtBox [300 100 900 900]"), (115.0, 115.0), "partly outside"); + let (w, h) = size(crop, "/ArtBox [300.6 170.4 60 50]"); + assert!((w - 240.6).abs() < 1e-9 && (h - 120.4).abs() < 1e-9, "corners in any order: {w} × {h}"); + // Finite corners whose span is not. + let big = format!("1{}", "0".repeat(308)); + let unreadable = [format!("/ArtBox [-{big} 50 {big} 170]")]; + for page in ["/ArtBox [500 300 600 400]", "/ArtBox [60 50 60 170]", "/ArtBox [60 50 300]"].map(String::from).iter().chain(&unreadable) { + assert_eq!(size(crop, page), (410.0, 210.0), "{page}"); + } + assert_eq!(size("/ArtBox [60 50 300 170]", ""), (420.0, 220.0), "an ArtBox on /Pages is not inherited"); + // A crop box that is not a box leaves the media box. + assert_eq!(size("", &format!("/CropBox [-{big} 5 {big} 215]")), (420.0, 220.0)); +} + +/// After Effects ignores /Rotate on placed footage (a page [100 50 400 150] with /Rotate 90 +/// imports as 300 × 100, drawn unturned); the viewer framing turns it. +#[test] +fn placed_framing_ignores_rotate() { + let bytes = boxes_pdf("", "/Rotate 90"); + let doc = placed(&bytes); + assert_eq!((doc.width, doc.height), (420.0, 220.0)); + // Unturned: the marker at PDF (64..84, 54..64) is at x 64..84, y 156..166 from the top. + let img = effectcraft_svg::rasterize(&doc, 420, 220, 1.0); + assert!(img.get(70, 160)[2] > 0.99, "{:?}", img.get(70, 160)); + let viewer = parse(&bytes).unwrap(); + assert_eq!((viewer.width, viewer.height), (220.0, 420.0)); +} + const EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\ /m {moveto} bind def /l {lineto} bind def\n\ /cm { 6 array astore concat } bind def\n\ diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index 2cf887b1f..eeace5e81 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -269,6 +269,13 @@ pub struct Footage { /// made from the footage starts with them, and Update Markers From Source copies new ones. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub markers: Vec, + /// PDF / Illustrator footage (`.pdf`, `.ai`; not EPS) from an After Effects project: the page + /// is framed as After Effects places it (the ArtBox, `/Rotate` ignored) and drawn to fill + /// `width` × `height`, the size After Effects stored, which the layers' anchors and masks are + /// measured against (#3099). Other footage (and every project saved before this field) shows + /// the page as a PDF viewer does, at its own size. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub placed_page: bool, } fn is_default(v: &T) -> bool { @@ -307,6 +314,7 @@ impl Default for Footage { start_timecode: None, file_timecode: None, markers: vec![], + placed_page: false, } } } diff --git a/crates/render/src/footage.rs b/crates/render/src/footage.rs index 0e17f152d..6b7fdcbf8 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -76,8 +76,9 @@ pub trait FootageSource: Send + Sync { fn cache_budget(&self) -> Option { None } - /// Vector footage (SVG) rasterised at `scale` × its pixel size, for Continuously Rasterize. - /// `None` when the footage is not vector or cannot be read. + /// Vector footage (SVG, PDF, Illustrator, EPS: [`is_vector_footage`]) rasterised at `scale` × + /// its size as [`vector_fit`] draws it, for Continuously Rasterize. `None` when the footage + /// is not vector or cannot be read. fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option> { None } @@ -216,9 +217,31 @@ impl FootageSource for CancellableFootage<'_> { } } -/// Footage that can be rasterised at any scale (SVG). +/// Footage that can be rasterised at any scale: SVG, PDF, Illustrator and EPS files, and a +/// placed page of an After Effects project (its codec is not stored there). pub fn is_vector_footage(f: &Footage) -> bool { - matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS") + matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS") || f.placed_page +} + +/// How vector footage `f` is drawn at `scale` × its size: the image size, and the x and y scale +/// from document pixels (`doc` = the document's width and height, `own` = its pixel size, the +/// size rounded up) to image pixels. +/// +/// Footage from an After Effects project ([`Footage::placed_page`]) fills its stored size, which +/// can round or measure the page differently: the layer's anchor and masks are measured against +/// that size, and the renderer resamples a reduced-resolution frame to it, so drawn at its own +/// size the art was scaled by the width ratio alone at full resolution (off against the masks) +/// and stretched to the stored aspect below 3/4 resolution (#3099). Other footage is drawn 1:1 at +/// the document's own size, as it always was. +pub fn vector_fit(f: &Footage, doc: [f64; 2], own: (u32, u32), scale: f64) -> ((u32, u32), [f64; 2]) { + let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + let side = |px: u32| (f64::from(px) * scale).ceil().clamp(1.0, f64::from(u32::MAX)) as u32; + let size = (f.width, f.height); + let fills = f.placed_page && size != own && size.0 > 0 && size.1 > 0 && doc.iter().all(|d| d.is_finite() && *d > 0.0); + if !fills { + return ((side(own.0), side(own.1)), [scale, scale]); + } + ((side(size.0), side(size.1)), [scale * f64::from(size.0) / doc[0], scale * f64::from(size.1) / doc[1]]) } /// No footage available (renders footage layers as transparent). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e09794de2..65f3ee772 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -47,7 +47,7 @@ mod opts; mod pipeline; pub use accel::{Accelerator, Backend, FxStep, FxTarget}; -pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem}; +pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem, vector_fit}; pub use frames::{ adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, render_frame, roi_inside, diff --git a/docs/aep-import.md b/docs/aep-import.md index 8a83de81f..f41ba3081 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -135,7 +135,15 @@ Units, the "absent = default" rule and the keyframe templates are the reader's text does not have; the runs are cut to the text. A range style splits `Hello` into runs of 1, 2 and 2 characters (248, 340). - **Null objects.** The hidden placeholder solid of a null object is not an item (null layers - have no source item here). + have no source item here). A null's Anchor Point that is not stored is its top-left corner + `[0, 0]`, not the centre, as After Effects 2026 reads it back (#3099). +- **Placed Illustrator and PDF files** (`.ai`, `.pdf`; `Footage::placed_page`): the page is + framed as After Effects places it (the ArtBox clipped to the crop box, `/Rotate` ignored) and + drawn to fill the stored footage size, which masks and anchors are measured against (#3099). + Create Shapes from Vector Layer on such a layer adds a hidden ` Frame` shape layer, the + Alpha track matte of every layer it makes, so they show only the frame, as the footage does. + EPS files keep their bounding box at their own size: how After Effects sizes them is not + measured. - **Track mattes.** The mode and the matte layer's id are in the target layer's header (aep-format.md 3.1). The matte layer can be any layer of the composition (407: below its target); it keeps its stored video switch (After Effects switches it off when the matte is set, diff --git a/docs/gaps.md b/docs/gaps.md index d481f9810..d9de84675 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -266,6 +266,12 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie The second means reading another implementation of the format, which AGENTS.md rules out unless the clean-room scope allows it. - Also in this area: relative footage paths and relinking when a project moves (gap 18). +- Placed PDF / Illustrator footage of an `.aep` is framed as After Effects places it (the ArtBox + clipped to the crop box, `/Rotate` ignored) and drawn to fill the size After Effects stored + (#3099), measured with hand-written PDFs named `.ai`. Not yet measured: files saved by + Illustrator itself (artboards, bleed, art past the artboard, private data), a CropBox without + an ArtBox, `.pdf` names, and EPS files (they keep their bounding box at their own size, not + the placed framing). - Done when: the decision is recorded and, if approved, a corpus of real-world-shaped projects opens with its comps, layers, keyframes, effects and expressions intact. From 91f35c73e1d17a45e854b7e8306e735ff151db6e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 20:01:20 -0800 Subject: [PATCH 39/94] Updates: restart from the update result dialog --- crates/ui-egui/src/updates_ui.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index bb8187f56..316e34ac4 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -91,6 +91,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { rt.updater.dismiss_status(); } CheckResultResponse::CheckAgain => rt.updater.check_now(ctx), + CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(), } // A check that found a release leaves the status idle and the startup prompt takes over. if matches!(rt.updater.status(), Status::Idle) && !rt.check_requested { From 544eebf90486c654c0a415c1d8547df1bad884a7 Mon Sep 17 00:00:00 2001 From: just_anshh Date: Mon, 9 Nov 2026 15:19:53 +0530 Subject: [PATCH 40/94] WebAssembly ABI 2: neighbouring frames for effect plug-ins (#2773) (#3329) * WebAssembly ABI 2: neighbouring frames for effect plug-ins (#2773) A wasm plug-in can export ec_api_version 2 and ec_render2 to receive the layer's previous and next frames, their layer times and validity flags. Neighbours come from PluginHost::input_at, are resampled onto the current grid, and are zeroed when absent. ABI 1 modules (ec_render) load and render unchanged; the manifest api stays independent. A layer with an ABI 2 plug-in keys the render cache on layer time; the Plug-in Library accepts ABI 1-2 releases. Docs, help text and tests updated. * Fix ABI 2 neighbour times on NTSC frame boundaries ime * fps is often a few ulps off an integer at rates such as 24000/1001. The old boundary test then took the bracket branch and passed the current frame back as prev (or ext). Treat a near-integer as a frame boundary within 1e-6 frames, and test 2400 frames at 24000/1001: every boundary must get the frames either side. Also document the loader helpers for the docstring coverage check. --------- Co-authored-by: justanshh <294019246+justanshh@users.noreply.github.com> --- ATTRIBUTION.md | 2 +- crates/effects/src/lib.rs | 2 +- crates/effects/src/plugin.rs | 47 +++- crates/engine/src/cmd/effect.rs | 2 +- crates/engine/src/system/library.rs | 11 +- crates/host/tests/it/plugins.rs | 131 ++++++++- crates/plugin/Cargo.toml | 2 +- crates/plugin/src/lib.rs | 22 +- crates/plugin/src/tests.rs | 179 +++++++++++- crates/plugin/src/wasm.rs | 287 +++++++++++++++++--- crates/render/src/cache.rs | 4 +- crates/render/src/tests/time.rs | 89 ++++++ docs/architecture.md | 2 +- docs/plugins.md | 49 +++- examples/plugins/posterize-bands/Cargo.toml | 2 +- examples/plugins/posterize-bands/src/lib.rs | 2 +- 16 files changed, 741 insertions(+), 92 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4bf3a044d..5bff5cdb0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -99,7 +99,7 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall (sidecars `.attribution`). - Bundled extension (ScriptUI panel) with its twelve built-in ease presets: `extensions/scriptui-panels/Ease Presets.jsx` (sidecar `.attribution`). -- Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly). +- Example effect plug-in: `examples/plugins/posterize-bands` (WebAssembly ABI 1). ## Trained models (optional downloads, not bundled) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 0d78c3619..771d51068 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -1514,7 +1514,7 @@ pub const TIME_DEPENDENT: &[&str] = &[ pub fn is_time_dependent(id: &str) -> bool { // Simulations replay from layer time 0, so every frame differs even when the defaults // happen to look static at the test's two sample times. - id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id) + id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id) || (!id.starts_with("ec.") && plugin::reads_other_times(id)) } /// Does effect `id` read its own parameters at earlier times (the stepped particle systems sample diff --git a/crates/effects/src/plugin.rs b/crates/effects/src/plugin.rs index 5c973ac57..8bbe17856 100644 --- a/crates/effects/src/plugin.rs +++ b/crates/effects/src/plugin.rs @@ -24,10 +24,12 @@ //! * rendering must be deterministic (same inputs, same output) and may run on several threads //! at once. //! -//! API 2 (native plug-ins; the WebAssembly ABI stays at 1) adds [`EffectPlugin::padding`] and -//! [`EffectPlugin::render_v2`], which gets a [`PluginHost`] to read the effect's input or other -//! layers at other times, and the extra parameter kinds. External effect helpers -//! (`effectcraft-helper-host`) are built on it. +//! API 2 (native plug-ins) adds [`EffectPlugin::padding`] and [`EffectPlugin::render_v2`], which +//! gets a [`PluginHost`] to read the effect's input or other layers at other times, and the extra +//! parameter kinds. External effect helpers (`effectcraft-helper-host`) are built on it. +//! +//! The WebAssembly ABI is versioned separately ([`WASM_API_VERSION`]): ABI 1 is `ec_render`; +//! ABI 2 adds `ec_render2`, which receives the layer's previous and next frames. use std::sync::{Arc, OnceLock, RwLock}; @@ -39,9 +41,12 @@ use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, Params, RenderFn}; /// The plug-in API version this build implements (manifests with API 1 or 2 are accepted). pub const PLUGIN_API_VERSION: u32 = 2; -/// The plug-in API a WebAssembly module implements (its `ec_api_version`). API 2 is for native -/// and helper plug-ins; WebAssembly modules stay at 1. -pub const WASM_API_VERSION: u32 = 1; +/// The newest WebAssembly ABI this build implements (a module's `ec_api_version`). ABI 1 is +/// `ec_render`; ABI 2 adds `ec_render2` and the layer's neighbouring frames. This is separate +/// from the plug-in API version: a module's manifest `api` stays 1 or 2. +pub const WASM_API_VERSION: u32 = 2; +/// The oldest WebAssembly ABI this build still loads. +pub const MIN_WASM_API_VERSION: u32 = 1; /// The most plug-in effects one process can register (commercial effect suites register a few hundred each). pub const MAX_PLUGINS: usize = 1024; @@ -269,6 +274,22 @@ impl PluginImage { fn from_buf(b: Buf) -> PluginImage { PluginImage { width: b.img.width, height: b.img.height, pixels: b.img.data, origin: b.offset, scale: b.scale } } + + /// This image resampled onto a `width × height` buffer grid at `origin`/`scale` (the current + /// frame's); the pixels unchanged when the grids already match. + pub fn on_grid(&self, width: u32, height: u32, origin: [f64; 2], scale: f64) -> Vec<[f32; 4]> { + if self.width == width && self.height == height && self.origin == origin && self.scale == scale { + return self.pixels.clone(); + } + let k = self.scale / scale.max(1e-9); + let src = crate::Image { width: self.width, height: self.height, data: self.pixels.clone() }; + crate::util::gen_image(width, height, |x, y| { + let lx = (x as f64 + 0.5 - origin[0]) * k + self.origin[0]; + let ly = (y as f64 + 0.5 - origin[1]) * k + self.origin[1]; + src.sample_bilinear(lx, ly) + }) + .data + } } /// Services available during an API 2 render ([`EffectPlugin::render_v2`]). @@ -388,6 +409,12 @@ pub trait EffectPlugin: Send + Sync { fn render_v2(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64, _host: &dyn PluginHost) -> Result<(), String> { self.render(frame, params, time) } + /// Whether rendering reads the layer at other times (its neighbouring frames). Default + /// false; the render cache keys a layer with such an effect on its layer time. WebAssembly + /// ABI 2 plug-ins return true. + fn reads_other_times(&self) -> bool { + false + } } static SLOTS: [OnceLock>; MAX_PLUGINS] = [const { OnceLock::new() }; MAX_PLUGINS]; @@ -432,6 +459,12 @@ pub fn plugin(id: &str) -> Option> { SLOTS.iter().filter_map(|s| s.get()).find(|p| p.manifest().id == id).cloned() } +/// Whether the registered plug-in `id` reads the layer at other times (its neighbouring frames), +/// so the render cache must key its layer on the layer time. +pub fn reads_other_times(id: &str) -> bool { + SLOTS.iter().filter_map(|s| s.get()).any(|p| p.manifest().id == id && p.reads_other_times()) +} + fn leak(s: &str) -> &'static str { Box::leak(s.to_string().into_boxed_str()) } diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index 1d352d192..1ee132955 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -1352,7 +1352,7 @@ pub fn specs() -> Vec { "Load Effect Plug-in...", [], None, - "{path (.wasm / .wat plug-in, API v1) | folder (loads every .wasm in it)}", + "{path (.wasm / .wat plug-in, WebAssembly ABI 1 or 2) | folder (loads every .wasm in it)}", super::always, plugins_load ), diff --git a/crates/engine/src/system/library.rs b/crates/engine/src/system/library.rs index 750ed600a..5b7f63092 100644 --- a/crates/engine/src/system/library.rs +++ b/crates/engine/src/system/library.rs @@ -304,7 +304,8 @@ pub fn parse_catalog(bytes: &[u8]) -> std::result::Result<(Catalog, Vec) fn runs_here(r: &Release) -> bool { // Without a pre-release suffix (`0.18.0-dev` is 0.18.0). let app = env!("CARGO_PKG_VERSION").split(['-', '+']).next().unwrap_or_default(); - r.api == crate::effects::plugin::WASM_API_VERSION && r.min_app.as_deref().is_none_or(|m| !newer(m, app)) + (crate::effects::plugin::MIN_WASM_API_VERSION..=crate::effects::plugin::WASM_API_VERSION).contains(&r.api) + && r.min_app.as_deref().is_none_or(|m| !newer(m, app)) } /// The newest release this build can run (plug-in API and minimum app version). @@ -789,12 +790,13 @@ fn pick(s: &Session, c: &str, p: &Value) -> Result<(String, Package, Release)> { return Err(bad( c, format!( - "{} {} needs plug-in API {} and EffectCraft {} or later; this is EffectCraft {} with WebAssembly plug-in API {}", + "{} {} needs WebAssembly ABI {} and EffectCraft {} or later; this is EffectCraft {} with WebAssembly ABI {} to {}", pkg.name, rel.version, rel.api, rel.min_app.as_deref().unwrap_or("(any)"), env!("CARGO_PKG_VERSION"), + crate::effects::plugin::MIN_WASM_API_VERSION, crate::effects::plugin::WASM_API_VERSION ), )); @@ -1125,8 +1127,9 @@ mod tests { ..Default::default() }; assert_eq!(latest_compatible(&p).map(|r| r.version.as_str()), Some("1.2.0")); - assert!(runs_here(&rel("1.0.0", 1, None)), "API 1 modules still run on a build with a newer plug-in API"); - assert!(!runs_here(&rel("1.0.0", 2, None)), "WebAssembly modules are API 1: an API 2 release can't load"); + assert!(runs_here(&rel("1.0.0", 1, None)), "ABI 1 modules still run on a build with a newer ABI"); + assert!(runs_here(&rel("1.0.0", 2, None)), "ABI 2 modules run on this build"); + assert!(!runs_here(&rel("1.0.0", 3, None)), "a WebAssembly ABI this build doesn't implement can't load"); let j = package_json(&p, None, &Status::default()); assert_eq!(j["needsNewerApp"], true); assert!(latest_compatible(&Package::default()).is_none()); diff --git a/crates/host/tests/it/plugins.rs b/crates/host/tests/it/plugins.rs index 7e4839e6d..33748d366 100644 --- a/crates/host/tests/it/plugins.rs +++ b/crates/host/tests/it/plugins.rs @@ -281,7 +281,7 @@ fn plugin_loading_reports_bad_modules_and_survives_failing_renders() { 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()), - ("c-v2.wasm", wat_plugin("org.test.host.v2", 2, "i32.const 0").into_bytes()), + ("c-v3.wasm", wat_plugin("org.test.host.v3", 3, "i32.const 0").into_bytes()), ("d-empty.wasm", vec![]), ("e-trap.wasm", wat_plugin("org.test.host.trap", 1, "unreachable").into_bytes()), ("notes.txt", b"not a plug-in".to_vec()), @@ -296,7 +296,7 @@ fn plugin_loading_reports_bad_modules_and_survives_failing_renders() { let errs: Vec = r["errors"].as_array().unwrap().iter().map(|e| e["path"].as_str().unwrap().to_string()).collect(); assert_eq!(errs.len(), 3, "{r}"); assert!(errs.iter().all(|p| !p.ends_with(".txt"))); - assert!(r["errors"].to_string().contains("API 2"), "{r}"); + assert!(r["errors"].to_string().contains("ABI 3"), "{r}"); // A single file: errors come back as command errors; loading twice is refused. let good = dir.join("a-good.wasm").to_string_lossy().to_string(); assert!(s.execute("effect.plugins.load", json!({"path": good})).unwrap_err().to_string().contains("already registered")); @@ -311,3 +311,130 @@ fn plugin_loading_reports_bad_modules_and_survives_failing_renders() { assert_eq!(px(&s), before); let _ = std::fs::remove_dir_all(&dir); } + +/// An ABI 2 plug-in (WebAssembly text) that shows the neighbouring frames it got: per pixel it +/// writes |next.red − prev.red| into red, `flags / 8` into green, `prev_time` into blue and 1 +/// into alpha, so a render test can read the frames and their times back from the image. +fn wat_neighbour_plugin(id: &str) -> String { + let manifest = format!(r#"{{"api":2,"id":"{id}","name":"Host {id}","category":"Test Plug-ins","version":"1","params":[]}}"#); + let len = manifest.len(); + let escaped = manifest.replace('"', "\\\""); + format!( + r#"(module + (memory (export "memory") 2) + (data (i32.const 16) "{escaped}") + (func (export "ec_api_version") (result i32) i32.const 2) + (func (export "ec_manifest_ptr") (result i32) i32.const 16) + (func (export "ec_manifest_len") (result i32) i32.const {len}) + (func (export "ec_alloc") (param i32) (result i32) i32.const 1024) + (func (export "ec_render2") + (param $px i32) (param $w i32) (param $h i32) (param $params i32) (param $np i32) + (param $t f64) (param $scale f64) + (param $prev i32) (param $prev_t f64) (param $next i32) (param $next_t f64) (param $flags i32) + (result i32) + (local $i i32) + (local $n i32) + local.get $w + local.get $h + i32.mul + local.set $n + block $done + loop $per_pixel + local.get $i + local.get $n + i32.ge_u + br_if $done + ;; red = |next.red - prev.red| + local.get $px + local.get $i + i32.const 16 + i32.mul + i32.add + local.get $next + local.get $i + i32.const 16 + i32.mul + i32.add + f32.load + local.get $prev + local.get $i + i32.const 16 + i32.mul + i32.add + f32.load + f32.sub + f32.abs + f32.store + ;; green = flags / 8 + local.get $px + local.get $i + i32.const 16 + i32.mul + i32.add + local.get $flags + f32.convert_i32_s + f32.const 8 + f32.div + f32.store offset=4 + ;; blue = prev_time + local.get $px + local.get $i + i32.const 16 + i32.mul + i32.add + local.get $prev_t + f32.demote_f64 + f32.store offset=8 + ;; alpha = 1 + local.get $px + local.get $i + i32.const 16 + i32.mul + i32.add + f32.const 1 + f32.store offset=12 + local.get $i + i32.const 1 + i32.add + local.set $i + br $per_pixel + end + end + i32.const 0))"# + ) +} + +/// The neighbouring frames are the layer's own pixels at other times: here a precomp whose +/// nested solid fades from 0 to 100 % over one second, at 4 fps. At 0.5 s the neighbours are +/// 0.25 s and 0.75 s, so red (the current frame) was 0.5 and |next − prev| is 0.5. +#[test] +fn abi_v2_plugins_render_with_real_neighbour_frames() { + let mut s = effectcraft_host::session(); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 20, "frameRate": 4, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let solid = s.execute("layer.newSolid", json!({"color": [1.0, 0.0, 0.0], "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.addKey", json!({"layer": solid, "path": "transform/opacity", "time": 0.0, "value": 0})).unwrap(); + s.execute("prop.addKey", json!({"layer": solid, "path": "transform/opacity", "time": 1.0, "value": 100})).unwrap(); + let outer = s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 4, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let layer = s.execute("layer.addItem", json!({"comp": outer, "item": inner, "time": 0})).unwrap()["layer"].as_u64().unwrap(); + let outer = effectcraft_engine::project::ItemId(outer); + + let dir = tempfile::TempDir::new().unwrap(); + let wasm = dir.path().join("neighbours.wasm"); + std::fs::write(&wasm, wat_neighbour_plugin("org.test.host.neighbours")).unwrap(); + let r = s.execute("effect.plugins.load", json!({"path": wasm.to_string_lossy()})).unwrap(); + assert_eq!(r["loaded"][0]["id"], "org.test.host.neighbours", "{r}"); + s.execute("effect.apply", json!({"layer": layer, "effect": "org.test.host.neighbours"})).unwrap(); + + let p = s.render(outer, Tick::from_seconds_f64(0.5), Default::default()).get(20, 10); + assert!((p[0] - 0.5).abs() < 0.02, "precomp neighbours: {p:?}"); + // Both neighbours exist, the previous one is the 0.25 s frame, and the frame is opaque. + assert!((p[1] - 3.0 / 8.0).abs() < 0.01, "flags: {p:?}"); + assert!((p[2] - 0.25).abs() < 0.01, "prev time: {p:?}"); + assert_eq!(p[3], 1.0, "{p:?}"); + + // At the very start of the layer only the next frame exists (the previous one is zeroed); + // solids are filled with a quantised colour, hence the 8-bit step. + let p = s.render(outer, Tick::ZERO, Default::default()).get(20, 10); + assert!((p[0] - 0.25).abs() < 0.01, "the next frame's red, with no previous frame: {p:?}"); + assert!((p[1] - 2.0 / 8.0).abs() < 0.01, "only the next frame is valid: {p:?}"); +} diff --git a/crates/plugin/Cargo.toml b/crates/plugin/Cargo.toml index 4081b61b6..d9b35a91a 100644 --- a/crates/plugin/Cargo.toml +++ b/crates/plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-plugin" -description = "EffectCraft effect plug-ins: loads sandboxed WebAssembly effect modules (plug-in API v1) into the effect registry" +description = "EffectCraft effect plug-ins: loads sandboxed WebAssembly effect modules (WebAssembly ABI v1 and v2) into the effect registry" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 7c85e144b..da21e22a0 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -1,21 +1,25 @@ //! # effectcraft-plugin (L3) //! -//! Loads **WebAssembly effect plug-ins** (plug-in ABI v1, see `docs/plugins.md`; the native -//! plug-in API 2 additions such as layer parameters and host services are not part of it) into the effect -//! registry ([`effectcraft_effects::plugin`]). A plug-in is a `.wasm` (or `.wat`) module with -//! no imports that exports: +//! Loads **WebAssembly effect plug-ins** (WebAssembly ABI v1 and v2, see `docs/plugins.md`; the +//! native plug-in API 2 additions such as layer parameters and host services are not part of it) +//! into the effect registry ([`effectcraft_effects::plugin`]). A plug-in is a `.wasm` (or +//! `.wat`) module with no imports that exports: //! //! | export | signature | | //! |---|---|---| //! | `memory` | memory | linear memory shared with the host | -//! | `ec_api_version` | `() -> i32` | must return 1 | +//! | `ec_api_version` | `() -> i32` | 1 for ABI 1, 2 for ABI 2 (neighbouring frames) | //! | `ec_manifest_ptr`, `ec_manifest_len` | `() -> i32` | a UTF-8 JSON [`PluginManifest`] in memory | //! | `ec_alloc` | `(bytes: i32) -> i32` | a buffer of at least `bytes` bytes, 8-byte aligned; it may reuse the previous one | -//! | `ec_render` | `(pixels: i32, width: i32, height: i32, params: i32, nparams: i32, time: f64, scale: f64) -> i32` | process the pixels in place; 0 = ok | +//! | `ec_render` (ABI 1) | `(pixels: i32, width: i32, height: i32, params: i32, nparams: i32, time: f64, scale: f64) -> i32` | process the pixels in place; 0 = ok | +//! | `ec_render2` (ABI 2) | `ec_render`'s arguments, then `prev: i32, prev_time: f64, next: i32, next_time: f64, flags: i32` | the same, with the neighbouring frames; 0 = ok | //! //! For each render the host asks `ec_alloc` for one buffer holding the parameters (`nparams` //! little-endian `f64`s) followed by the pixels (`width × height × 4` little-endian `f32`s, -//! premultiplied RGBA, row-major), calls `ec_render`, and reads the pixels back. +//! premultiplied RGBA, row-major), calls the render export, and reads the pixels back. An ABI 2 +//! module gets two more pixel buffers after the current frame: the layer's previous and next +//! frames, resampled onto the current grid, and their layer times. The buffers are always +//! allocated (zeroed when a frame does not exist); `flags` bit 0/1 says which are real. //! //! Modules run in the [wasmi](https://github.com/wasmi-labs/wasmi) interpreter (pure Rust, //! MIT/Apache-2.0) with deterministic floats, no imports (no files, network or clock) and a @@ -26,7 +30,9 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use effectcraft_effects::EffectSpec; -pub use effectcraft_effects::plugin::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, PluginParams, WASM_API_VERSION, register_plugin}; +pub use effectcraft_effects::plugin::{ + EffectPlugin, MIN_WASM_API_VERSION, PLUGIN_API_VERSION, PluginFrame, PluginImage, PluginManifest, PluginParams, WASM_API_VERSION, register_plugin, +}; #[cfg(feature = "wasm")] mod wasm; diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index f68fde00a..a4fadb26b 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -163,11 +163,15 @@ fn wasm_plugins_are_sandboxed() { // No imports (files, clock, network…). let imports = r#"(module (import "env" "now" (func $now (result f64))) (memory (export "memory") 1))"#; assert!(load_err(load_wasm(imports.as_bytes(), "imports.wat")).contains("import")); - // Garbage, wrong API version, bad manifests. + // Garbage, an ABI this build doesn't implement, bad manifests. A module that claims ABI 2 + // must export `ec_render2` (this one still has only `ec_render`). assert!(load_wasm(b"\0asm nope", "bad.wasm").is_err()); let v2 = gain_module("org.test.v2", "") .replace("(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result i32) i32.const 2)"); - assert!(load_err(load_wasm(v2.as_bytes(), "v2.wat")).contains("API 2")); + assert!(load_err(load_wasm(v2.as_bytes(), "v2.wat")).contains("ec_render2")); + let v3 = gain_module("org.test.v3", "") + .replace("(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result i32) i32.const 3)"); + assert!(load_err(load_wasm(v3.as_bytes(), "v3.wat")).contains("ABI 3")); let reserved = gain_module("ec.blur.mine", ""); assert!(load_err(load_wasm(reserved.as_bytes(), "reserved.wat")).contains("reserved")); } @@ -194,14 +198,14 @@ fn abi_violations_fail_to_load_with_reasons() { assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("ec_api_version")); let m = edit(&base, "(param $scale f64) (result i32)", "(param $scale f32) (result i32)"); assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("ec_render")); - // API versions other than 1. - for v in ["0", "-1", "2147483647"] { + // WebAssembly ABIs other than 1 and 2. + for v in ["0", "-1", "3", "2147483647"] { let m = edit( &base, "(func (export \"ec_api_version\") (result i32) i32.const 1)", &format!("(func (export \"ec_api_version\") (result i32) i32.const {v})"), ); - assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains(&format!("API {v}")), "{v}"); + assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains(&format!("ABI {v}")), "{v}"); } // Manifest out of bounds, negative or absurd lengths, not JSON. let ptr = "(func (export \"ec_manifest_ptr\") (result i32) i32.const 16)"; @@ -317,3 +321,168 @@ fn wasm_rendering_is_deterministic() { let twin = load_wasm(gain_module("org.test.det2", body).as_bytes(), "det2.wat").unwrap(); assert_eq!(bits(&render(twin, 0.37, true)), first); } + +/// An ABI 2 module whose `ec_render2` writes what it got from the host into each pixel: red is +/// |next.red − prev.red|, green `flags / 8`, blue `prev_time`, alpha 1. +fn neighbour_module(id: &str) -> String { + let manifest = format!(r#"{{"api":2,"id":"{id}","name":"Neighbours {id}","category":"Test Plug-ins","version":"1","params":[]}}"#); + let len = manifest.len(); + let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\""); + format!( + r#"(module + (memory (export "memory") 2) + (data (i32.const 16) "{escaped}") + (func (export "ec_api_version") (result i32) i32.const 2) + (func (export "ec_manifest_ptr") (result i32) i32.const 16) + (func (export "ec_manifest_len") (result i32) i32.const {len}) + (func (export "ec_alloc") (param i32) (result i32) i32.const 1024) + (func (export "ec_render2") + (param $px i32) (param $w i32) (param $h i32) (param $params i32) (param $np i32) + (param $t f64) (param $scale f64) + (param $prev i32) (param $prev_t f64) (param $next i32) (param $next_t f64) (param $flags i32) + (result i32) + (local $i i32) (local $n i32) + local.get $w + local.get $h + i32.mul + local.set $n + block $done + loop $per_pixel + local.get $i + local.get $n + i32.ge_u + br_if $done + local.get $px + local.get $i + i32.const 16 + i32.mul + i32.add + local.get $next + local.get $i + i32.const 16 + i32.mul + i32.add + f32.load + local.get $prev + local.get $i + i32.const 16 + i32.mul + i32.add + f32.load + f32.sub + f32.abs + f32.store + local.get $px + local.get $i + i32.const 16 + i32.mul + i32.add + local.get $flags + f32.convert_i32_s + f32.const 8 + f32.div + f32.store offset=4 + local.get $px + local.get $i + i32.const 16 + i32.mul + i32.add + local.get $prev_t + f32.demote_f64 + f32.store offset=8 + local.get $px + local.get $i + i32.const 16 + i32.mul + i32.add + f32.const 1 + f32.store offset=12 + local.get $i + i32.const 1 + i32.add + local.set $i + br $per_pixel + end + end + i32.const 0))"# + ) +} + +fn neighbour_image(red: f32) -> effectcraft_effects::plugin::PluginImage { + effectcraft_effects::plugin::PluginImage { width: 2, height: 1, pixels: vec![[red, 0.0, 0.0, 1.0]; 2], origin: [0.0; 2], scale: 1.0 } +} + +/// A host with frames at two layer times. +struct Host { + time: f64, + fps: f64, + prev: Option<(f64, effectcraft_effects::plugin::PluginImage)>, + next: Option<(f64, effectcraft_effects::plugin::PluginImage)>, +} + +impl effectcraft_effects::plugin::PluginHost for Host { + fn input_at(&self, layer_time: f64) -> Option { + if let Some((t, img)) = &self.prev + && (t - layer_time).abs() < 1e-9 + { + return Some(img.clone()); + } + if let Some((t, img)) = &self.next + && (t - layer_time).abs() < 1e-9 + { + return Some(img.clone()); + } + None + } + + fn layer_input(&self, _param_id: &str, _layer_time: f64) -> Option { + None + } + + fn params_at(&self, _layer_time: f64) -> Option<(Vec, Vec)> { + None + } + + fn frame_rate(&self) -> f64 { + self.fps + } + + fn time(&self) -> f64 { + self.time + } +} + +#[test] +fn abi_v2_receives_neighbour_frames_and_times() { + load_wasm(neighbour_module("org.test.neighbours").as_bytes(), "neighbours.wat").unwrap(); + let p = effectcraft_effects::plugin::plugin("org.test.neighbours").unwrap(); + assert!(effectcraft_effects::plugin::reads_other_times("org.test.neighbours")); + assert!(effectcraft_effects::is_time_dependent("org.test.neighbours")); + // The module has no parameters, so the flattened vector is empty. + let params = effectcraft_effects::plugin::PluginParams { manifest: p.manifest(), flat: &[], texts: &[] }; + let mut px = vec![[0.0f32; 4]; 2]; + let host = Host { time: 0.5, fps: 4.0, prev: Some((0.25, neighbour_image(0.1))), next: Some((0.75, neighbour_image(0.6))) }; + let mut frame = effectcraft_effects::plugin::PluginFrame { width: 2, height: 1, pixels: &mut px, scale: 1.0, origin: [0.0; 2], layer_size: [2.0, 1.0] }; + p.render_v2(&mut frame, ¶ms, 0.5, &host).unwrap(); + assert!((px[0][0] - 0.5).abs() < 1e-6, "|next − prev|: {px:?}"); + assert!((px[0][1] - 3.0 / 8.0).abs() < 1e-6, "both flags: {px:?}"); + assert!((px[0][2] - 0.25).abs() < 1e-6, "previous frame's time: {px:?}"); + assert_eq!(px[0][3], 1.0); +} + +#[test] +fn abi_v2_without_a_host_gets_zeroed_neighbours() { + let spec = load_wasm(neighbour_module("org.test.neighbours.empty").as_bytes(), "empty.wat").unwrap(); + let mut next_id = 1; + let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next_id), spec.name, [2.0, 1.0]); + let params = effectcraft_effects::flatten_params(&g, &mut |p| p.value.clone()); + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: [2.0, 1.0], seed: 1, adjustment: false, env: EffectEnv::default() }; + let buf = Buf { img: Image::filled(2, 1, [0.5, 0.5, 0.5, 1.0]), offset: [0.0; 2], scale: 1.0 }; + // No host: both neighbour buffers are zeroed and no flag is set. + assert_eq!((spec.render)(&ctx, buf).img.get(0, 0), [0.0, 0.0, 0.0, 1.0]); + // ABI 1 plug-ins keep the time-independent behaviour. + let gain = load_wasm(gain_module("org.test.neighbours.v1", "").as_bytes(), "v1.wat").unwrap(); + assert!(!effectcraft_effects::plugin::reads_other_times("org.test.neighbours.v1")); + assert!(!effectcraft_effects::is_time_dependent("org.test.neighbours.v1")); + assert!(gain.id.starts_with("org.test.")); +} diff --git a/crates/plugin/src/wasm.rs b/crates/plugin/src/wasm.rs index 18a34548f..499d57397 100644 --- a/crates/plugin/src/wasm.rs +++ b/crates/plugin/src/wasm.rs @@ -1,10 +1,11 @@ -//! The wasmi-backed [`EffectPlugin`] (plug-in API v1 ABI, see the crate docs). +//! The wasmi-backed [`EffectPlugin`] (WebAssembly ABI v1 and v2, see the crate docs). use std::sync::Mutex; use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, WasmParams, WasmResults}; -use crate::{EffectPlugin, PluginFrame, PluginManifest, PluginParams, WASM_API_VERSION}; +use crate::{EffectPlugin, MIN_WASM_API_VERSION, PluginFrame, PluginManifest, PluginParams, WASM_API_VERSION}; +use effectcraft_effects::plugin::{PluginHost, PluginImage}; /// Instruction budget per frame: a fixed allowance plus some per pixel. Generous for per-pixel /// work, but a runaway loop stops instead of hanging the render. @@ -15,32 +16,74 @@ const MAX_MEMORY: usize = 1 << 30; /// The longest manifest read. const MAX_MANIFEST: i32 = 1 << 20; +/// ABI 1: one buffer. type RenderFn = TypedFunc<(i32, i32, i32, i32, i32, f64, f64), i32>; +/// ABI 2: the same call plus the previous and next frame buffers and their layer times. +type Render2Fn = TypedFunc<(i32, i32, i32, i32, i32, f64, f64, i32, f64, i32, f64, i32), i32>; + +enum Renderer { + V1(RenderFn), + V2(Render2Fn), +} /// One instantiated module (renders running on several threads each take their own). struct Inst { + store: Store, + memory: Memory, + alloc: TypedFunc, + render: Renderer, +} + +/// An instance whose ABI version (and so its render export) is not selected yet. +struct Pending { store: Store, instance: Instance, memory: Memory, alloc: TypedFunc, - render: RenderFn, } -impl Inst { +impl Pending { + /// A typed export by name, reporting a missing or mistyped export like [`missing`]. fn func(&self, name: &str) -> Result, String> { self.instance.get_typed_func::(&self.store, name).map_err(|_| missing(name)) } + + /// Select the render export for `abi`. + fn finish(self, abi: u32) -> Result { + let render = match abi { + 1 => Renderer::V1(self.instance.get_typed_func::<_, i32>(&self.store, "ec_render").map_err(|_| missing("ec_render"))?), + 2 => Renderer::V2(self.instance.get_typed_func::<_, i32>(&self.store, "ec_render2").map_err(|_| missing("ec_render2"))?), + v => return Err(unsupported(v as i32)), + }; + Ok(Inst { store: self.store, memory: self.memory, alloc: self.alloc, render }) + } } +/// The error for a missing or mistyped export. fn missing(name: &str) -> String { format!("the module exports no `{name}` (or with the wrong signature)") } +/// The error for an ABI this build does not implement. +fn unsupported(v: i32) -> String { + format!("the module implements WebAssembly ABI {v}, this build implements ABI {MIN_WASM_API_VERSION} to {WASM_API_VERSION}") +} + +/// The module's `ec_api_version`, when this build implements it. +fn abi_of(v: i32) -> Result { + match u32::try_from(v) { + Ok(v) if (MIN_WASM_API_VERSION..=WASM_API_VERSION).contains(&v) => Ok(v), + _ => Err(unsupported(v)), + } +} + +/// The error for a wasmi or trap failure. fn err(e: impl std::fmt::Display) -> String { e.to_string() } -fn instantiate(engine: &Engine, module: &Module) -> Result { +/// Instantiate `module` in a fresh, limited store and read its memory and allocator. +fn begin(engine: &Engine, module: &Module) -> Result { let mut store = Store::new(engine, StoreLimitsBuilder::new().memory_size(MAX_MEMORY).instances(1).build()); store.limiter(|l| l); store.set_fuel(FUEL_BASE).map_err(err)?; @@ -48,14 +91,20 @@ fn instantiate(engine: &Engine, module: &Module) -> Result { let instance = linker.instantiate_and_start(&mut store, module).map_err(|e| format!("instantiation failed: {e}"))?; let memory = instance.get_memory(&store, "memory").ok_or("the module exports no `memory`")?; let alloc = instance.get_typed_func::(&store, "ec_alloc").map_err(|_| missing("ec_alloc"))?; - let render = instance.get_typed_func::<(i32, i32, i32, i32, i32, f64, f64), i32>(&store, "ec_render").map_err(|_| missing("ec_render"))?; - Ok(Inst { store, instance, memory, alloc, render }) + Ok(Pending { store, instance, memory, alloc }) +} + +/// Instantiate `module` and select the render export for `abi`. +fn instantiate(engine: &Engine, module: &Module, abi: u32) -> Result { + begin(engine, module)?.finish(abi) } /// A loaded WebAssembly effect plug-in. pub struct WasmPlugin { manifest: PluginManifest, source: String, + /// The WebAssembly ABI the module implements (`ec_api_version`). + abi: u32, engine: Engine, module: Module, /// Idle instances. @@ -63,6 +112,7 @@ pub struct WasmPlugin { } impl WasmPlugin { + /// Load `bytes` and its manifest; errors on imports, a bad manifest or a missing export. pub fn new(bytes: &[u8], source: &str) -> Result { let mut config = Config::default(); config.consume_fuel(true); @@ -71,51 +121,42 @@ impl WasmPlugin { if let Some(i) = module.imports().next() { return Err(format!("plug-ins can't import anything (the module imports `{}.{}`)", i.module(), i.name())); } - let mut inst = instantiate(&engine, &module)?; - let version = inst.func::<(), i32>("ec_api_version")?; - let v = version.call(&mut inst.store, ()).map_err(err)?; - if u32::try_from(v).ok() != Some(WASM_API_VERSION) { - return Err(format!("the module implements plug-in API {v}, this build implements API {WASM_API_VERSION} for WebAssembly")); - } - let ptr = inst.func::<(), i32>("ec_manifest_ptr")?.call(&mut inst.store, ()).map_err(err)?; - let len = inst.func::<(), i32>("ec_manifest_len")?.call(&mut inst.store, ()).map_err(err)?; + let mut pending = begin(&engine, &module)?; + let version = pending.func::<(), i32>("ec_api_version")?; + let abi = abi_of(version.call(&mut pending.store, ()).map_err(err)?)?; + let ptr = pending.func::<(), i32>("ec_manifest_ptr")?.call(&mut pending.store, ()).map_err(err)?; + let len = pending.func::<(), i32>("ec_manifest_len")?.call(&mut pending.store, ()).map_err(err)?; if !(0..=MAX_MANIFEST).contains(&len) { return Err(format!("bad manifest length {len}")); } let mut text = vec![0u8; len as usize]; - inst.memory.read(&inst.store, ptr as u32 as usize, &mut text).map_err(|e| format!("manifest out of bounds: {e}"))?; + pending.memory.read(&pending.store, ptr as u32 as usize, &mut text).map_err(|e| format!("manifest out of bounds: {e}"))?; let manifest: PluginManifest = serde_json::from_slice(&text).map_err(|e| format!("bad manifest JSON: {e}"))?; manifest.validate()?; if let Some(p) = manifest.params.iter().find(|p| p.kind.needs_v2()) { return Err(format!("parameter `{}`: WebAssembly plug-ins use plug-in API 1 parameter types (no point3, layer, text or hidden)", p.id)); } - Ok(WasmPlugin { manifest, source: source.to_string(), engine, module, pool: Mutex::new(vec![inst]) }) + let inst = pending.finish(abi)?; + Ok(WasmPlugin { manifest, source: source.to_string(), abi, engine, module, pool: Mutex::new(vec![inst]) }) } + /// An idle instance, or a fresh one when the pool is empty. fn take(&self) -> Result { if let Some(i) = self.pool.lock().ok().and_then(|mut p| p.pop()) { return Ok(i); } - instantiate(&self.engine, &self.module) + instantiate(&self.engine, &self.module, self.abi) } + /// Return a working instance to the pool. fn give_back(&self, inst: Inst) { if let Ok(mut p) = self.pool.lock() { p.push(inst); } } -} -impl EffectPlugin for WasmPlugin { - fn manifest(&self) -> &PluginManifest { - &self.manifest - } - - fn source(&self) -> String { - self.source.clone() - } - - fn render(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String> { + /// ABI 1: parameters, then the pixels; `ec_render`. + fn render_v1(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String> { let n = frame.pixels.len(); let params_bytes = params.flat.len() * 8; let total = params_bytes + n * 16; @@ -130,25 +171,19 @@ impl EffectPlugin for WasmPlugin { for v in params.flat { buf.extend_from_slice(&v.to_le_bytes()); } - for px in frame.pixels.iter() { - for c in px { - buf.extend_from_slice(&c.to_le_bytes()); - } - } + push_pixels(&mut buf, frame.pixels); inst.memory.write(&mut inst.store, base, &buf).map_err(|e| format!("ec_alloc returned a buffer out of bounds: {e}"))?; let px_ptr = (base + params_bytes) as i32; - let r = inst - .render - .call(&mut inst.store, (px_ptr, frame.width as i32, frame.height as i32, base as i32, params.flat.len() as i32, time, frame.scale)) - .map_err(|e| format!("ec_render trapped: {e}")); + let r = match &inst.render { + Renderer::V1(f) => f + .call(&mut inst.store, (px_ptr, frame.width as i32, frame.height as i32, base as i32, params.flat.len() as i32, time, frame.scale)) + .map_err(|e| format!("ec_render trapped: {e}")), + Renderer::V2(_) => return Err("the module implements WebAssembly ABI 1 but exports ec_render2".into()), + }; let r = match r { Ok(0) => { inst.memory.read(&inst.store, base + params_bytes, &mut buf[params_bytes..]).map_err(err)?; - for (px, b) in frame.pixels.iter_mut().zip(buf[params_bytes..].as_chunks::<16>().0.iter()) { - for (c, v) in px.iter_mut().zip(b.as_chunks::<4>().0.iter()) { - *c = f32::from_le_bytes([v[0], v[1], v[2], v[3]]); - } - } + read_pixels(&buf[params_bytes..], frame.pixels); Ok(()) } Ok(code) => Err(format!("ec_render returned error {code}")), @@ -159,4 +194,170 @@ impl EffectPlugin for WasmPlugin { } r } + + /// ABI 2: parameters, the current frame, then the previous and next frames; `ec_render2`. + /// The neighbour buffers are always allocated (zeroed when absent), so a module can read + /// both without a null check; `flags` says which hold real frames. + fn render_v2( + &self, + frame: &mut PluginFrame, + params: &PluginParams, + time: f64, + prev: Option<(f64, &PluginImage)>, + next: Option<(f64, &PluginImage)>, + ) -> Result<(), String> { + let n = frame.pixels.len(); + let params_bytes = params.flat.len() * 8; + let frame_bytes = n * 16; + let total = params_bytes + frame_bytes * 3; + if total > i32::MAX as usize { + return Err("frame too large for a 32-bit plug-in".into()); + } + let prev_px = prev.map(|(t, img)| (t, img.on_grid(frame.width, frame.height, frame.origin, frame.scale))); + let next_px = next.map(|(t, img)| (t, img.on_grid(frame.width, frame.height, frame.origin, frame.scale))); + let prev = prev_px.as_ref().filter(|(_, px)| px.len() == n); + let next = next_px.as_ref().filter(|(_, px)| px.len() == n); + let mut inst = self.take()?; + inst.store.set_fuel(FUEL_BASE + FUEL_PER_PIXEL * (n as u64 * 3)).map_err(err)?; + let base = inst.alloc.call(&mut inst.store, total as i32).map_err(err)? as u32 as usize; + let mut buf: Vec = Vec::with_capacity(total); + for v in params.flat { + buf.extend_from_slice(&v.to_le_bytes()); + } + push_pixels(&mut buf, frame.pixels); + push_neighbour(&mut buf, prev, frame_bytes); + push_neighbour(&mut buf, next, frame_bytes); + inst.memory.write(&mut inst.store, base, &buf).map_err(|e| format!("ec_alloc returned a buffer out of bounds: {e}"))?; + let px_ptr = (base + params_bytes) as i32; + let prev_ptr = (base + params_bytes + frame_bytes) as i32; + let next_ptr = (base + params_bytes + frame_bytes * 2) as i32; + let flags = i32::from(prev.is_some()) | (i32::from(next.is_some()) << 1); + let prev_time = prev.map_or(0.0, |(t, _)| *t); + let next_time = next.map_or(0.0, |(t, _)| *t); + let r = match &inst.render { + Renderer::V2(f) => f + .call( + &mut inst.store, + ( + px_ptr, + frame.width as i32, + frame.height as i32, + base as i32, + params.flat.len() as i32, + time, + frame.scale, + prev_ptr, + prev_time, + next_ptr, + next_time, + flags, + ), + ) + .map_err(|e| format!("ec_render2 trapped: {e}")), + Renderer::V1(_) => return Err("the module implements WebAssembly ABI 2 but exports ec_render".into()), + }; + let r = match r { + Ok(0) => { + let mut out = vec![0u8; frame_bytes]; + inst.memory.read(&inst.store, base + params_bytes, &mut out).map_err(err)?; + read_pixels(&out, frame.pixels); + Ok(()) + } + Ok(code) => Err(format!("ec_render2 returned error {code}")), + Err(e) => Err(e), + }; + if r.is_ok() { + self.give_back(inst); + } + r + } +} + +/// Append pixels to the little-endian buffer the module reads. +fn push_pixels(buf: &mut Vec, px: &[[f32; 4]]) { + for p in px { + for c in p { + buf.extend_from_slice(&c.to_le_bytes()); + } + } +} + +/// The neighbour's pixels, or `frame_bytes` zeroed bytes when there is no frame. +fn push_neighbour(buf: &mut Vec, neighbour: Option<&(f64, Vec<[f32; 4]>)>, frame_bytes: usize) { + match neighbour { + Some((_, px)) => push_pixels(buf, px), + None => buf.resize(buf.len() + frame_bytes, 0), + } +} + +/// Read the little-endian pixels the module wrote back. +fn read_pixels(bytes: &[u8], pixels: &mut [[f32; 4]]) { + for (px, b) in pixels.iter_mut().zip(bytes.as_chunks::<16>().0.iter()) { + for (c, v) in px.iter_mut().zip(b.as_chunks::<4>().0.iter()) { + *c = f32::from_le_bytes([v[0], v[1], v[2], v[3]]); + } + } +} + +/// The layer times of the frames around `time` on a `fps` grid: the frame before and after it +/// at a frame boundary, the two frames that bracket it between frames. +fn neighbour_times(time: f64, fps: f64) -> (f64, f64) { + if !fps.is_finite() || fps <= 0.0 { + return (time - 1.0 / 30.0, time + 1.0 / 30.0); + } + let step = 1.0 / fps; + let at = time * fps; + // Frame times are not exact in floating point: at NTSC rates `time * fps` often lands a few + // ulps above or below an integer. Treat a near-integer as a frame boundary, so the current + // frame is never handed back as its own neighbour. + if (at - at.round()).abs() < 1e-6 { (time - step, time + step) } else { (at.floor() / fps, at.ceil() / fps) } +} + +impl EffectPlugin for WasmPlugin { + fn manifest(&self) -> &PluginManifest { + &self.manifest + } + + fn source(&self) -> String { + self.source.clone() + } + + fn reads_other_times(&self) -> bool { + self.abi >= 2 + } + + fn render(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String> { + if self.abi == 1 { self.render_v1(frame, params, time) } else { self.render_v2(frame, params, time, None, None) } + } + + fn render_v2(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64, host: &dyn PluginHost) -> Result<(), String> { + if self.abi == 1 { + return self.render_v1(frame, params, time); + } + let (prev_time, next_time) = neighbour_times(time, host.frame_rate()); + let prev = host.input_at(prev_time); + let next = host.input_at(next_time); + let prev = prev.as_ref().map(|img| (prev_time, img)); + let next = next.as_ref().map(|img| (next_time, img)); + self.render_v2(frame, params, time, prev, next) + } +} + +#[cfg(test)] +mod tests { + use super::neighbour_times; + + /// NTSC frame times do not multiply back exactly; every frame boundary must still get the + /// frames either side of it, never the frame itself. + #[test] + fn frame_boundaries_get_their_neighbours_at_ntsc_rates() { + let fps = 24000.0 / 1001.0; + for n in 0..2400 { + // 24000/1001 fps at 254 016 000 000 ticks/s, as `Tick` stores it. + let time = (n * 10_594_584_000_i64) as f64 / 254_016_000_000.0; + let (prev, next) = neighbour_times(time, fps); + assert!((prev - (time - 1.0 / fps)).abs() < 1e-9, "frame {n}: prev {prev}, current {time}"); + assert!((next - (time + 1.0 / fps)).abs() < 1e-9, "frame {n}: next {next}, current {time}"); + } + } } diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 8a3d7b8ae..0756c47f2 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -689,7 +689,9 @@ fn reads_other_times(layer: &Layer, effects: Option) -> bool { fx.groups().take(effects.unwrap_or(usize::MAX)).any(|g| { g.enabled && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } - if effect.starts_with("ec.time.") || effectcraft_effects::reads_param_history(effect)) + if effect.starts_with("ec.time.") + || effectcraft_effects::reads_param_history(effect) + || (!effect.starts_with("ec.") && effectcraft_effects::plugin::reads_other_times(effect))) }) }) } diff --git a/crates/render/src/tests/time.rs b/crates/render/src/tests/time.rs index 715c37ef0..f959401bb 100644 --- a/crates/render/src/tests/time.rs +++ b/crates/render/src/tests/time.rs @@ -1,7 +1,10 @@ //! Time effects through the renderer: neighbouring frames via `EffectHost::self_at`, and the //! layer cache staying correct while scrubbing. +use std::sync::Arc; + use effectcraft_color::Label; +use effectcraft_effects::plugin::{EffectPlugin, PluginFrame, PluginManifest, PluginParams, register_plugin}; use effectcraft_keyframe::{Keyframe, ShapePath, Value}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Project, Solid}; @@ -157,3 +160,89 @@ fn input_keys_ignore_later_effects() { set(&mut p, "effects/#1/blurriness", 8.0); assert_ne!(key(&p).0, one); } + +/// A plug-in that reads the layer's neighbouring frames (WebAssembly ABI 2) makes its layer's +/// cache key depend on the layer time, even when the source and every property are static. A +/// plug-in that does not keeps the old key (#1977: a static layer stays cached across a scrub). +#[test] +fn abi2_plugin_layers_key_on_layer_time() { + struct NeighbourReader { + m: PluginManifest, + } + + impl EffectPlugin for NeighbourReader { + fn manifest(&self) -> &PluginManifest { + &self.m + } + + fn render(&self, _frame: &mut PluginFrame, _params: &PluginParams, _time: f64) -> Result<(), String> { + Ok(()) + } + + fn reads_other_times(&self) -> bool { + true + } + } + + struct Plain { + m: PluginManifest, + } + + impl EffectPlugin for Plain { + fn manifest(&self) -> &PluginManifest { + &self.m + } + + fn render(&self, _frame: &mut PluginFrame, _params: &PluginParams, _time: f64) -> Result<(), String> { + Ok(()) + } + } + + /// A static white solid with one plug-in effect, registered by `plugin`. + fn plugin_scene(plugin: Arc) -> (Project, ItemId) { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 200, height: 100, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (200, 100), None); + let spec = register_plugin(plugin).unwrap(); + let mut next = p.next_id; + let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]); + p.next_id = next; + l.props.sub_mut("effects").unwrap().children.push(g.into()); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) + } + + fn key(p: &Project, cid: ItemId, t: f64) -> Option { + let comp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx { + project: p, + comp_id: cid, + comp, + time: Tick::from_seconds_f64(t), + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + reads: None, + }; + crate::cache::layer_key(&ctx, &comp.layers[0], 1.0, false, false) + } + + let manifest = |id: &str| PluginManifest { + api: 2, + id: id.into(), + name: "Key".into(), + category: "Test Plug-ins".into(), + version: "1".into(), + author: String::new(), + description: String::new(), + params: vec![], + }; + + let (plain, c1) = plugin_scene(Arc::new(Plain { m: manifest("org.test.render.time-key.plain") })); + assert_eq!(key(&plain, c1, 0.25), key(&plain, c1, 0.5), "a plain plug-in keeps the old key"); + let (reader, c2) = plugin_scene(Arc::new(NeighbourReader { m: manifest("org.test.render.time-key.reader") })); + assert_ne!(key(&reader, c2, 0.25), key(&reader, c2, 0.5), "an ABI 2 plug-in keys on the layer time"); +} diff --git a/docs/architecture.md b/docs/architecture.md index a04907aec..1e91da215 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L3 | `interchange` | Premiere Pro interop through timeline interchange (FCP7 XML, FCPXML, OTIO, EDL, AAF, OMF via FilmCraft's `filmcraft-interchange`): sequences ↔ compositions (§6a) | | L3 | `aep` | After Effects projects in one crate (like `lottie`): `container` reads and writes the `.aep` container (RIFX `Egg!`) as a lossless chunk tree with a byte-identical round trip, bounded for hostile input, reads the `.aepx` XML form into the same tree, and has a typed view of items, layers, properties, effects, masks, shapes, text, markers, footage, cameras, lights and layer styles, from our own observations of generated files ([aep-format.md](aep-format.md)); `import` maps the typed view into a project (items, settings, compositions, every layer kind, Transform, keyframes, expressions, masks, text, shapes, cameras, lights, mattes, markers, time remapping, layer styles, the effects of a shared table, footage) with a report of everything not imported ([aep-import.md](aep-import.md)); `export` builds every chunk in code (prototypes in the layout After Effects writes, cloned and edited only at verified fields) for the same scope, with a report of everything not exported, verified by opening the output in After Effects ([aep-export.md](aep-export.md)) | | L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express | -| L3 | `plugin` | Effect plug-ins: loads sandboxed WebAssembly effect modules (plug-in API v1; the wasmi interpreter behind the `wasm` feature, on for native hosts) into the effect registry ([plugins.md](plugins.md)) | +| L3 | `plugin` | Effect plug-ins: loads sandboxed WebAssembly effect modules (WebAssembly ABI v1 and v2, the latter with neighbouring frames; the wasmi interpreter behind the `wasm` feature, on for native hosts) into the effect registry ([plugins.md](plugins.md)) | | L3 | `helper-host` | External effect helpers: finds helper programs, runs each as a child process and registers its effects (plug-in API 2) over a versioned pipe + shared-file protocol; a crashing helper is an error on the effect, never a crash ([external-helpers.md](external-helpers.md), [ADR 0001](adr/0001-external-effect-helpers.md)). Native only | | L4 | `engine` | `Session`: project, branching undo history, editor state, the command registry and menus, the ScriptUI window model (`scriptui`) | | L4 | `script` | Scripting: JavaScript (boa) with an After Effects-style object model (`app.project`, comps, layers, properties, render queue) and ScriptUI (dialogs, palettes, dockable panels; script-host threads so dialogs block `show()`), whose edits run engine commands; AE match names | diff --git a/docs/plugins.md b/docs/plugins.md index 3fd8cf944..290ed033c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -11,7 +11,7 @@ Guide): | After Effects | EffectCraft | | |---|---|---| -| Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) | +| Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (WebAssembly ABI 1 and 2) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) | | Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples; a subfolder (one level) is a submenu named after it | Same | | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | | File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same | @@ -152,7 +152,7 @@ example of a keyframe tool. ## Effect plug-ins -EffectCraft loads third-party effects through a small, versioned plug-in API (**API version 2**; WebAssembly ABI 1). +EffectCraft loads third-party effects through a small, versioned plug-in API (**API version 2**; WebAssembly ABI 1 and 2). A plug-in effect behaves exactly like a built-in one: it is listed in Effects & Presets (and the Effect menu) under its category, applied by id (`effect.apply {"effect": "org.example.posterize"}`) or display name, its parameters are ordinary properties (keyframes, expressions, Effect Controls, @@ -178,7 +178,8 @@ author, source, params}]}`. * **Parameters** arrive flattened to `f64`s in the order they are declared: slider, angle, checkbox (0 / 1) and popup (0-based index) take one value, point two (already in **buffer pixels**), colour four (RGBA 0–1). -* **Time** is layer time in seconds. +* **Time** is layer time in seconds. A WebAssembly module written for ABI 2 also gets the + layer's neighbouring frames at other times (see [WebAssembly ABI](#webassembly-abi)). * Rendering must be **deterministic** and may run on several threads at once (a WebAssembly plug-in gets one instance per thread). * A failed render (an error code, a trap, running out of fuel) leaves the frame unchanged. @@ -216,22 +217,36 @@ Every plug-in describes itself with a JSON manifest: parameters; a slider needs `min ≤ default ≤ max` (and `sliderMin ≤ sliderMax`); the manifest is at most 1 MiB. -### WebAssembly ABI (v1) +### WebAssembly ABI -A module with **no imports** that exports: +A module with **no imports** exports: | export | signature | | |---|---|---| | `memory` | memory | linear memory shared with the host | -| `ec_api_version` | `() -> i32` | must return `1` | +| `ec_api_version` | `() -> i32` | `1` for ABI 1, `2` for ABI 2 (neighbouring frames) | | `ec_manifest_ptr`, `ec_manifest_len` | `() -> i32` | the UTF-8 JSON manifest in memory | | `ec_alloc` | `(bytes: i32) -> i32` | a buffer of at least `bytes` bytes, 8-byte aligned; it may reuse the previous one | -| `ec_render` | `(pixels: i32, width: i32, height: i32, params: i32, nparams: i32, time: f64, scale: f64) -> i32` | process the pixels in place; return 0 on success | +| `ec_render` (ABI 1) | `(pixels: i32, width: i32, height: i32, params: i32, nparams: i32, time: f64, scale: f64) -> i32` | process the pixels in place; return 0 on success | +| `ec_render2` (ABI 2) | `ec_render`'s arguments, then `prev: i32, prev_time: f64, next: i32, next_time: f64, flags: i32` | the same, with the neighbouring frames; return 0 on success | For each frame the host calls `ec_alloc` once for a buffer holding the parameters (`nparams` -little-endian `f64`s) followed by the pixels (`width × height × 4` little-endian `f32`s), fills it, -calls `ec_render`, and reads the pixels back. Each call gets a fuel budget proportional to the -frame size; a module that loops forever stops with an error instead of hanging the app. +little-endian `f64`s) followed by the pixels (`width × height × 4` little-endian `f32`s, +premultiplied RGBA, row-major), fills it, calls the module's render export, and reads the pixels +back. An ABI 2 module gets two more pixel buffers after the current frame: the previous and next +frames, resampled onto the current buffer's grid. At a frame boundary they are the frames before +and after it (`time ± 1/fps`); between frames they are the two frames that bracket `time`. + +The neighbour buffers are always allocated (zeroed when the layer has no pixels there, so a +module can read both without a null check). `flags` bit 0 is set when `prev` holds a real frame, +bit 1 when `next` does; `prev_time` / `next_time` are those frames' layer times in seconds (0 +when absent). The ABI version is the module's `ec_api_version` and is independent of the +manifest's `api` (the native plug-in API): a module can use ABI 2 while its manifest says +`"api": 1`. Because an ABI 2 module reads other times, its layer is keyed on the layer time in +the render cache; ABI 1 modules keep the time-independent key. + +Each call gets a fuel budget proportional to the frame size; a module that loops forever stops +with an error instead of hanging the app. #### Example @@ -324,8 +339,10 @@ register_plugin(Arc::new(Swap(manifest)))?; // before the UI builds its menus ## Plug-in API 2 (native plug-ins) API 2 is for Rust plug-ins and for hosts that bring effects from elsewhere (such as -[external effect helpers](external-helpers.md)); the WebAssembly ABI stays at 1. Manifests with `"api": 1` or `2` are accepted, -`effect.plugins.list` reports `api: 2`, and up to 1024 plug-in effects can be registered. +[external effect helpers](external-helpers.md)); the WebAssembly ABI is versioned separately +(ABI 2 adds the neighbouring frames, see [WebAssembly ABI](#webassembly-abi)). Manifests with +`"api": 1` or `2` are accepted, `effect.plugins.list` reports `api: 2`, and up to 1024 plug-in +effects can be registered. * **New parameter types**: `point3` (`default` `[x, y, z]`; x, y arrive in buffer pixels, z as is), `layer` (another layer of the composition; flattens to its id, -1 for none, read with @@ -354,9 +371,11 @@ API 2 is for Rust plug-ins and for hosts that bring effects from elsewhere (such ### Compatibility -The API version is checked when a plug-in loads: a manifest or module for another version is -refused with a message. Within a version the contract above does not change; additions (new -parameter types, new optional exports) bump the version. +The API version is checked when a plug-in loads: this build implements plug-in API 1 and 2 and +WebAssembly ABI 1 and 2; a manifest or module for another version is refused with a message. +ABI 1 modules load and render unchanged; ABI 2 modules add `ec_render2` and the neighbouring +frames. Within a version the contract above does not change; additions (new parameter types, +new optional exports) bump the version. ## Plug-in Library diff --git a/examples/plugins/posterize-bands/Cargo.toml b/examples/plugins/posterize-bands/Cargo.toml index 825d704aa..65bbbaaaf 100644 --- a/examples/plugins/posterize-bands/Cargo.toml +++ b/examples/plugins/posterize-bands/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "posterize-bands" -description = "Example EffectCraft effect plug-in (WebAssembly, plug-in API v1): posterizes luminance into tinted bands" +description = "Example EffectCraft effect plug-in (WebAssembly ABI 1): posterizes luminance into tinted bands" version = "1.0.0" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/examples/plugins/posterize-bands/src/lib.rs b/examples/plugins/posterize-bands/src/lib.rs index 9af709433..77026a16f 100644 --- a/examples/plugins/posterize-bands/src/lib.rs +++ b/examples/plugins/posterize-bands/src/lib.rs @@ -1,4 +1,4 @@ -//! Posterize Bands: an example EffectCraft effect plug-in (plug-in API v1, see +//! Posterize Bands: an example EffectCraft effect plug-in (WebAssembly ABI 1, see //! `docs/plugins.md`). It quantizes each pixel's luminance into `levels` bands and mixes a tint //! colour in by `mix` percent, optionally only above a `threshold`. //! From 9a0a198ff1e69ed8f62061db1cc739abfe9ede58 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Mon, 9 Nov 2026 18:27:04 +0530 Subject: [PATCH 41/94] Preview: the window stays smooth while lanes render on the GPU (#3345) Since #595 (v0.19.0) a playing preview and Cache Frames When Idle render one frame per lane, a lane per core, each recording, submitting and reading back a whole frame on the GPU the window draws with. On a GPU that can't keep up, the window froze for hundreds of milliseconds to seconds and the playhead jumped ahead when it came back. Lanes now take a GPU frame only while fewer than a limit of them composite. The limit starts at one lane and adapts to the window's frame gaps while a preview plays: a stall (>100 ms) with lanes compositing halves it, half a second of smooth frames (<50 ms) with every allowed lane busy adds one, up to every lane; a limit that stalled is tried again only after 10 s. Frames on the CPU (Software Only, no or retired GPU, GPU memory pressure) and disk-cache hits use every lane; the pool and the viewer's frame are not limited, and frames ahead of the playhead still start first. --- crates/ui-egui/src/frames.rs | 126 ++++++++++++++++++++-- crates/ui-egui/src/frames/tests.rs | 166 +++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 968b2d791..8bfcd73e8 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -54,6 +54,14 @@ const GPU_BUDGET: usize = 1 << 30; 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); +/// The window's frame gaps that adapt [`Queue::gpu_limit`] while a preview plays: a longer one +/// is a stall, shorter ones for a while are smooth. +const UI_STALL: std::time::Duration = std::time::Duration::from_millis(100); +const UI_SMOOTH: std::time::Duration = std::time::Duration::from_millis(50); +/// How long the window must stay smooth before one more lane composites on the GPU, and before it +/// tries again a limit that stalled. +const GPU_LANE_STEP: std::time::Duration = std::time::Duration::from_millis(500); +const GPU_LANE_RETRY: std::time::Duration = std::time::Duration::from_secs(10); mod f16; pub use f16::{F16_PIXEL_BYTES, F16Frame}; @@ -639,7 +647,7 @@ impl Default for Frames { }) .collect(); #[cfg(not(target_arch = "wasm32"))] - let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), ..Default::default() }; + let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), gpu_limit: 1, ..Default::default() }; #[cfg(target_arch = "wasm32")] let queue = Queue::default(); Frames { @@ -1282,6 +1290,8 @@ impl Frames { #[cfg(not(target_arch = "wasm32"))] let moved = q.playhead.is_some() != playhead.is_some(); q.playhead = playhead; + // (called once per frame of the window while a preview plays) + let more_gpu = q.ui_frame(playhead.map(|_| web_time::Instant::now())); if let Some(p) = playhead && let Ok(mut inf) = self.inflight.lock() { @@ -1293,6 +1303,13 @@ impl Frames { !gone }); } + // A lane more may composite: one that stopped at the limit starts again. + #[cfg(not(target_arch = "wasm32"))] + if more_gpu && !moved { + self.start_lanes(&mut q, 1); + } + #[cfg(target_arch = "wasm32")] + let _ = more_gpu; #[cfg(not(target_arch = "wasm32"))] if moved { self.start_lanes(&mut q, usize::MAX); @@ -1610,6 +1627,17 @@ impl Drop for Draining { } } +/// A lane's hold on one of the [`Queue::gpu_limit`] GPU slots (see [`Queue::gpu_lanes`]), given back when dropped, +/// even by a panic. +struct GpuSlot<'a>(&'a Mutex); + +impl Drop for GpuSlot<'_> { + fn drop(&mut self) { + let mut q = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + q.gpu_lanes = q.gpu_lanes.saturating_sub(1); + } +} + impl Worker { /// Render queued jobs, most important first, until the queue is empty. /// @@ -1636,19 +1664,25 @@ impl Worker { /// idle again. fn run_next(&self, lane: Option) -> bool { let Worker { queue, content_keys, .. } = self; - let job = { + // (asked before the queue's lock: it takes the cache's) + let gpu = lane.is_some() && self.gpu_ready(); + let (job, mut slot) = { let Ok(mut q) = queue.lock() else { return false }; - let Some(i) = q.next(lane.is_some()) else { + let Some(i) = q.next(lane.is_some(), gpu) else { q.idle_lanes.extend(lane); return false; }; let job = q.jobs.swap_remove(i); + let slot = (gpu && q.gpu_job(&job)).then(|| { + q.gpu_lanes += 1; + GpuSlot(queue) + }); q.running.insert(job.key); if job.urgent { q.urgent_running += 1; q.urgent_cancels.push((job.key, job.cancel.clone())); } - job + (job, slot) }; let t0 = web_time::Instant::now(); // (the drop generation before the frame reads anything: File ▸ Reload Footage meanwhile @@ -1657,6 +1691,10 @@ impl Worker { job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts)), dc.drop_generation())); let from_disk = disk.as_ref().and_then(|(dc, k, _)| self.disk_frame(dc, *k, &job)); let disk_hit = from_disk.is_some(); + if disk_hit { + // Nothing for the GPU to do: another lane may composite meanwhile. + slot = None; + } // `files`: the source files the frame was made of (see `render::reads`). let (ci, files) = match from_disk { Some(hit) => hit, @@ -1683,6 +1721,8 @@ impl Worker { // and compression. Persistence still runs on this worker, using the same snapshot. let since = disk.as_ref().map(|(dc, _, since)| DiskSince::of(dc, *since)); self.finish(&job, ci.clone(), Some(files.clone()), since, t0); + // The GPU's part is done; storing it on disk is the CPU's. + drop(slot); if !disk_hit && let Some((dc, k, since)) = &disk && put_disk_frame(dc, *k, &ci, &files, self.virtual_id.as_deref(), *since) @@ -1951,6 +1991,24 @@ struct Queue { /// caching left the same gaps, and held up the viewer's frame when it shared a piece. lanes: usize, idle_lanes: Vec, + /// Lanes compositing a frame on the GPU, at most [`Queue::gpu_limit`]. The GPU is the + /// viewer's device: each lane records, submits and reads back a whole frame on it, and the + /// window can't draw until that work is done. With a lane per core on a GPU that can't keep + /// up, the window froze for hundreds of milliseconds at a time while a preview played, and + /// the preview jumped ahead when it came back (#3345). The other lanes go on with the frames + /// that don't need the GPU (the disk cache's, or all of them while the GPU is out of memory + /// or retired); the pool and the viewer's frame are not counted. + gpu_lanes: usize, + /// How many lanes may composite on the GPU at a time (at least 1, see [`Queue::ui_frame`]). + gpu_limit: usize, + /// The limit that last stalled the window: tried again only after [`GPU_LANE_RETRY`]. + gpu_stalled_at: usize, + /// While a preview plays: the window's last frame, and since when it has been smooth. + ui_last: Option, + ui_smooth_since: Option, + /// Tests: as if there were a GPU compositor (no adapter is there). + #[cfg(test)] + fake_gpu: bool, } impl Queue { @@ -1959,9 +2017,63 @@ impl Queue { self.lanes > 0 && !j.urgent && (j.background || self.playhead.is_some()) } - /// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own. - fn next(&self, lane: bool) -> Option { - self.best_of(|j| self.on_lane(j) == lane) + /// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own. `gpu`: + /// the GPU composites (a lane's frames), so a lane takes no GPU frame while + /// [`Queue::gpu_limit`] lanes are compositing. + fn next(&self, lane: bool, gpu: bool) -> Option { + let gpu_full = gpu && self.gpu_lanes >= self.gpu_limit.max(1); + self.best_of(|j| self.on_lane(j) == lane && !(gpu_full && self.gpu_job(j))) + } + + /// The window drew a frame at `now` (`None`: no preview plays). A window that stalls with + /// lanes compositing gets fewer of them: the GPU can't keep up with them and the window. One + /// that stays smooth while every allowed lane composites gets one more, up to all of them: a + /// GPU that keeps up renders the preview on every lane. A limit that stalled is tried again + /// only after a longer smooth spell, so the window doesn't stall over and over. `true` when + /// one more lane may composite. + fn ui_frame(&mut self, now: Option) -> bool { + let Some(now) = now else { + self.ui_last = None; + return false; + }; + let limit = self.gpu_limit.max(1); + let gap = self.ui_last.map(|last| now.saturating_duration_since(last)); + self.ui_last = Some(now); + let Some(gap) = gap else { + self.ui_smooth_since = Some(now); + return false; + }; + if gap > UI_STALL && self.gpu_lanes > 0 { + self.gpu_stalled_at = limit; + self.gpu_limit = (limit / 2).max(1); + self.ui_smooth_since = Some(now); + } else if gap > UI_SMOOTH { + self.ui_smooth_since = Some(now); + } else { + let since = *self.ui_smooth_since.get_or_insert(now); + let wait = if limit + 1 >= self.gpu_stalled_at && self.gpu_stalled_at > 0 { GPU_LANE_RETRY } else { GPU_LANE_STEP }; + if self.gpu_lanes >= limit && limit < self.lanes && now.saturating_duration_since(since) >= wait { + self.gpu_limit = limit + 1; + self.ui_smooth_since = Some(now); + return true; + } + } + false + } + + /// Whether `j` is composited on the GPU when the GPU is ready: there is a GPU compositor and + /// the frame's renderer uses it (as `Renderer::active_accel` decides: Software Only keeps + /// every frame on the CPU). + fn gpu_job(&self, j: &Job) -> bool { + let adapter = j.src.gpu.is_some(); + #[cfg(test)] + let adapter = adapter || self.fake_gpu; + adapter + && match j.opts.backend { + effectcraft_engine::render::Backend::Cpu => false, + effectcraft_engine::render::Backend::Gpu => true, + effectcraft_engine::render::Backend::Auto => j.src.project.settings.gpu_acceleration, + } } /// The job that matters most: urgent first (newest), then, while a preview plays, the diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index 360ddf1b6..d980f8565 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -610,15 +610,21 @@ struct ParallelClip { started: std::sync::atomic::AtomicUsize, /// Frames started by the time frame 1 was done. started_at_first: std::sync::atomic::AtomicUsize, + /// Frames decoding now, and the most at a time. + active: std::sync::atomic::AtomicUsize, + most_active: std::sync::atomic::AtomicUsize, } impl FootageSource for ParallelClip { fn frame(&self, _: ItemId, _: &effectcraft_engine::project::Footage, t: Tick) -> Option> { self.started.fetch_add(1, Ordering::SeqCst); + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.most_active.fetch_max(active, Ordering::SeqCst); (0..16).into_par_iter().for_each(|_| (0..4).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1)))); if effectcraft_engine::time::FrameRate::FPS_30.frame_at(t) == 1 { self.started_at_first.store(self.started.load(Ordering::SeqCst), Ordering::SeqCst); } + self.active.fetch_sub(1, Ordering::SeqCst); Some(Arc::new(effectcraft_engine::render::Image::new(4, 4))) } } @@ -729,6 +735,166 @@ fn queued_frames_render_when_a_preview_starts_or_stops() { assert_eq!(f.cached_frames(&series).len(), 119); } +/// Waits for the lanes to be idle (after their last frame they give back their GPU slot) and +/// checks every GPU slot was given back. +fn wait_for_idle_lanes(f: &Frames) { + for _ in 0..2000 { + let q = f.queue.lock().unwrap(); + if q.idle_lanes.len() == f.lanes.len() { + break; + } + drop(q); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let q = f.queue.lock().unwrap(); + assert_eq!(q.idle_lanes.len(), f.lanes.len(), "every lane is idle again"); + assert_eq!(q.gpu_lanes, 0, "every lane gave its GPU slot back"); +} + +/// Plays a preview of `frames` frames of a [`parallel_clip`] as the viewer renders them (the +/// project's renderer: `backend` Auto) or with `backend`, as if there were a GPU compositor +/// (`gpu`), `retired` or with the project on Mercury Software Only (`!gpu_acceleration`), and +/// waits for them: the frame cache and the most frames that rendered at a time. +fn play_preview(gpu: bool, retired: bool, backend: effectcraft_engine::render::Backend, gpu_acceleration: bool) -> (Frames, usize) { + use effectcraft_engine::time::FrameRate; + let (f, footage, mut src, cid, series) = parallel_clip(); + Arc::make_mut(&mut src.project).settings.gpu_acceleration = gpu_acceleration; + f.queue.lock().unwrap().fake_gpu = gpu; + if retired { + f.retire_gpu(); + } + let opts = RenderOpts { backend, ..RenderOpts::default() }; + f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false })); + let frames = 24; + for frame in 1..=frames { + f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), opts); + } + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), frames as usize); + wait_for_idle_lanes(&f); + let most = footage.most_active.load(Ordering::SeqCst); + (f, most) +} + +/// Every lane composited a playing preview's frame on the GPU at the same time, on the device +/// the window draws with: the window froze for hundreds of milliseconds and the preview jumped +/// ahead (#3345). A preview starts on one GPU lane (see [`Queue::ui_frame`]). +#[test] +fn a_preview_composites_on_a_few_lanes_at_a_time() { + use effectcraft_engine::render::Backend; + for backend in [Backend::Auto, Backend::Gpu] { + let (_, most) = play_preview(true, false, backend, true); + assert!(most <= 1, "{most} lanes composited on the GPU at once ({backend:?})"); + } +} + +/// Frames that render on the CPU use every lane: with no GPU compositor, with the GPU retired, +/// and with Mercury Software Only (the project's renderer, or the CPU backend). +#[test] +fn frames_on_the_cpu_render_on_every_lane() { + use effectcraft_engine::render::Backend; + for (gpu, retired, backend, gpu_acceleration) in + [(false, false, Backend::Auto, true), (true, true, Backend::Auto, true), (true, false, Backend::Auto, false), (true, false, Backend::Cpu, true)] + { + let (f, most) = play_preview(gpu, retired, backend, gpu_acceleration); + if f.lanes.len() > 2 { + assert!(most > 2, "frames on the CPU rendered on {most} lanes at a time ({gpu} {retired} {backend:?} {gpu_acceleration})"); + } + } +} + +/// A queue of `lanes` lanes, `busy` of them compositing on the GPU. +fn gpu_queue(lanes: usize, busy: usize) -> Queue { + Queue { lanes, gpu_lanes: busy, gpu_limit: 1, ..Default::default() } +} + +/// Window frames every `gap` ms from `t` for `ms` ms; the time after the last. +fn ui_frames(q: &mut Queue, t: web_time::Instant, gap: u64, ms: u64) -> web_time::Instant { + let mut t = t; + for _ in 0..ms / gap { + t += std::time::Duration::from_millis(gap); + q.ui_frame(Some(t)); + } + t +} + +/// A GPU that keeps up with the window gets every lane, one more each half second the window +/// stays smooth with every allowed lane compositing. +#[test] +fn a_smooth_window_gives_the_gpu_every_lane() { + let mut q = gpu_queue(8, 0); + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + // Nothing compositing: the limit isn't what holds the preview back. + let t = ui_frames(&mut q, t, 16, 2000); + assert_eq!(q.gpu_limit, 1); + let mut t = t; + for _ in 0..20 { + q.gpu_lanes = q.gpu_limit; + t = ui_frames(&mut q, t, 16, 520); + } + assert_eq!(q.gpu_limit, 8, "no more than the lanes"); +} + +/// A window that stalls while lanes composite halves the limit, and the limit that stalled is +/// tried again only after a long smooth spell; a window that is only slow keeps the limit. +#[test] +fn a_stalling_window_takes_lanes_from_the_gpu() { + let mut q = gpu_queue(8, 4); + q.gpu_limit = 4; + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + let t = ui_frames(&mut q, t, 300, 300); + assert_eq!((q.gpu_limit, q.gpu_stalled_at), (2, 4)); + let t = ui_frames(&mut q, t, 80, 5000); + assert_eq!(q.gpu_limit, 2, "a slow (not stalling) window keeps the limit"); + q.gpu_lanes = 2; + let t = ui_frames(&mut q, t, 16, 600); + assert_eq!(q.gpu_limit, 3); + q.gpu_lanes = 3; + let t = ui_frames(&mut q, t, 16, 2000); + assert_eq!(q.gpu_limit, 3, "4 stalled: it waits longer"); + ui_frames(&mut q, t, 16, 10_000); + assert_eq!(q.gpu_limit, 4); + // A stall with nothing compositing isn't the GPU's. + let mut q = gpu_queue(8, 0); + q.gpu_limit = 4; + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + ui_frames(&mut q, t, 500, 500); + assert_eq!(q.gpu_limit, 4); + // Never below one lane; a stopped preview's pause is no stall. + let mut q = gpu_queue(8, 1); + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + ui_frames(&mut q, t, 400, 400); + assert_eq!(q.gpu_limit, 1); + q.gpu_limit = 2; + q.ui_frame(None); + q.ui_frame(Some(t + std::time::Duration::from_secs(5))); + assert_eq!(q.gpu_limit, 2); +} + +/// A preview that stops while lanes composite: their frames finish, the queued ones move to the +/// pool and render, the viewer's frame is not held up, and every GPU slot is given back. +#[test] +fn a_preview_stopping_mid_render_gives_the_gpu_slots_back() { + use effectcraft_engine::time::FrameRate; + let (f, _, src, cid, series) = parallel_clip(); + f.queue.lock().unwrap().fake_gpu = true; + let opts = RenderOpts { backend: effectcraft_engine::render::Backend::Auto, ..RenderOpts::default() }; + f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false })); + for frame in 1..=24 { + f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), opts); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + f.set_playhead(None); + f.request_urgent(&src, FrameKey { frame: 100, ..series }, cid, FrameRate::FPS_30.tick_of(100), opts); + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), 25); + wait_for_idle_lanes(&f); +} + /// The cache is full. A zoom (or preview-resolution) change left the frames of the old /// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back /// at went before the ones of the abandoned zoom. The other size's frames go first, then the From 43e82587fdced6c9942a64eef03206ba3fb9b7f3 Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 18:10:06 +0100 Subject: [PATCH 42/94] #3357: avoid whole-layer cover for Drop Shadow --- crates/effects/src/lib.rs | 4 +- crates/render/src/tests.rs | 1 + crates/render/src/tests/drop_shadow_cover.rs | 142 +++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 crates/render/src/tests/drop_shadow_cover.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 771d51068..019596f80 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -451,7 +451,9 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3 /// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge /// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these /// skips the renderer's cover of the whole layer rectangle (#1205). -pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"]; +/// Drop Shadow also pads by its distance and softness on both CPU and GPU: covering the +/// layer rectangle first only inflates working images on highly scaled text and shapes (#3357). +pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace", "ec.perspective.dropshadow"]; /// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels /// of the resampling pyramid used for downscaled layers. diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 59fa0fadc..e7dd02182 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -11,6 +11,7 @@ mod auto; mod aux_channels; mod collapse; mod color; +mod drop_shadow_cover; mod footage_size; mod frame_blend; mod guides; diff --git a/crates/render/src/tests/drop_shadow_cover.rs b/crates/render/src/tests/drop_shadow_cover.rs new file mode 100644 index 000000000..94d4ca67b --- /dev/null +++ b/crates/render/src/tests/drop_shadow_cover.rs @@ -0,0 +1,142 @@ +use super::*; +use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer}; +use effectcraft_effects::Buf; + +// A synthetic painted source avoids installed-font dependencies and the multi-gigabyte +// allocation in #3357 while exercising the same text/shape effect-stack bounds. +fn source(scale: f64) -> Buf { + let mut img = Image::new(20, 12); + for y in 2..10 { + for x in 2..18 { + img.set(x, y, [0.8, 0.2, 0.1, 1.0]); + } + } + Buf { img, offset: [7.0, -3.0], scale } +} + +#[test] +fn drop_shadow_does_not_cover_the_zoomed_layer_rectangle() { + let (mut p, cid, comp) = setup(); + let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (0, 0), None); + add_effect_200(&mut p, &mut layer, "ec.perspective.dropshadow", &[("distance", Value::Scalar(2.0)), ("softness", Value::Scalar(1.0))]); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO); + let out = r.apply_effects(&ctx, &layer, source(4.0), false); + // The old cover makes this at least 800 x 400. Alignment and the shadow's + // own padding need less than 128 pixels per side for this source. + assert!(out.img.width < 128 && out.img.height < 128, "{} x {}", out.img.width, out.img.height); +} + +#[test] +fn drop_shadow_matches_the_covered_reference() { + for scale in [0.5, 1.0, 4.0] { + for only in [false, true] { + for direction in [0.0, 135.0, 270.0] { + let (mut p, cid, comp) = setup(); + let mut layer = build::layer(&mut p, &comp, "Text", LayerSource::Text, (0, 0), None); + add_effect_200( + &mut p, + &mut layer, + "ec.perspective.dropshadow", + &[ + ("distance", Value::Scalar(3.5)), + ("softness", Value::Scalar(2.0)), + ("direction", Value::Scalar(direction)), + ("shadowOnly", Value::Bool(only)), + ], + ); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO); + let input = source(scale); + let mut covered = input.clone(); + let (size, origin) = ctx.effect_bounds(&layer); + covered.rebase(origin); + covered.cover(size); + covered.rebase([-origin[0], -origin[1]]); + let actual = r.apply_effects(&ctx, &layer, input, false); + let reference = r.apply_effects(&ctx, &layer, covered, false); + // Compare the union in layer-pixel coordinates, including the shadow outside + // the original content. Buffer origins differ by integral pixels. + let x0 = (-actual.offset[0]).min(-reference.offset[0]) as i32; + let y0 = (-actual.offset[1]).min(-reference.offset[1]) as i32; + let x1 = (f64::from(actual.img.width) - actual.offset[0]).max(f64::from(reference.img.width) - reference.offset[0]) as i32; + let y1 = (f64::from(actual.img.height) - actual.offset[1]).max(f64::from(reference.img.height) - reference.offset[1]) as i32; + for y in y0..y1 { + for x in x0..x1 { + let sample = |b: &Buf| b.img.sample_bilinear(f64::from(x) + b.offset[0] + 0.5, f64::from(y) + b.offset[1] + 0.5); + let (a, b) = (sample(&actual), sample(&reference)); + for c in 0..4 { + assert!( + (a[c] - b[c]).abs() < 1e-5, + "scale {scale}, only {only}, direction {direction}, ({x}, {y}), channel {c}: {} != {}", + a[c], + b[c] + ); + } + } + } + } + } + } +} +#[test] +fn drop_shadow_with_glow_stays_local_but_other_effects_keep_cover() { + for other in ["ec.stylize.glow", "ec.color.tint"] { + let (mut p, cid, comp) = setup(); + let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (0, 0), None); + add_effect_200(&mut p, &mut layer, "ec.perspective.dropshadow", &[("distance", Value::Scalar(2.0)), ("softness", Value::Scalar(1.0))]); + let values = if other == "ec.stylize.glow" { vec![("radius", Value::Scalar(1.0))] } else { vec![] }; + add_effect_200(&mut p, &mut layer, other, &values); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO); + let out = r.apply_effects(&ctx, &layer, source(4.0), false); + if other == "ec.stylize.glow" { + assert!(out.img.width < 192 && out.img.height < 192, "{} x {}", out.img.width, out.img.height); + } else { + assert!(out.img.width >= 800 && out.img.height >= 400); + } + } +} +#[test] +fn parented_text_and_shape_keep_small_working_images_at_2200_percent() { + for text in [false, true] { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(80, 40, 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 parent = build::layer(&mut p, &comp, "Null", LayerSource::Null, (100, 100), None); + parent.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0; 3]); + parent.props.prop_mut("transform/position").unwrap().value = Value::Vec3([40.0, 20.0, 0.0]); + parent.props.prop_mut("transform/scale").unwrap().value = Value::Vec3([2200.0, 2200.0, 100.0]); + let source = if text { LayerSource::Text } else { LayerSource::Shape }; + let mut layer = build::layer(&mut p, &comp, "Painted", source, (80, 40), None); + layer.parent = Some(parent.id); + layer.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0; 3]); + layer.props.prop_mut("transform/position").unwrap().value = Value::Vec3([0.0; 3]); + if text { + layer.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "I".into(), size: 8.0, ..Default::default() })); + } else { + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [4.0, 4.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0; 4]); + let group = build::shape_group(&mut ids, "Rectangle", vec![rect, fill]); + p.next_id = next; + layer.props.sub_mut("contents").unwrap().children.push(group.into()); + } + add_effect(&mut p, &mut layer, "ec.stylize.glow", [80.0, 40.0], &[("radius", Value::Scalar(0.1))]); + add_effect(&mut p, &mut layer, "ec.perspective.dropshadow", [80.0, 40.0], &[("distance", Value::Scalar(0.1)), ("softness", Value::Scalar(0.1))]); + p.comp_mut(cid).unwrap().layers = vec![layer, parent].into(); + let comp = p.comp(cid).unwrap(); + let layer = &comp.layers[0]; + for scale in [1.0, 0.5] { + let r = Renderer::new(&p, &NoFootage, RenderOpts { scale, ..Default::default() }); + let ctx = EvalCtx::new(&p, cid, comp, Tick::ZERO); + let input = r.layer_input(&ctx, layer, usize::MAX).unwrap(); + assert!(input.img.width < 512 && input.img.height < 512, "text={text}, output scale={scale}: {} x {}", input.img.width, input.img.height); + assert!(input.img.data.iter().any(|pixel| pixel[3] > 0.5), "the painted source is not empty"); + let frame = r.comp_frame(cid, Tick::ZERO); + assert!(frame.data.iter().any(|pixel| pixel[3] > 0.5), "the layer still draws in the composition"); + } + } +} From 45e81e603eb36002cd90363c9987bdb07b98808a Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 18:16:57 +0100 Subject: [PATCH 43/94] #3335: isolate proxy caches and settle small render batches --- crates/export/src/pipeline.rs | 57 +++++++++++++++++++++++++- crates/render/src/layers.rs | 8 ++++ crates/render/src/tests.rs | 1 + crates/render/src/tests/proxy_cache.rs | 45 ++++++++++++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 crates/render/src/tests/proxy_cache.rs diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index c233435a8..922b1b4ea 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -530,10 +530,21 @@ impl<'a> Cx<'a> { self.decide(Choice { renderer: crate::JobRenderer::Gpu, gpu_ms: None, cpu_ms: None, why: "the project's renderer (too few frames to measure)" }); return vec![]; } - // Small batches (few threads) measure one frame each; a batch too small for even that - // (a resumed sequence skipping frames) renders on the GPU and leaves it to the next. + // Nothing will be emitted for a skipped batch, so a later batch can still measure. + if ks.is_empty() { + return vec![]; + } + // A resumed sequence can have only one or two missing frames in its first batch. + // Settle before emitting them: measuring a later batch could otherwise switch to the + // CPU after GPU pixels have already been written, including across output modules. let n = PROBE_FRAMES.min(ks.len().saturating_sub(1) / 2); if n == 0 { + self.decide(Choice { + renderer: crate::JobRenderer::Gpu, + gpu_ms: None, + cpu_ms: None, + why: "the project's renderer (too few pending frames to measure)", + }); return vec![]; } let (Some(&first), Some(gpu_ks), Some(cpu_ks)) = (ks.first(), ks.get(1..1 + n), ks.get(1 + n..ks.len().min(1 + n + PROBE_CPU_FRAMES))) else { @@ -1198,6 +1209,48 @@ mod tests { assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator"); } + /// A resumed sequence can start with one or two missing frames. Once emitted, their + /// compositor must also be used by every later batch and the item's other output modules. + #[test] + fn a_small_first_batch_settles_the_renderer_before_emitting_frames() { + if std::env::var_os(RENDERER_ENV).is_some() { + return; + } + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + 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 = NoAccel; + for n in [1, 2] { + let shared = Mutex::new(None); + let job = Job { + project: &p, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: Some(&accel), + comp: cid, + settings: &settings, + output: &output, + path: "/r/f_[#####].png", + sink: None, + nested_switches: true, + options: crate::JobOptions { renderer: Some(&shared), ..Default::default() }, + }; + let cx = Cx::new(&job); + let empty = cx.render_frames(Vec::new(), |_, on| on, |_, on| on); + assert!(empty.is_empty()); + assert_eq!(cx.choice(), None, "skipped batches do not choose"); + let first = cx.render_frames((0..n).collect(), |_, on| on, |_, on| on); + assert_eq!(first, vec![crate::JobRenderer::Gpu; n as usize]); + assert_eq!(*shared.lock().unwrap(), Some(Backend::Gpu), "settled before any pixels are emitted"); + let later = cx.render_frames((n..12).collect(), |_, on| on, |_, on| on); + assert!(later.iter().all(|on| *on == crate::JobRenderer::Gpu)); + assert_eq!(Cx::new(&job).backend(), crate::JobRenderer::Gpu, "the next output module follows"); + } + } + #[test] fn a_job_that_chose_the_cpu_is_not_capped() { // #3335: the CPU's frame-parallel compositor is the point of choosing it; a GPU project diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index df976a857..cfae5dbab 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -295,7 +295,15 @@ impl<'a> Renderer<'a> { /// in this render ([`Self::mask_blur`]'s gate: the render options, the precomp layers above). /// The key has the layer's own motion blur state, but a mask set to On blurs without it. pub(crate) fn mask_gate_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { + // Queue jobs share this cache but may override Proxy Use differently. The item's + // proxy switch alone does not describe the source a renderer actually reads. let k = key?; + let k = match self.opts.proxy { + effectcraft_project::render_queue::ProxyUse::CurrentSettings => k, + effectcraft_project::render_queue::ProxyUse::UseAll => cache::derive(k, 0x7072_6f78_7901), + effectcraft_project::render_queue::ProxyUse::UseCompOnly => cache::derive(k, 0x7072_6f78_7902), + effectcraft_project::render_queue::ProxyUse::UseNone => cache::derive(k, 0x7072_6f78_7903), + }; let masked = layer.masks().is_some_and(|m| !m.children.is_empty()); Some(if masked && !self.mask_blur(ctx, layer).allowed { cache::derive(k, 0x6d62_6f66) } else { k }) } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 59fa0fadc..4ff292d9f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -20,6 +20,7 @@ mod mask_feather; mod motion_tile; mod nested; mod paint; +mod proxy_cache; mod remap; mod roi; mod stretch_limits; diff --git a/crates/render/src/tests/proxy_cache.rs b/crates/render/src/tests/proxy_cache.rs new file mode 100644 index 000000000..85343a25d --- /dev/null +++ b/crates/render/src/tests/proxy_cache.rs @@ -0,0 +1,45 @@ +use super::setup; +use crate::eval::EvalCtx; +use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer}; +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Proxy, build, render_queue::ProxyUse}; +use effectcraft_time::Tick; +use std::sync::Arc; + +/// A source and its proxy deliberately have different pixels. +struct Pictures; +impl FootageSource for Pictures { + fn frame(&self, _: ItemId, footage: &Footage, _: Tick) -> Option> { + let color = if footage.path == "proxy" { [0.0, 1.0, 0.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }; + Some(Arc::new(Image::filled(16, 16, color))) + } +} + +/// A queue run reuses its layer cache across jobs with different Proxy Use settings. +/// Time effects read layer_input, which must never serve another job's source pixels. +#[test] +fn shared_layer_cache_separates_proxy_use() { + let (mut p, cid, comp) = setup(); + let footage = Footage { path: "original".into(), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() }; + let item = p.add_item("Footage", Label::Red, None, ItemKind::Footage(footage.clone())); + p.item_mut(item).unwrap().proxy = Some(Box::new(Proxy { footage: Footage { path: "proxy".into(), ..footage }, enabled: false })); + let layer = build::layer(&mut p, &comp, "Footage", LayerSource::Footage { item }, (16, 16), None); + p.comp_mut(cid).unwrap().layers.push(layer); + for enabled in [false, true] { + p.item_mut(item).unwrap().proxy.as_mut().unwrap().enabled = enabled; + let cache = LayerCache::default(); + let ctx = EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO); + let layer = &ctx.comp.layers[0]; + for proxy in [ProxyUse::UseAll, ProxyUse::UseNone, ProxyUse::CurrentSettings, ProxyUse::UseCompOnly, ProxyUse::UseAll] { + let mut r = Renderer::new(&p, &Pictures, RenderOpts { proxy, ..Default::default() }); + let input = r.layer_input(&ctx, layer, 0).unwrap(); + let content = r.content_buf(&ctx, layer).unwrap(); + r.cache = Some(&cache); + assert_eq!(r.layer_input(&ctx, layer, 0).unwrap().img.data, input.img.data, "{proxy:?}, enabled={enabled}"); + assert_eq!(r.content_buf(&ctx, layer).unwrap().img.data, content.img.data, "{proxy:?}, enabled={enabled}"); + let hits = cache.stats().hits; + r.layer_input(&ctx, layer, 0).unwrap(); + assert!(cache.stats().hits > hits, "unchanged proxy settings still reuse inputs"); + } + } +} From e8634b1f4bb9a86b90fb476c9a6d6a8b5e7eba0f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:41:39 +0100 Subject: [PATCH 44/94] #3357: release retired GPU resources before CPU fallback --- crates/gpu/src/lib.rs | 25 ++++++++-- crates/gpu/src/tests.rs | 43 ++++++++++++++++ crates/render/src/pipeline.rs | 26 ++++++++-- crates/render/src/tests/auto.rs | 88 ++++++++++++++++++++++++++++++++- crates/ui-egui/src/frames.rs | 2 +- 5 files changed, 174 insertions(+), 10 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 3ede5c6b9..0d260d919 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -300,7 +300,12 @@ impl Gpu { /// Browser callers use the host/owned-device failure bridge and bounded readbacks instead /// of blocking their event loop on an error-scope future. pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { - self.ctx.check_health().map_err(FrameError::Retired)?; + if let Err(error) = self.ctx.check_health() { + // Queued jobs may still own this context after the UI detached it. Release its + // working caches before CPU fallback adds host-memory pressure (#3357). + self.ctx.discard_kept(); + return Err(FrameError::Retired(error)); + } #[cfg(not(target_arch = "wasm32"))] { let oom = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); @@ -314,7 +319,7 @@ impl Gpu { // 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(); if errors.is_empty() { - return self.ctx.check_health().map(|()| out).map_err(FrameError::Retired); + return self.finish_memory_attempt(out); } // Its textures go back to the pool when dropped: drop them before emptying it. drop(out); @@ -325,7 +330,21 @@ impl Gpu { #[cfg(target_arch = "wasm32")] { let out = f(); - self.ctx.check_health().map(|()| out).map_err(FrameError::Retired) + self.finish_memory_attempt(out) + } + } + + /// Retirement without a scoped error (for example, a readback timeout) must release + /// the same retained resources as a rejected allocation before the caller retries. + fn finish_memory_attempt(&self, out: T) -> Result { + match self.ctx.check_health() { + Ok(()) => Ok(out), + Err(error) => { + // Dropping the result can return leased textures to the pool. + drop(out); + self.ctx.discard_kept(); + Err(FrameError::Retired(error)) + } } } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index b88bac0be..e60613238 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -996,6 +996,49 @@ fn within_memory_returns_the_work_when_there_is_room() { assert_eq!((img.width, img.height), (64, 32)); } +/// A timeout can retire a context without any device error scope reporting a failure. +/// Queued jobs keep that context alive, so CPU fallback must not wait for its last owner +/// to drop before releasing cached uploads and working textures (#3357). +fn retired_attempt_releases_kept_resources(retire_before: bool) { + hold_gpu_lock(); + let made = Gpu::try_headless(); + let Some(g) = require_gpu("retired memory attempt", made.as_ref()) else { return }; + let buf = Arc::new(effectcraft_effects::Buf { img: Image::new(16, 16), offset: [0.0; 2], scale: 1.0 }); + drop(g.ctx.upload_buf(&buf).expect("a small cached upload")); + drop(crate::context::Enc::new(&g.ctx).image(32, 16)); + assert_eq!(g.ctx.upload_entries(), 1); + assert!(g.ctx.pool_retained_bytes() > 0); + // An earlier frame can return a lease after retirement; it must not refill the pool. + let earlier = crate::context::Enc::new(&g.ctx).image(48, 16); + let retire = g.ctx.device_failure_notifier(); + if retire_before { + retire("synthetic readback retirement".into()); + } + let called = std::cell::Cell::new(false); + let result = g.within_memory(|| { + called.set(true); + let out = crate::context::Enc::new(&g.ctx).image(64, 16); + retire("synthetic readback retirement".into()); + out + }); + assert!(matches!(result, Err(crate::FrameError::Retired(_)))); + assert_eq!(called.get(), !retire_before); + assert_eq!(g.ctx.upload_entries(), 0, "CPU fallback must not retain uploaded layers"); + assert_eq!(g.ctx.pool_retained_bytes(), 0, "CPU fallback must not retain working textures"); + drop(earlier); + assert_eq!(g.ctx.pool_retained_bytes(), 0, "late leases cannot refill a retired pool"); +} + +#[test] +fn within_memory_releases_caches_when_already_retired() { + retired_attempt_releases_kept_resources(true); +} + +#[test] +fn within_memory_releases_caches_when_retired_during_work() { + retired_attempt_releases_kept_resources(false); +} + /// A texture the device rejected (deliberately too wide), made outside any frame under a scope /// of its own: the view a frame then makes of it is the reported validation error. fn rejected_texture(g: &Gpu) -> wgpu::Texture { diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 6c79ba7bb..114daf0e3 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -482,6 +482,22 @@ impl<'a> Renderer<'a> { /// Render a composition at comp time `t` (transparent background, comp size × scale). /// Top-level frames go to the GPU when [`Self::active_accel`] is set and handles them. pub fn comp_frame(&self, comp_id: ItemId, t: Tick) -> Image { + self.comp_frame_with(comp_id, t, false, |a| Ok::<_, std::convert::Infallible>(a.comp_frame(self, comp_id, t))).unwrap_or_else(|never| match never {}) + } + + /// Render a frame, validating the accelerated attempt before starting any CPU fallback. + /// The caller's scope must run only the accelerator, not this whole method: a GPU fault + /// must release failed resources before a potentially large CPU render (#3357). + /// A scope error returns immediately; the caller can then retry with a fresh CPU renderer + /// without accelerator-produced cache entries. Healthy declines and Auto timing retain + /// the same behavior as [`Self::comp_frame`]. Its fallback disables acceleration for + /// effects too: GPU work must never escape the supplied error scope. + pub fn comp_frame_scoped(&self, comp_id: ItemId, t: Tick, scope: impl FnOnce(&dyn Accelerator) -> Result, E>) -> Result { + self.comp_frame_with(comp_id, t, true, scope) + } + + fn comp_frame_with(&self, comp_id: ItemId, t: Tick, scoped: bool, scope: impl FnOnce(&dyn Accelerator) -> Result, E>) -> Result { + let cpu = if scoped { self.with_accel(None) } else { self.with_accel(self.accel) }; // Classic 3D runs composite on the GPU too (M12.7), so Auto sends 3D comps there as well; // Auto renders each comp on whichever compositor measured faster ([`AutoPick`]). if self.depth == 0 @@ -490,24 +506,24 @@ 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) = a.comp_frame(self, comp_id, t) { + if let Some(img) = scope(a)? { if let Some((p, key)) = auto { p.record(key, true, t0.elapsed().as_secs_f64() * 1e3); } - return img; + return Ok(img); } if let Some((p, key)) = auto { p.declined(key); } } let t0 = web_time::Instant::now(); - let img = self.comp_frame_cpu(comp_id, t); + 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); } - return img; + return Ok(img); } - self.comp_frame_cpu(comp_id, t) + Ok(cpu.comp_frame_cpu(comp_id, t)) } /// The Auto timing history and key for a top-level frame of `comp` (`None` unless the diff --git a/crates/render/src/tests/auto.rs b/crates/render/src/tests/auto.rs index 22f1a6215..5cc503173 100644 --- a/crates/render/src/tests/auto.rs +++ b/crates/render/src/tests/auto.rs @@ -14,12 +14,13 @@ struct MockAccel { handle: bool, calls: AtomicUsize, pick: AutoPick, + effect_calls: AtomicUsize, } impl MockAccel { /// Construct a renderer stand-in without making assumptions about scheduling or speed. fn new(handle: bool) -> MockAccel { - MockAccel { handle, calls: Default::default(), pick: Default::default() } + MockAccel { handle, calls: Default::default(), pick: Default::default(), effect_calls: Default::default() } } /// Number of full-frame requests dispatched to this accelerator. fn calls(&self) -> usize { @@ -42,6 +43,7 @@ impl Accelerator for MockAccel { }) } fn supports_effect(&self, _id: &str) -> bool { + self.effect_calls.fetch_add(1, Ordering::SeqCst); false } fn effects(&self, _chain: &[crate::FxStep], _buf: &crate::Buf, _levels: Option) -> Option { @@ -160,3 +162,87 @@ fn auto_pick_follows_changing_costs() { assert!(pick.stats(AutoKey::new(ItemId(7), 1.0, true)).is_none()); assert!(pick.stats(AutoKey::new(ItemId(7), 0.5, false)).is_none()); } + +/// Counts actual CPU source reads and rejects any CPU fallback inside a GPU error scope. +struct ScopedFootage { + in_scope: std::sync::atomic::AtomicBool, + reads: AtomicUsize, +} +impl crate::FootageSource for ScopedFootage { + fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option> { + assert!(!self.in_scope.load(Ordering::SeqCst), "CPU fallback ran before GPU validation/cleanup"); + self.reads.fetch_add(1, Ordering::SeqCst); + Some(std::sync::Arc::new(crate::Image::filled(f.width, f.height, [1.0, 0.0, 0.0, 1.0]))) + } +} + +fn scoped_project() -> (Project, ItemId) { + let (mut p, cid) = project(); + let comp = p.comp(cid).unwrap().clone(); + let footage = effectcraft_project::Footage { + path: "synthetic.png".into(), + kind: effectcraft_project::FootageKind::Still, + width: 50, + height: 50, + has_video: true, + duration: Tick::from_seconds_f64(2.0), + ..Default::default() + }; + let item = p.add_item("Source", Label::Red, None, ItemKind::Footage(footage)); + let mut layer = build::layer(&mut p, &comp, "Source", LayerSource::Footage { item }, (50, 50), None); + super::add_effect_200(&mut p, &mut layer, "ec.blur.gaussian", &[]); + p.comp_mut(cid).unwrap().layers = vec![layer].into(); + (p, cid) +} + +/// #3357: a declined GPU frame must not allocate a CPU frame only to discard it when the +/// enclosing GPU scope reports a fault. The caller retries on a fresh CPU renderer once. +#[test] +fn failed_gpu_scope_returns_before_cpu_fallback() { + let (p, cid) = scoped_project(); + for handled in [false, true] { + let a = MockAccel::new(handled); + let src = ScopedFootage { in_scope: false.into(), reads: 0.into() }; + let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Gpu, ..Default::default() }); + r.accel = Some(&a); + let result = r.comp_frame_scoped(cid, Tick::ZERO, |a| { + src.in_scope.store(true, Ordering::SeqCst); + let frame = a.comp_frame(&r, cid, Tick::ZERO); + drop(frame); + src.in_scope.store(false, Ordering::SeqCst); + Err::, _>("device retired") + }); + assert_eq!(result.unwrap_err(), "device retired"); + assert_eq!(src.reads.load(Ordering::SeqCst), 0); + let frame = r.with_accel(None).comp_frame(cid, Tick::ZERO); + assert!(frame.get(100, 50)[3] > 0.99); + assert_eq!(src.reads.load(Ordering::SeqCst), 1); + assert_eq!(a.calls(), 1); + } +} + +/// A healthy decline closes its scope before CPU work, and still records Auto timings. +#[test] +fn healthy_gpu_decline_renders_cpu_once_outside_scope() { + let (p, cid) = scoped_project(); + let a = MockAccel::new(false); + 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); + let frame = r + .comp_frame_scoped(cid, Tick::ZERO, |a| { + src.in_scope.store(true, Ordering::SeqCst); + let frame = a.comp_frame(&r, cid, Tick::ZERO); + src.in_scope.store(false, Ordering::SeqCst); + Ok::<_, ()>(frame) + }) + .unwrap(); + assert!(frame.get(100, 50)[3] > 0.99); + assert_eq!(src.reads.load(Ordering::SeqCst), 1); + assert_eq!(a.calls(), 1); + assert_eq!(a.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap().cpu_frames, 1); + // 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!(a.effect_calls.load(Ordering::SeqCst), 0, "CPU effects must not use the GPU outside its scope"); +} diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 8bfcd73e8..72d1edba6 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -1926,7 +1926,7 @@ 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)) { + return match r.comp_frame_scoped(job.comp, job.t, |a| g.within_memory(|| a.comp_frame(&r, job.comp, job.t))) { Ok(img) => { self.gpu_succeeded(); cpu_frame(&img, &job.src.project, &job.opts) From d643e7f8f0babbf9c80aeba1fbd82dea7d9bd9f8 Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 18:55:46 +0100 Subject: [PATCH 45/94] #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 46/94] #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 47/94] #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 48/94] #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 49/94] #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 50/94] #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 51/94] 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 52/94] #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 53/94] #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 54/94] #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 55/94] #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 58a1f3df174ce9664563a72f2071bf5766129e04 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 11:01:42 +0530 Subject: [PATCH 56/94] #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 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 57/94] 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 58/94] 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 59/94] 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 60/94] 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 61/94] #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 62/94] 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 63/94] 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 64/94] #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 65/94] 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 66/94] 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 67/94] 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 68/94] 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 69/94] 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 70/94] 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 71/94] 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 72/94] 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 73/94] 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 74/94] 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 75/94] 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 76/94] 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 77/94] 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 78/94] 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 79/94] 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 80/94] 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 81/94] 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 82/94] 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 83/94] 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 84/94] 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 85/94] 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 86/94] 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 87/94] 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 88/94] 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 89/94] 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 90/94] #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 e356e4adf5dbdf59def7fcbd9cd34a6888d307d6 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 15:58:42 +0100 Subject: [PATCH 91/94] 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 92/94] 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 114fbaed6335394be86e073badaede6c410e6bdb Mon Sep 17 00:00:00 2001 From: FredApps Date: Tue, 10 Nov 2026 17:22:44 +0100 Subject: [PATCH 93/94] 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 94/94] 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 +}